feat(studio): copy template scene graph into editable project (use-template works)
Build backend images / build content-svc (push) Failing after 53s
Build backend images / build file-svc (push) Failing after 51s
Build backend images / build gateway (push) Failing after 1m2s
Build backend images / build identity-svc (push) Failing after 57s
Build backend images / build notification-svc (push) Failing after 45s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 53s

Build now created an EMPTY project: (1) the studio binds camelCase but the frontend
sent snake_case → original_project_id dropped to Guid.Empty; (2) CreateProjectAsync
never copied scenes. Now:
- saved-projects.ts sends camelCase (originalProjectId/copyDefaultValues).
- /api/projects resolves the container slug → first published variant content project.
- StudioService.CreateProjectAsync deep-copies the content scene graph (scenes +
  content elements + scene colours + shared colours) into the new saved project via
  one atomic cross-schema SQL copy (enum cols cast to text; temp scene-id map).

Verified: insta-promo → 1 scene, 6 content fields, 4 shared colours, loadable by the
studio editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-05 09:27:25 +03:30
parent baf6e40dde
commit 0ca11f19dd
3 changed files with 117 additions and 2 deletions
@@ -81,11 +81,84 @@ public class StudioService(StudioDbContext db)
ProjectDurationSec = 0,
};
await using var tx = await db.Database.BeginTransactionAsync();
db.SavedProjects.Add(project);
await db.SaveChangesAsync();
// Deep-copy the template's scene graph (scenes + content/colour elements) from
// the content schema so the new project opens editable in the studio. Same DB,
// so this is one atomic cross-schema copy.
if (req.CopyDefaultValues && req.OriginalProjectId != Guid.Empty)
await CopyTemplateGraphAsync(project.Id, req.OriginalProjectId);
await tx.CommitAsync();
return await GetProjectAsync(project.Id, userId);
}
/// <summary>
/// Copies a content template project's scenes, content elements and colour elements
/// into a freshly-created editable project. Runs inside the caller's transaction;
/// the temp scene-id map drops on commit. Same Postgres DB → atomic cross-schema copy.
/// </summary>
private async Task CopyTemplateGraphAsync(Guid savedProjectId, Guid originalProjectId)
{
// 1. scenes → capture old→new id mapping in a temp table
await db.Database.ExecuteSqlRawAsync(@"
CREATE TEMP TABLE _scene_map ON COMMIT DROP AS
WITH ins AS (
INSERT INTO studio.saved_scenes
(saved_project_id, original_scene_id, key, title, image, demo,
scene_color_svg, scene_type, sort, scene_length_sec, min_duration_sec,
max_duration_sec, overlap_at_end_sec, can_handle_duration,
manual_color_selection, created_at, updated_at)
SELECT {0}, s.id, s.key, s.title, s.image, s.demo, s.scene_color_svg,
s.scene_type::text, s.sort, COALESCE(s.default_duration_sec, 0),
s.min_duration_sec, s.max_duration_sec, s.overlap_at_end_sec,
s.can_handle_duration, s.manual_color_selection, now(), now()
FROM content.scenes s
WHERE s.project_id = {1} AND s.deleted_at IS NULL AND s.is_active = true
RETURNING id AS new_id, original_scene_id AS old_id
)
SELECT new_id, old_id FROM ins;",
savedProjectId, originalProjectId);
// 2. content elements (skip repeater children for now)
await db.Database.ExecuteSqlRawAsync(@"
INSERT INTO studio.saved_scene_contents
(saved_scene_id, key, title, localized_title, hint, type, value,
font_face, font_face_name, font_size, default_font_size, default_font_face,
justify, position_in_container, direction_layer_value, is_text_box,
ai_input_type, mapped_list, thumbnail, sort, status, created_at, updated_at)
SELECT sm.new_id, ce.key, ce.title, ce.localized_title, ce.hint, ce.type::text,
ce.default_value, ce.font_face, ce.font_face_name, ce.font_size,
ce.default_font_size, ce.default_font_face, ce.justify::text,
ce.position_in_container, ce.direction_layer_value, ce.is_text_box,
ce.ai_input_type::text, ce.mapped_list, ce.thumbnail, ce.sort, 'default',
now(), now()
FROM content.scene_content_elements ce
JOIN _scene_map sm ON sm.old_id = ce.scene_id
WHERE ce.repeater_item_id IS NULL;");
// 3. colour elements
await db.Database.ExecuteSqlRawAsync(@"
INSERT INTO studio.saved_scene_colors
(saved_scene_id, element_key, title, icon, attr_value, value, is_selected, sort)
SELECT sm.new_id, ce.element_key, ce.title, ce.icon, ce.attr_value::text,
COALESCE(ce.default_color, ''), false, ce.sort
FROM content.scene_color_elements ce
JOIN _scene_map sm ON sm.old_id = ce.scene_id;");
// 4. shared (project-level) colours — frshare frd_* controls
await db.Database.ExecuteSqlRawAsync(@"
INSERT INTO studio.saved_shared_colors
(saved_project_id, element_key, title, icon, attr_value, value, is_selected, sort)
SELECT {0}, sc.element_key, sc.title, sc.icon, sc.attr_value::text,
COALESCE(sc.default_color, ''), false, sc.sort
FROM content.shared_colors sc
WHERE sc.project_id = {1};",
savedProjectId, originalProjectId);
}
public async Task<SavedProjectFullResponse> UpdateProjectAsync(
Guid id, Guid userId, UpdateSavedProjectRequest req)
{