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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-30 05:56:25 +03:30
parent f366d73697
commit 14cdb772b4
4 changed files with 298 additions and 198 deletions
+48 -93
View File
@@ -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<string, unknown> = {};
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<string, unknown> = {
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,
},
});
}
+49 -84
View File
@@ -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 }
);
}