2026-02-20 13:43:25 -06:00
|
|
|
import { eq, inArray } from "drizzle-orm";
|
2026-02-16 13:31:58 -06:00
|
|
|
import type { Db } from "@paperclip/db";
|
2026-02-20 13:43:25 -06:00
|
|
|
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<ProjectWithGoals[]> {
|
|
|
|
|
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<string, ProjectGoalRef[]>();
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-16 13:31:58 -06:00
|
|
|
|
|
|
|
|
export function projectService(db: Db) {
|
|
|
|
|
return {
|
2026-02-20 13:43:25 -06:00
|
|
|
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
|
|
|
|
|
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
|
|
|
|
|
return attachGoals(db, rows);
|
|
|
|
|
},
|
2026-02-16 13:31:58 -06:00
|
|
|
|
2026-02-20 13:43:25 -06:00
|
|
|
getById: async (id: string): Promise<ProjectWithGoals | null> => {
|
|
|
|
|
const row = await db
|
2026-02-16 13:31:58 -06:00
|
|
|
.select()
|
|
|
|
|
.from(projects)
|
|
|
|
|
.where(eq(projects.id, id))
|
2026-02-20 13:43:25 -06:00
|
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
const [enriched] = await attachGoals(db, [row]);
|
|
|
|
|
return enriched;
|
|
|
|
|
},
|
2026-02-16 13:31:58 -06:00
|
|
|
|
2026-02-20 13:43:25 -06:00
|
|
|
create: async (
|
|
|
|
|
companyId: string,
|
|
|
|
|
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
|
|
|
|
|
): Promise<ProjectWithGoals> => {
|
|
|
|
|
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
|
2026-02-16 13:31:58 -06:00
|
|
|
.insert(projects)
|
2026-02-20 13:43:25 -06:00
|
|
|
.values({ ...projectData, goalId: legacyGoalId, companyId })
|
2026-02-16 13:31:58 -06:00
|
|
|
.returning()
|
2026-02-20 13:43:25 -06:00
|
|
|
.then((rows) => rows[0]);
|
2026-02-16 13:31:58 -06:00
|
|
|
|
2026-02-20 13:43:25 -06:00
|
|
|
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<typeof projects.$inferInsert> & { goalIds?: string[] },
|
|
|
|
|
): Promise<ProjectWithGoals | null> => {
|
|
|
|
|
const { goalIds: inputGoalIds, ...projectData } = data;
|
|
|
|
|
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
|
|
|
|
|
|
|
|
|
// Keep legacy goalId column in sync
|
|
|
|
|
const updates: Partial<typeof projects.$inferInsert> = {
|
|
|
|
|
...projectData,
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
};
|
|
|
|
|
if (ids !== undefined) {
|
|
|
|
|
updates.goalId = ids.length > 0 ? ids[0] : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const row = await db
|
2026-02-16 13:31:58 -06:00
|
|
|
.update(projects)
|
2026-02-20 13:43:25 -06:00
|
|
|
.set(updates)
|
2026-02-16 13:31:58 -06:00
|
|
|
.where(eq(projects.id, id))
|
|
|
|
|
.returning()
|
2026-02-20 13:43:25 -06:00
|
|
|
.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;
|
|
|
|
|
},
|
2026-02-16 13:31:58 -06:00
|
|
|
|
|
|
|
|
remove: (id: string) =>
|
|
|
|
|
db
|
|
|
|
|
.delete(projects)
|
|
|
|
|
.where(eq(projects.id, id))
|
|
|
|
|
.returning()
|
|
|
|
|
.then((rows) => rows[0] ?? null),
|
|
|
|
|
};
|
|
|
|
|
}
|