withAudit war als tRPC-Wrapper definiert, aber an keine Procedure gehaengt (audit_event blieb leer). Jetzt eine echte tRPC-v11-Middleware-Procedure (auditedProcedure), die nach erfolgreicher Mutation best-effort einen audit_event schreibt (Fehler beim Audit kippen die Mutation nicht). Verdrahtet an die folgenreichen Zustandsuebergaenge: - blocker.resolve - project.archive, project.setPhase, project.addPhaseGate Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
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, auditedProcedure } 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,
|
|
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: auditedProcedure('project.archive')
|
|
.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: auditedProcedure('project.setPhase')
|
|
.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: auditedProcedure('project.addPhaseGate')
|
|
.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));
|
|
}),
|
|
});
|