import { and, asc, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclip/db"; import { projects, projectGoals, goals, projectWorkspaces } from "@paperclip/db"; import { PROJECT_COLORS, type ProjectGoalRef, type ProjectWorkspace } from "@paperclip/shared"; type ProjectRow = typeof projects.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; type CreateWorkspaceInput = { name?: string | null; cwd?: string | null; repoUrl?: string | null; repoRef?: string | null; metadata?: Record | null; isPrimary?: boolean; }; type UpdateWorkspaceInput = Partial; interface ProjectWithGoals extends ProjectRow { goalIds: string[]; goals: ProjectGoalRef[]; workspaces: ProjectWorkspace[]; primaryWorkspace: ProjectWorkspace | null; } /** Batch-load goal refs for a set of projects. */ async function attachGoals(db: Db, rows: ProjectRow[]): Promise { if (rows.length === 0) return []; const projectIds = rows.map((r) => r.id); // Fetch join rows + goal titles in one query const links = await db .select({ projectId: projectGoals.projectId, goalId: projectGoals.goalId, goalTitle: goals.title, }) .from(projectGoals) .innerJoin(goals, eq(projectGoals.goalId, goals.id)) .where(inArray(projectGoals.projectId, projectIds)); const map = new Map(); for (const link of links) { let arr = map.get(link.projectId); if (!arr) { arr = []; map.set(link.projectId, arr); } arr.push({ id: link.goalId, title: link.goalTitle }); } return rows.map((r) => { const g = map.get(r.id) ?? []; return { ...r, goalIds: g.map((x) => x.id), goals: g } as ProjectWithGoals; }); } function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace { return { id: row.id, companyId: row.companyId, projectId: row.projectId, name: row.name, cwd: row.cwd, repoUrl: row.repoUrl ?? null, repoRef: row.repoRef ?? null, metadata: (row.metadata as Record | null) ?? null, isPrimary: row.isPrimary, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } function pickPrimaryWorkspace(rows: ProjectWorkspaceRow[]): ProjectWorkspace | null { if (rows.length === 0) return null; const explicitPrimary = rows.find((row) => row.isPrimary); return toWorkspace(explicitPrimary ?? rows[0]); } /** Batch-load workspace refs for a set of projects. */ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise { if (rows.length === 0) return []; const projectIds = rows.map((r) => r.id); const workspaceRows = await db .select() .from(projectWorkspaces) .where(inArray(projectWorkspaces.projectId, projectIds)) .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); const map = new Map(); for (const row of workspaceRows) { let arr = map.get(row.projectId); if (!arr) { arr = []; map.set(row.projectId, arr); } arr.push(row); } return rows.map((row) => { const projectWorkspaceRows = map.get(row.id) ?? []; const workspaces = projectWorkspaceRows.map(toWorkspace); return { ...row, workspaces, primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows), }; }); } /** Sync the project_goals join table for a single project. */ async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) { // Delete existing links await db.delete(projectGoals).where(eq(projectGoals.projectId, projectId)); // Insert new links if (goalIds.length > 0) { await db.insert(projectGoals).values( goalIds.map((goalId) => ({ projectId, goalId, companyId })), ); } } /** Resolve goalIds from input, handling the legacy goalId field. */ function resolveGoalIds(data: { goalIds?: string[]; goalId?: string | null }): string[] | undefined { if (data.goalIds !== undefined) return data.goalIds; if (data.goalId !== undefined) { return data.goalId ? [data.goalId] : []; } return undefined; } function readNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function normalizeWorkspaceCwd(value: unknown): string | null { const cwd = readNonEmptyString(value); if (!cwd) return null; return cwd === REPO_ONLY_CWD_SENTINEL ? null : cwd; } function deriveNameFromCwd(cwd: string): string { const normalized = cwd.replace(/[\\/]+$/, ""); const segments = normalized.split(/[\\/]/).filter(Boolean); return segments[segments.length - 1] ?? "Local folder"; } function deriveNameFromRepoUrl(repoUrl: string): string { try { const url = new URL(repoUrl); const cleanedPath = url.pathname.replace(/\/+$/, ""); const lastSegment = cleanedPath.split("/").filter(Boolean).pop() ?? ""; const noGitSuffix = lastSegment.replace(/\.git$/i, ""); return noGitSuffix || repoUrl; } catch { return repoUrl; } } function deriveWorkspaceName(input: { name?: string | null; cwd?: string | null; repoUrl?: string | null; }) { const explicit = readNonEmptyString(input.name); if (explicit) return explicit; const cwd = readNonEmptyString(input.cwd); if (cwd) return deriveNameFromCwd(cwd); const repoUrl = readNonEmptyString(input.repoUrl); if (repoUrl) return deriveNameFromRepoUrl(repoUrl); return "Workspace"; } async function ensureSinglePrimaryWorkspace( dbOrTx: any, input: { companyId: string; projectId: string; keepWorkspaceId: string; }, ) { await dbOrTx .update(projectWorkspaces) .set({ isPrimary: false, updatedAt: new Date() }) .where( and( eq(projectWorkspaces.companyId, input.companyId), eq(projectWorkspaces.projectId, input.projectId), ), ); await dbOrTx .update(projectWorkspaces) .set({ isPrimary: true, updatedAt: new Date() }) .where( and( eq(projectWorkspaces.companyId, input.companyId), eq(projectWorkspaces.projectId, input.projectId), eq(projectWorkspaces.id, input.keepWorkspaceId), ), ); } export function projectService(db: Db) { return { list: async (companyId: string): Promise => { const rows = await db.select().from(projects).where(eq(projects.companyId, companyId)); const withGoals = await attachGoals(db, rows); return attachWorkspaces(db, withGoals); }, getById: async (id: string): Promise => { const row = await db .select() .from(projects) .where(eq(projects.id, id)) .then((rows) => rows[0] ?? null); if (!row) return null; const [withGoals] = await attachGoals(db, [row]); if (!withGoals) return null; const [enriched] = await attachWorkspaces(db, [withGoals]); return enriched ?? null; }, create: async ( companyId: string, data: Omit & { goalIds?: string[] }, ): Promise => { const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); // Auto-assign a color from the palette if none provided if (!projectData.color) { const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId)); const usedColors = new Set(existing.map((r) => r.color).filter(Boolean)); const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length]; projectData.color = nextColor; } // Also write goalId to the legacy column (first goal or null) const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null; const row = await db .insert(projects) .values({ ...projectData, goalId: legacyGoalId, companyId }) .returning() .then((rows) => rows[0]); if (ids && ids.length > 0) { await syncGoalLinks(db, row.id, companyId, ids); } const [withGoals] = await attachGoals(db, [row]); const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : []; return enriched!; }, update: async ( id: string, data: Partial & { goalIds?: string[] }, ): Promise => { const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); // Keep legacy goalId column in sync const updates: Partial = { ...projectData, updatedAt: new Date(), }; if (ids !== undefined) { updates.goalId = ids.length > 0 ? ids[0] : null; } const row = await db .update(projects) .set(updates) .where(eq(projects.id, id)) .returning() .then((rows) => rows[0] ?? null); if (!row) return null; if (ids !== undefined) { await syncGoalLinks(db, id, row.companyId, ids); } const [withGoals] = await attachGoals(db, [row]); const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : []; return enriched ?? null; }, remove: (id: string) => db .delete(projects) .where(eq(projects.id, id)) .returning() .then((rows) => rows[0] ?? null), listWorkspaces: async (projectId: string): Promise => { const rows = await db .select() .from(projectWorkspaces) .where(eq(projectWorkspaces.projectId, projectId)) .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); return rows.map(toWorkspace); }, createWorkspace: async ( projectId: string, data: CreateWorkspaceInput, ): Promise => { const project = await db .select() .from(projects) .where(eq(projects.id, projectId)) .then((rows) => rows[0] ?? null); if (!project) return null; const cwd = normalizeWorkspaceCwd(data.cwd); const repoUrl = readNonEmptyString(data.repoUrl); if (!cwd && !repoUrl) return null; const name = deriveWorkspaceName({ name: data.name, cwd, repoUrl, }); const existing = await db .select() .from(projectWorkspaces) .where(eq(projectWorkspaces.projectId, projectId)) .orderBy(asc(projectWorkspaces.createdAt)) .then((rows) => rows); const shouldBePrimary = data.isPrimary === true || existing.length === 0; const created = await db.transaction(async (tx) => { if (shouldBePrimary) { await tx .update(projectWorkspaces) .set({ isPrimary: false, updatedAt: new Date() }) .where( and( eq(projectWorkspaces.companyId, project.companyId), eq(projectWorkspaces.projectId, projectId), ), ); } const row = await tx .insert(projectWorkspaces) .values({ companyId: project.companyId, projectId, name, cwd: cwd ?? null, repoUrl: repoUrl ?? null, repoRef: readNonEmptyString(data.repoRef), metadata: (data.metadata as Record | null | undefined) ?? null, isPrimary: shouldBePrimary, }) .returning() .then((rows) => rows[0] ?? null); return row; }); return created ? toWorkspace(created) : null; }, updateWorkspace: async ( projectId: string, workspaceId: string, data: UpdateWorkspaceInput, ): Promise => { const existing = await db .select() .from(projectWorkspaces) .where( and( eq(projectWorkspaces.id, workspaceId), eq(projectWorkspaces.projectId, projectId), ), ) .then((rows) => rows[0] ?? null); if (!existing) return null; const nextCwd = data.cwd !== undefined ? normalizeWorkspaceCwd(data.cwd) : normalizeWorkspaceCwd(existing.cwd); const nextRepoUrl = data.repoUrl !== undefined ? readNonEmptyString(data.repoUrl) : readNonEmptyString(existing.repoUrl); if (!nextCwd && !nextRepoUrl) return null; const patch: Partial = { updatedAt: new Date(), }; if (data.name !== undefined) patch.name = deriveWorkspaceName({ name: data.name, cwd: nextCwd, repoUrl: nextRepoUrl }); if (data.name === undefined && (data.cwd !== undefined || data.repoUrl !== undefined)) { patch.name = deriveWorkspaceName({ cwd: nextCwd, repoUrl: nextRepoUrl }); } if (data.cwd !== undefined) patch.cwd = nextCwd ?? null; if (data.repoUrl !== undefined) patch.repoUrl = nextRepoUrl ?? null; if (data.repoRef !== undefined) patch.repoRef = readNonEmptyString(data.repoRef); if (data.metadata !== undefined) patch.metadata = data.metadata; const updated = await db.transaction(async (tx) => { if (data.isPrimary === true) { await tx .update(projectWorkspaces) .set({ isPrimary: false, updatedAt: new Date() }) .where( and( eq(projectWorkspaces.companyId, existing.companyId), eq(projectWorkspaces.projectId, projectId), ), ); patch.isPrimary = true; } else if (data.isPrimary === false) { patch.isPrimary = false; } const row = await tx .update(projectWorkspaces) .set(patch) .where(eq(projectWorkspaces.id, workspaceId)) .returning() .then((rows) => rows[0] ?? null); if (!row) return null; if (row.isPrimary) return row; const hasPrimary = await tx .select({ id: projectWorkspaces.id }) .from(projectWorkspaces) .where( and( eq(projectWorkspaces.companyId, row.companyId), eq(projectWorkspaces.projectId, row.projectId), eq(projectWorkspaces.isPrimary, true), ), ) .then((rows) => rows[0] ?? null); if (!hasPrimary) { const nextPrimaryCandidate = await tx .select({ id: projectWorkspaces.id }) .from(projectWorkspaces) .where( and( eq(projectWorkspaces.companyId, row.companyId), eq(projectWorkspaces.projectId, row.projectId), eq(projectWorkspaces.id, row.id), ), ) .then((rows) => rows[0] ?? null); const alternateCandidate = await tx .select({ id: projectWorkspaces.id }) .from(projectWorkspaces) .where( and( eq(projectWorkspaces.companyId, row.companyId), eq(projectWorkspaces.projectId, row.projectId), ), ) .orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) .then((rows) => rows.find((candidate) => candidate.id !== row.id) ?? null); await ensureSinglePrimaryWorkspace(tx, { companyId: row.companyId, projectId: row.projectId, keepWorkspaceId: alternateCandidate?.id ?? nextPrimaryCandidate?.id ?? row.id, }); const refreshed = await tx .select() .from(projectWorkspaces) .where(eq(projectWorkspaces.id, row.id)) .then((rows) => rows[0] ?? row); return refreshed; } return row; }); return updated ? toWorkspace(updated) : null; }, removeWorkspace: async (projectId: string, workspaceId: string): Promise => { const existing = await db .select() .from(projectWorkspaces) .where( and( eq(projectWorkspaces.id, workspaceId), eq(projectWorkspaces.projectId, projectId), ), ) .then((rows) => rows[0] ?? null); if (!existing) return null; const removed = await db.transaction(async (tx) => { const row = await tx .delete(projectWorkspaces) .where(eq(projectWorkspaces.id, workspaceId)) .returning() .then((rows) => rows[0] ?? null); if (!row) return null; if (!row.isPrimary) return row; const next = await tx .select() .from(projectWorkspaces) .where( and( eq(projectWorkspaces.companyId, row.companyId), eq(projectWorkspaces.projectId, row.projectId), ), ) .orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) .limit(1) .then((rows) => rows[0] ?? null); if (next) { await ensureSinglePrimaryWorkspace(tx, { companyId: row.companyId, projectId: row.projectId, keepWorkspaceId: next.id, }); } return row; }); return removed ? toWorkspace(removed) : null; }, }; }