Stage 3 of per-project Coolify isolation. Adds an authoritative ownership
table so apps_list { projectId } returns ONLY the resources actually owned
by that Vibn project, even when multiple Vibn projects share a single
Coolify project (the legacy workspace-level vibn-ws-{slug}).
- New table fs_project_resources (project_id, resource_uuid, type, workspace).
Auto-created on first use.
- lib/projects.ts: linkResourceToProject / unlinkResource /
getProjectResourceUuids / getProjectIdForResource helpers.
- apps_list { projectId }: when the project's coolifyProjectUuid equals the
legacy workspace project, restrict results to explicitly-linked resources.
When it has a dedicated Coolify project, return everything in that project.
- apps_create / databases_create: auto-link the newly-created resource to
the requesting Vibn project.
- apps_delete / databases_delete / services_delete: unlink on success.
- projects_get → possibleDeployments: prefer explicit links; fuzzy-match
fallback only fires when no link table entry exists yet.
- POST /api/projects/backfill-isolation: idempotent migration that mints a
dedicated Coolify project for every Vibn project AND records existing
coolifyServiceUuid/coolifyAppUuid/coolifyDatabaseUuid links. Resolves
the "Twenty CRM project shows n8n" bug for legacy projects without
needing to physically move services in Coolify.
Made-with: Cursor
132 lines
3.8 KiB
TypeScript
132 lines
3.8 KiB
TypeScript
/**
|
|
* 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 { authSession } from '@/lib/auth/session-server';
|
|
import { query } from '@/lib/db-postgres';
|
|
import { getOrCreateProvisionedWorkspace } 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() {
|
|
const session = await authSession();
|
|
if (!session?.user?.email) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
const email = session.user.email;
|
|
|
|
// Resolve workspace + load all of the user's projects.
|
|
const users = await query<{ id: string }>(
|
|
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
|
[email],
|
|
);
|
|
if (users.length === 0) {
|
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
}
|
|
const firebaseUserId = users[0].id;
|
|
|
|
const ws = await getOrCreateProvisionedWorkspace({
|
|
userId: firebaseUserId,
|
|
email,
|
|
displayName: session.user.name ?? email,
|
|
});
|
|
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,
|
|
});
|
|
}
|