From 14cdb772b4230dbf33c5084118738d028e286cda Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sat, 30 May 2026 05:56:25 +0330 Subject: [PATCH] feat(frontend): migrate dashboard projects flow off Supabase to V2 Studio User-saved projects now read/write through the gateway /v1/saved-projects (studio schema) using the Identity access-token cookie, replacing the Supabase `projects` table. Adds src/lib/api/saved-projects.ts client that maps studio snake_case DTOs into the existing DashboardProject shape. - DashboardProjectsContent: lists via studio service, degrades gracefully - /api/projects GET: studio list; POST: copy-from-template create (studio requires original_project_id; falls back to scene_data.templateId) - /api/projects/[projectId] GET/PATCH: proxy to studio with JWT Co-Authored-By: Claude Opus 4.7 --- src/app/api/projects/[projectId]/route.ts | 141 +++++-------- src/app/api/projects/route.ts | 133 +++++------- .../dashboard/DashboardProjectsContent.tsx | 26 +-- src/lib/api/saved-projects.ts | 196 ++++++++++++++++++ 4 files changed, 298 insertions(+), 198 deletions(-) create mode 100644 src/lib/api/saved-projects.ts diff --git a/src/app/api/projects/[projectId]/route.ts b/src/app/api/projects/[projectId]/route.ts index b98507d..839b746 100644 --- a/src/app/api/projects/[projectId]/route.ts +++ b/src/app/api/projects/[projectId]/route.ts @@ -1,10 +1,9 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import type { ProjectRow } from "@/lib/projects"; +import { getSavedProject, updateSavedProject } from "@/lib/api/saved-projects"; +import { getAccessToken } from "@/lib/auth/session"; import { isDevProjectId } from "@/lib/project-ids"; -import { isSupabaseConfigured } from "@/lib/supabase/config"; -import { createClient } from "@/lib/supabase/server"; export const dynamic = "force-dynamic"; @@ -24,49 +23,30 @@ export async function GET(_request: Request, context: RouteContext) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } - if (!isSupabaseConfigured()) { - if (process.env.NODE_ENV === "production") { - return NextResponse.json( - { error: "Supabase is not configured", code: "SUPABASE_NOT_CONFIGURED" }, - { status: 503 } - ); - } - return NextResponse.json({ error: "Project not found" }, { status: 404 }); - } - - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { + const token = await getAccessToken(); + if (!token) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { data, error } = await supabase - .from("projects") - .select("*") - .eq("id", projectId) - .eq("user_id", user.id) - .maybeSingle(); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + const result = await getSavedProject(projectId); + if (!result.ok || !result.data) { + return NextResponse.json( + { error: result.error ?? "Project not found" }, + { status: result.status === 404 ? 404 : result.status } + ); } - if (!data) { - return NextResponse.json({ error: "Project not found" }, { status: 404 }); - } - - const row = data as ProjectRow; + const p = result.data; return NextResponse.json({ project: { - id: row.id, - name: row.name, - type: row.type, - scene_data: row.scene_data, - status: row.status, - updated_at: row.updated_at, + id: p.id, + name: p.name, + type: p.type, + // The studio service owns the normalized scene graph; expose the full + // payload as scene_data so the editor can hydrate from it. + scene_data: p, + status: "draft", + updated_at: p.last_edit_date, }, }); } @@ -78,6 +58,11 @@ export async function PATCH(request: Request, context: RouteContext) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } + const token = await getAccessToken(); + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + let body: unknown; try { body = await request.json(); @@ -94,66 +79,36 @@ export async function PATCH(request: Request, context: RouteContext) { } if (!parsed.data.scene_data && !parsed.data.name) { + return NextResponse.json({ error: "Nothing to update" }, { status: 400 }); + } + + // The studio PATCH endpoint updates project metadata (name + edit state). + // Scene graphs are saved through the dedicated PUT /scenes endpoint, so here + // we forward name and any edit_state carried in scene_data. + const update: Record = {}; + if (parsed.data.name !== undefined) update.name = parsed.data.name; + if (parsed.data.scene_data !== undefined) { + const editState = parsed.data.scene_data.edit_state; + if (typeof editState === "string") update.edit_state = editState; + } + + const result = await updateSavedProject(projectId, update); + if (!result.ok || !result.data) { return NextResponse.json( - { error: "Nothing to update" }, - { status: 400 } + { error: result.error ?? "Project not found" }, + { status: result.status === 404 ? 404 : result.status } ); } - if (!isSupabaseConfigured()) { - if (process.env.NODE_ENV === "production") { - return NextResponse.json( - { error: "Supabase is not configured", code: "SUPABASE_NOT_CONFIGURED" }, - { status: 503 } - ); - } - return NextResponse.json({ error: "Project not found" }, { status: 404 }); - } - - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const updates: Record = { - updated_at: new Date().toISOString(), - }; - if (parsed.data.scene_data !== undefined) { - updates.scene_data = parsed.data.scene_data; - } - if (parsed.data.name !== undefined) { - updates.name = parsed.data.name; - } - - const { data, error } = await supabase - .from("projects") - .update(updates) - .eq("id", projectId) - .eq("user_id", user.id) - .select("*") - .maybeSingle(); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } - - if (!data) { - return NextResponse.json({ error: "Project not found" }, { status: 404 }); - } - - const row = data as ProjectRow; + const p = result.data; return NextResponse.json({ project: { - id: row.id, - name: row.name, - type: row.type, - scene_data: row.scene_data, - status: row.status, - updated_at: row.updated_at, + id: p.id, + name: p.name, + type: p.type, + scene_data: p, + status: "draft", + updated_at: p.last_edit_date, }, }); } diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 772b16b..bb33022 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,14 +1,12 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { buildMockProjectRow } from "@/lib/dev-mock-project"; import { - createDefaultSceneData, - defaultProjectName, -} from "@/lib/project-defaults"; -import { mapProjectRow, type ProjectRow } from "@/lib/projects"; -import { isSupabaseConfigured } from "@/lib/supabase/config"; -import { createClient } from "@/lib/supabase/server"; + createSavedProject, + listSavedProjects, + savedProjectToDashboard, +} from "@/lib/api/saved-projects"; +import { getAccessToken } from "@/lib/auth/session"; export const dynamic = "force-dynamic"; @@ -16,46 +14,28 @@ const createProjectSchema = z.object({ name: z.string().min(1).max(120).optional(), type: z.enum(["video", "image", "trimmer"]), scene_data: z.record(z.string(), z.unknown()).optional(), + // V2 studio creates a saved project by copying a content template container. + // The original/template project id may be passed explicitly or carried inside + // scene_data.templateId by the legacy create helpers. + original_project_id: z.string().uuid().optional(), }); export async function GET() { - if (!isSupabaseConfigured()) { - if (process.env.NODE_ENV === "production") { - return NextResponse.json( - { - error: "Supabase is not configured", - code: "SUPABASE_NOT_CONFIGURED", - }, - { status: 503 } - ); - } - return NextResponse.json({ projects: [] }); - } - - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { + const token = await getAccessToken(); + if (!token) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { data, error } = await supabase - .from("projects") - .select("*") - .eq("user_id", user.id) - .order("updated_at", { ascending: false }); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } - - const projects = ((data ?? []) as ProjectRow[]).map(mapProjectRow); + const projects = await listSavedProjects({ pageSize: 100 }); return NextResponse.json({ projects }); } export async function POST(request: Request) { + const token = await getAccessToken(); + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + let body: unknown; try { body = await request.json(); @@ -71,56 +51,41 @@ export async function POST(request: Request) { ); } - const { type } = parsed.data; - const name = parsed.data.name ?? defaultProjectName(type); - const scene_data = - parsed.data.scene_data ?? createDefaultSceneData(type); + // Resolve the template/original project id: explicit field first, then the + // legacy `scene_data.templateId` carried by createProjectFromTemplate. + const templateIdFromScene = + typeof parsed.data.scene_data?.templateId === "string" + ? (parsed.data.scene_data.templateId as string) + : undefined; + const originalProjectId = + parsed.data.original_project_id ?? templateIdFromScene; - if (!isSupabaseConfigured()) { - if (process.env.NODE_ENV === "production") { - return NextResponse.json( - { - error: "Supabase is not configured", - code: "SUPABASE_NOT_CONFIGURED", - }, - { status: 503 } - ); - } - - const project = mapProjectRow( - buildMockProjectRow({ name, type, scene_data }) - ); - return NextResponse.json({ project }, { status: 201 }); - } - - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { data, error } = await supabase - .from("projects") - .insert({ - user_id: user.id, - name, - type, - scene_data, - status: "draft", - }) - .select("*") - .single(); - - if (error || !data) { + if (!originalProjectId) { return NextResponse.json( - { error: error?.message ?? "Failed to create project" }, - { status: 500 } + { + error: + "A template is required to create a project. Pick a template first.", + code: "TEMPLATE_REQUIRED", + }, + { status: 422 } ); } - const project = mapProjectRow(data as ProjectRow); - return NextResponse.json({ project }, { status: 201 }); + const result = await createSavedProject({ + original_project_id: originalProjectId, + name: parsed.data.name, + copy_default_values: true, + }); + + if (!result.ok || !result.data) { + return NextResponse.json( + { error: result.error ?? "Failed to create project" }, + { status: result.status === 401 ? 401 : 502 } + ); + } + + return NextResponse.json( + { project: savedProjectToDashboard(result.data) }, + { status: 201 } + ); } diff --git a/src/components/dashboard/DashboardProjectsContent.tsx b/src/components/dashboard/DashboardProjectsContent.tsx index 697a9d9..7414441 100644 --- a/src/components/dashboard/DashboardProjectsContent.tsx +++ b/src/components/dashboard/DashboardProjectsContent.tsx @@ -1,27 +1,11 @@ import { DashboardProjectsSection } from "@/components/dashboard/DashboardProjectsSection"; -import { mapProjectRow, type ProjectRow } from "@/lib/projects"; -import { isSupabaseConfigured } from "@/lib/supabase/config"; -import { createClient } from "@/lib/supabase/server"; +import { listSavedProjects } from "@/lib/api/saved-projects"; export async function DashboardProjectsContent() { - let projects: ReturnType[] = []; - - if (isSupabaseConfigured()) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - const { data } = user - ? await supabase - .from("projects") - .select("*") - .eq("user_id", user.id) - .order("updated_at", { ascending: false }) - : { data: [] }; - - projects = ((data ?? []) as ProjectRow[]).map(mapProjectRow); - } + // V2: user-saved projects come from the Studio service via the gateway, + // authenticated with the caller's access-token cookie. Degrades to an empty + // grid when signed out or the service is unreachable. + const projects = await listSavedProjects({ pageSize: 100 }); return ; } diff --git a/src/lib/api/saved-projects.ts b/src/lib/api/saved-projects.ts new file mode 100644 index 0000000..77b940f --- /dev/null +++ b/src/lib/api/saved-projects.ts @@ -0,0 +1,196 @@ +/** + * Server-side client for the FlatRender V2 Studio service's saved-projects API. + * + * Replaces the legacy Supabase `projects` table. User-saved projects now live in + * the `studio` schema and are reached through the gateway at `/v1/saved-projects`, + * authenticated with the caller's Identity access JWT (read from the httpOnly + * `fr_access` cookie). + * + * Studio JSON bodies are snake_case (responses + request bodies), but query-string + * params bind by C# property name (camelCase) — see project memory. This module + * maps the studio DTOs into the camelCase `DashboardProject` the UI consumes, so + * dashboard call sites are unchanged. + */ + +import { gatewayUrl } from "@/lib/api/gateway"; +import { getAccessToken } from "@/lib/auth/session"; +import type { DashboardProject, ProjectType } from "@/lib/projects"; + +// ── V2 studio response shapes (snake_case JSON) ────────────────────────────── + +export interface V2SavedProjectSummary { + id: string; + user_id: string; + original_project_id: string; + original_project_name: string; + original_container_slug?: string | null; + name: string; + image?: string | null; + type: string; + resolution: string; + choose_mode: string; + project_duration_sec: number; + last_edit_date: string; + created_at: string; +} + +export interface V2SavedProjectFull extends V2SavedProjectSummary { + frame_rate: number; + edit_state: string; + last_edit_step?: string | null; + // The studio full payload carries the normalized scene graph; the dashboard + // does not need it, so we keep it loosely typed for pass-through. + scenes?: unknown[]; + shared_colors?: unknown[]; + shared_color_presets?: unknown[]; + shared_layers?: unknown[]; + [key: string]: unknown; +} + +interface V2Paged { + items: T[]; + meta: { page: number; page_size: number; total: number; total_pages: number }; +} + +// ── Mappers (V2 → UI shapes) ───────────────────────────────────────────────── + +/** Studio stores `type` as a free string; coerce to the UI's ProjectType. */ +function normalizeType(type: string | null | undefined): ProjectType { + switch ((type ?? "").toLowerCase()) { + case "image": + return "image"; + case "trimmer": + return "trimmer"; + default: + return "video"; + } +} + +function thumbnailSeed(id: string): string { + return id.replace(/-/g, "").slice(0, 12); +} + +export function savedProjectToDashboard( + p: V2SavedProjectSummary +): DashboardProject { + return { + id: p.id, + name: p.name, + type: normalizeType(p.type), + // Studio has no draft/rendering/ready lifecycle on the summary; treat saved + // projects as editable drafts until the render service reports otherwise. + status: "draft", + renderUrl: null, + thumbnailSeed: thumbnailSeed(p.id), + lastEditedAt: p.last_edit_date ?? p.created_at ?? new Date().toISOString(), + }; +} + +// ── Fetch helper (authenticated) ───────────────────────────────────────────── + +interface StudioResult { + ok: boolean; + status: number; + data: T | null; + error?: string; +} + +async function studioFetch( + path: string, + init?: RequestInit +): Promise> { + const token = await getAccessToken(); + if (!token) return { ok: false, status: 401, data: null, error: "Unauthorized" }; + + try { + const res = await fetch(gatewayUrl(path), { + ...init, + cache: "no-store", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(init?.headers ?? {}), + }, + }); + if (res.status === 204) return { ok: true, status: 204, data: null }; + const json = (await res.json().catch(() => null)) as T | null; + if (!res.ok) { + const err = + (json as { message?: string } | null)?.message ?? + `Studio service error (${res.status})`; + return { ok: false, status: res.status, data: null, error: err }; + } + return { ok: true, status: res.status, data: json }; + } catch { + return { ok: false, status: 502, data: null, error: "Studio service unreachable" }; + } +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * List the current user's saved projects (newest first per the studio service's + * own ordering). Returns an empty array when signed out or the service is down, + * so the dashboard degrades gracefully instead of throwing. + */ +export async function listSavedProjects(opts?: { + page?: number; + pageSize?: number; + q?: string; + type?: ProjectType; +}): Promise { + const params = new URLSearchParams(); + if (opts?.page) params.set("page", String(opts.page)); + params.set("pageSize", String(opts?.pageSize ?? 100)); + if (opts?.q) params.set("q", opts.q); + if (opts?.type) params.set("type", opts.type); + const qs = params.toString() ? `?${params}` : ""; + + const res = await studioFetch>( + `/v1/saved-projects${qs}` + ); + if (!res.ok || !res.data) return []; + return (res.data.items ?? []).map(savedProjectToDashboard); +} + +export async function getSavedProject( + id: string +): Promise> { + return studioFetch( + `/v1/saved-projects/${encodeURIComponent(id)}` + ); +} + +/** + * Create a saved project by copying a content template container. + * The studio service requires `original_project_id` (the content template + * project GUID) — there is no blank-project concept in V2. + */ +export async function createSavedProject(body: { + original_project_id: string; + name?: string; + preset_story_id?: string; + copy_default_values?: boolean; +}): Promise> { + return studioFetch("/v1/saved-projects", { + method: "POST", + body: JSON.stringify(body), + }); +} + +export async function updateSavedProject( + id: string, + body: Record +): Promise> { + return studioFetch( + `/v1/saved-projects/${encodeURIComponent(id)}`, + { method: "PATCH", body: JSON.stringify(body) } + ); +} + +export async function deleteSavedProject(id: string): Promise> { + return studioFetch(`/v1/saved-projects/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +}