import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { and, eq, desc } from 'drizzle-orm'; import { project, milestone, area, user, projectStatus, phaseGate, type Project } from '@cockpit/db'; import { ProjectSchemas } from '@cockpit/shared'; import { router, protectedProcedure } from '../trpc.js'; const PHASE_ORDER = ['definition', 'planung', 'umsetzung', 'abschluss'] as const; function nextPhase(p: (typeof PHASE_ORDER)[number]): (typeof PHASE_ORDER)[number] { const i = PHASE_ORDER.indexOf(p); return PHASE_ORDER[Math.min(i + 1, PHASE_ORDER.length - 1)]!; } const byAreaInput = z.object({ slug: z.string().min(1), includeArchived: z.boolean().optional(), }); const byIdInput = z.object({ id: z.string().uuid() }); export const projectRouter = router({ list: protectedProcedure.query(async ({ ctx }) => { const rows = await ctx.db .select({ project: project, area: area, verantwortliche: user, }) .from(project) .innerJoin(area, eq(project.areaId, area.id)) .leftJoin(user, eq(user.id, project.verantwortlicheUserId)) .where(eq(project.archiviert, false)); return rows; }), byAreaSlug: protectedProcedure.input(byAreaInput).query(async ({ ctx, input }) => { const a = (await ctx.db.select().from(area).where(eq(area.slug, input.slug)).limit(1))[0]; if (!a) return []; const projects = await ctx.db .select() .from(project) .where( input.includeArchived ? eq(project.areaId, a.id) : and(eq(project.areaId, a.id), eq(project.archiviert, false)), ) .orderBy(desc(project.createdAt)); return projects; }), byId: protectedProcedure.input(byIdInput).query(async ({ ctx, input }) => { const rows = await ctx.db .select({ project: project, area: area }) .from(project) .innerJoin(area, eq(area.id, project.areaId)) .where(eq(project.id, input.id)) .limit(1); const row = rows[0]; if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' }); const ms = await ctx.db.select().from(milestone).where(eq(milestone.projectId, row.project.id)); return { project: row.project, area: row.area, milestones: ms }; }), create: protectedProcedure .input(ProjectSchemas.createProjectInput) .mutation(async ({ ctx, input }) => { const result = await ctx.db.transaction(async (tx) => { const [created] = await tx .insert(project) .values({ areaId: input.areaId, name: input.name, description: input.description ?? null, logoUrl: input.logoUrl ?? null, kind: input.kind, verantwortlicheUserId: input.verantwortlicheUserId ?? null, arbeitsmodus: input.arbeitsmodus ?? null, outlineSteckbriefUrl: input.outlineSteckbriefUrl, planeProjectUrl: input.planeProjectUrl, risikolisteUrl: input.risikolisteUrl ?? null, dodText: input.dodText ?? null, start: input.start ?? null, deadline: input.deadline ?? null, createdBy: ctx.user.id, pflichtMinimumState: { steckbrief_freigegeben: false, verantwortlicher: !!input.verantwortlicheUserId, plane_board: true, meilensteine: input.milestones.length > 0, status_aktuell: false, retrospektive: false, }, }) .returning(); if (!created) throw new Error('Projekt konnte nicht angelegt werden.'); if (input.milestones.length > 0) { await tx.insert(milestone).values( input.milestones.map((m, idx) => ({ projectId: created.id, title: m.title, due: m.due ?? null, order: idx, })), ); } return created; }); return result; }), archive: protectedProcedure .input(ProjectSchemas.archiveProjectInput) .mutation(async ({ ctx, input }) => { const p = ( await ctx.db.select().from(project).where(eq(project.id, input.projectId)).limit(1) )[0]; if (!p) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' }); if (p.verantwortlicheUserId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Nur die/der Projektverantwortliche darf das Projekt abschließen.', }); } const [updated] = await ctx.db .update(project) .set({ archiviert: true, archiviertAm: new Date(), phase: 'abschluss', lessonsLearnedUrl: input.lessonsLearnedUrl, abschlussvermerk: input.abschlussvermerk ?? p.abschlussvermerk, pflichtMinimumState: { ...p.pflichtMinimumState, retrospektive: true }, }) .where(eq(project.id, input.projectId)) .returning(); return updated as Project; }), // Phase direkt setzen (Leitfaden Kap. 2). setPhase: protectedProcedure .input(ProjectSchemas.setPhaseInput) .mutation(async ({ ctx, input }) => { const [updated] = await ctx.db .update(project) .set({ phase: input.phase, updatedAt: new Date() }) .where(eq(project.id, input.projectId)) .returning(); if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' }); return updated; }), // Gate-Review am Phasenende (Leitfaden Kap. 5), optional Folgephase freigeben. addPhaseGate: protectedProcedure .input(ProjectSchemas.addPhaseGateInput) .mutation(async ({ ctx, input }) => { const [gate] = await ctx.db .insert(phaseGate) .values({ projectId: input.projectId, phase: input.phase, ergebnisErreicht: input.ergebnisErreicht, notiz: input.notiz ?? null, reviewedBy: ctx.user.id, }) .returning(); if (input.advance) { await ctx.db .update(project) .set({ phase: nextPhase(input.phase), updatedAt: new Date() }) .where(eq(project.id, input.projectId)); } return gate; }), phaseGates: protectedProcedure .input(ProjectSchemas.phaseGatesInput) .query(async ({ ctx, input }) => { return ctx.db .select() .from(phaseGate) .where(eq(phaseGate.projectId, input.projectId)) .orderBy(desc(phaseGate.reviewedAt)); }), // Statusbericht erfassen (Leitfaden Kap. 5) — speist statusCache + Pflicht-Minimum. addStatus: protectedProcedure .input(ProjectSchemas.addProjectStatusInput) .mutation(async ({ ctx, input }) => { const p = ( await ctx.db.select().from(project).where(eq(project.id, input.projectId)).limit(1) )[0]; if (!p) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' }); const [created] = await ctx.db .insert(projectStatus) .values({ projectId: input.projectId, ampel: input.ampel, trend: input.trend ?? null, text: input.text ?? null, externSichtbar: input.externSichtbar, createdBy: ctx.user.id, }) .returning(); await ctx.db .update(project) .set({ statusCache: input.ampel, trendCache: input.trend ?? null, pflichtMinimumState: { ...p.pflichtMinimumState, status_aktuell: true }, updatedAt: new Date(), }) .where(eq(project.id, input.projectId)); return created; }), statusHistory: protectedProcedure .input(ProjectSchemas.projectStatusHistoryInput) .query(async ({ ctx, input }) => { const rows = await ctx.db .select({ status: projectStatus, authorName: user.name }) .from(projectStatus) .leftJoin(user, eq(user.id, projectStatus.createdBy)) .where(eq(projectStatus.projectId, input.projectId)) .orderBy(desc(projectStatus.createdAt)); return rows.map((r) => ({ ...r.status, authorName: r.authorName })); }), setMilestoneStatus: protectedProcedure .input(ProjectSchemas.setMilestoneStatusInput) .mutation(async ({ ctx, input }) => { const [updated] = await ctx.db .update(milestone) .set({ status: input.status }) .where(eq(milestone.id, input.milestoneId)) .returning(); return updated; }), // Projekt-Modus setzen (Leitfaden): muss jederzeit ans Team kommuniziert sein. setModus: protectedProcedure .input(ProjectSchemas.setModusInput) .mutation(async ({ ctx, input }) => { const [updated] = await ctx.db .update(project) .set({ modus: input.modus, updatedAt: new Date() }) .where(eq(project.id, input.projectId)) .returning(); if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' }); return updated; }), // Maxime / Beschreibung / DoD bearbeiten (Ziel-Disziplin). update: protectedProcedure .input(ProjectSchemas.updateProjectInput) .mutation(async ({ ctx, input }) => { const { projectId, ...patch } = input; if (Object.keys(patch).length === 0) { const existing = ( await ctx.db.select().from(project).where(eq(project.id, projectId)).limit(1) )[0]; if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' }); return existing; } const [updated] = await ctx.db .update(project) .set({ ...patch, updatedAt: new Date() }) .where(eq(project.id, projectId)) .returning(); if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' }); return updated; }), addMilestone: protectedProcedure .input(ProjectSchemas.addMilestoneInput) .mutation(async ({ ctx, input }) => { const existing = await ctx.db .select({ id: milestone.id }) .from(milestone) .where(eq(milestone.projectId, input.projectId)); const [created] = await ctx.db .insert(milestone) .values({ projectId: input.projectId, title: input.title, due: input.due ?? null, order: existing.length, }) .returning(); // Pflicht-Minimum-Punkt „Meilensteine" nachziehen (Leitfaden: mind. 2). const p = ( await ctx.db.select().from(project).where(eq(project.id, input.projectId)).limit(1) )[0]; if (p) { await ctx.db .update(project) .set({ pflichtMinimumState: { ...p.pflichtMinimumState, meilensteine: existing.length + 1 >= 2, }, }) .where(eq(project.id, input.projectId)); } return created; }), updateMilestone: protectedProcedure .input(ProjectSchemas.updateMilestoneInput) .mutation(async ({ ctx, input }) => { const { milestoneId, ...patch } = input; if (Object.keys(patch).length === 0) { const existing = ( await ctx.db.select().from(milestone).where(eq(milestone.id, milestoneId)).limit(1) )[0]; if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Meilenstein nicht gefunden.' }); return existing; } const [updated] = await ctx.db .update(milestone) .set(patch) .where(eq(milestone.id, milestoneId)) .returning(); if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Meilenstein nicht gefunden.' }); return updated; }), removeMilestone: protectedProcedure .input(ProjectSchemas.removeMilestoneInput) .mutation(async ({ ctx, input }) => { const [removed] = await ctx.db .delete(milestone) .where(eq(milestone.id, input.milestoneId)) .returning({ projectId: milestone.projectId }); if (removed) { const rest = await ctx.db .select({ id: milestone.id }) .from(milestone) .where(eq(milestone.projectId, removed.projectId)); const p = ( await ctx.db.select().from(project).where(eq(project.id, removed.projectId)).limit(1) )[0]; if (p) { await ctx.db .update(project) .set({ pflichtMinimumState: { ...p.pflichtMinimumState, meilensteine: rest.length >= 2 }, }) .where(eq(project.id, removed.projectId)); } } return { ok: true as const }; }), listArchived: protectedProcedure.query(async ({ ctx }) => { return await ctx.db .select() .from(project) .where(eq(project.archiviert, true)) .orderBy(desc(project.archiviertAm)); }), });