chore: convert submodules to standard directories for true monorepo structure
This commit is contained in:
145
vibn-frontend/app/api/admin/backfill-isolation/route.ts
Normal file
145
vibn-frontend/app/api/admin/backfill-isolation/route.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Backfill endpoint: per-Vibn-project Coolify isolation.
|
||||
*
|
||||
* For each Vibn project in the caller's workspace, this:
|
||||
* 1. Mints a dedicated `vibn-{ws}-{slug}` Coolify project (idempotent).
|
||||
* 2. Records the project's existing linked Coolify resource (coolifyAppUuid
|
||||
* / coolifyServiceUuid) in fs_project_resources.
|
||||
*
|
||||
* After backfill, `apps_list { projectId }` will only surface the user's
|
||||
* actually-owned resources for that project, even when multiple projects
|
||||
* legacy-share a single Coolify project.
|
||||
*
|
||||
* Safe to re-run; everything is idempotent.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import { getOrCreateProvisionedWorkspace, type VibnWorkspace } from '@/lib/workspaces';
|
||||
import {
|
||||
ensureProjectCoolifyProject,
|
||||
ensureProjectResourcesTable,
|
||||
linkResourceToProject,
|
||||
type ResourceType,
|
||||
} from '@/lib/projects';
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface BackfillReport {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
beforeCoolifyProjectUuid: string | null;
|
||||
afterCoolifyProjectUuid: string | null;
|
||||
linkedResources: Array<{ uuid: string; type: ResourceType }>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Three accepted auth modes:
|
||||
// 1. NextAuth session (browser)
|
||||
// 2. Bearer vibn_sk_... workspace API key (matches /api/mcp)
|
||||
// 3. Bearer <NEXTAUTH_SECRET> + ?email=<owner> (ops bootstrap so the
|
||||
// maintainer can curl the backfill from a workstation without
|
||||
// needing a session cookie or pre-minted API key)
|
||||
let ws: VibnWorkspace | null = null;
|
||||
|
||||
const authHeader = request.headers.get('authorization') ?? '';
|
||||
const bearer = authHeader.toLowerCase().startsWith('bearer ')
|
||||
? authHeader.slice(7).trim()
|
||||
: '';
|
||||
const opsSecret = process.env.NEXTAUTH_SECRET;
|
||||
const url = new URL(request.url);
|
||||
const opsEmail = url.searchParams.get('email');
|
||||
|
||||
if (bearer && opsSecret && bearer === opsSecret && opsEmail) {
|
||||
const users = await query<{ id: string }>(
|
||||
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||
[opsEmail],
|
||||
);
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({ error: `No fs_users row for ${opsEmail}` }, { status: 404 });
|
||||
}
|
||||
ws = await getOrCreateProvisionedWorkspace({
|
||||
userId: users[0].id,
|
||||
email: opsEmail,
|
||||
displayName: opsEmail,
|
||||
});
|
||||
} else {
|
||||
const principal = await requireWorkspacePrincipal(request);
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
ws = principal.workspace;
|
||||
}
|
||||
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not provisioned' }, { status: 503 });
|
||||
}
|
||||
|
||||
await ensureProjectResourcesTable();
|
||||
|
||||
const projects = await query<ProjectRow>(
|
||||
`SELECT id, slug, data
|
||||
FROM fs_projects
|
||||
WHERE vibn_workspace_id = $1 OR workspace = $2
|
||||
ORDER BY created_at ASC`,
|
||||
[ws.id, ws.slug],
|
||||
);
|
||||
|
||||
const reports: BackfillReport[] = [];
|
||||
|
||||
for (const p of projects) {
|
||||
const data = p.data || {};
|
||||
const projectName = data.productName || data.name || data.title || p.slug;
|
||||
const before = (data.coolifyProjectUuid as string) || null;
|
||||
const warnings: string[] = [];
|
||||
|
||||
let after = before;
|
||||
try {
|
||||
// ensureProjectCoolifyProject is a no-op when already set.
|
||||
const ensured = await ensureProjectCoolifyProject(p.id, ws, {
|
||||
projectSlug: p.slug,
|
||||
projectName,
|
||||
});
|
||||
if (ensured) after = ensured;
|
||||
} catch (err: any) {
|
||||
warnings.push(`coolify provision failed: ${err?.message ?? err}`);
|
||||
}
|
||||
|
||||
// Record any pre-existing single-resource link.
|
||||
const linked: Array<{ uuid: string; type: ResourceType }> = [];
|
||||
const candidate: Array<[string | undefined, ResourceType]> = [
|
||||
[data.coolifyServiceUuid, 'service'],
|
||||
[data.coolifyAppUuid, 'application'],
|
||||
[data.coolifyDatabaseUuid, 'database'],
|
||||
];
|
||||
for (const [uuid, type] of candidate) {
|
||||
if (typeof uuid === 'string' && uuid.length > 0) {
|
||||
try {
|
||||
await linkResourceToProject(p.id, ws.slug, uuid, type);
|
||||
linked.push({ uuid, type });
|
||||
} catch (err: any) {
|
||||
warnings.push(`link ${type}=${uuid} failed: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reports.push({
|
||||
projectId: p.id,
|
||||
projectName,
|
||||
beforeCoolifyProjectUuid: before,
|
||||
afterCoolifyProjectUuid: after,
|
||||
linkedResources: linked,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
workspace: ws.slug,
|
||||
processed: reports.length,
|
||||
reports,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user