import { eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclip/db"; import { projects, projectGoals, goals } from "@paperclip/db"; import type { ProjectGoalRef } from "@paperclip/shared"; type ProjectRow = typeof projects.$inferSelect; interface ProjectWithGoals extends ProjectRow { goalIds: string[]; goals: ProjectGoalRef[]; } /** 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 }; }); } /** 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; } export function projectService(db: Db) { return { list: async (companyId: string): Promise => { const rows = await db.select().from(projects).where(eq(projects.companyId, companyId)); return attachGoals(db, rows); }, 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 [enriched] = await attachGoals(db, [row]); return enriched; }, create: async ( companyId: string, data: Omit & { goalIds?: string[] }, ): Promise => { const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); // 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 [enriched] = await attachGoals(db, [row]); 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 [enriched] = await attachGoals(db, [row]); return enriched; }, remove: (id: string) => db .delete(projects) .where(eq(projects.id, id)) .returning() .then((rows) => rows[0] ?? null), }; }