import { TRPCError } from '@trpc/server'; import { and, eq, isNull, lt, asc, desc, inArray } from 'drizzle-orm'; import { dayEntry, todo, blocker, user, type Db, type DayEntry, type Todo } from '@cockpit/db'; import { DayEntrySchemas } from '@cockpit/shared'; import { router, protectedProcedure } from '../trpc.js'; import { todayIso, previousWerktag, nextWerktag, addCalendarDays, workdayCutoffMs, isWorkdaySealed, } from '../lib/werktag.js'; import { kimaiClockIn, kimaiClockOut, kimaiEnabled } from '../lib/kimai.js'; /** * Der 6-Uhr-Schnitt: Ist um 06:00 (Berlin) noch ein Eintrag offen, dessen * Arbeitstag (= Check-in-Kalendertag) abgelaufen ist, wird er automatisch beendet * (checkedOutAt = 06:00 des Folgetags) und ein nahtloser Folge-Tag gestartet — * offene ToDos wandern mit. So entstehen aus Nacht-Durcharbeiten saubere * Tagesgrenzen, ohne dass der Nutzer manuell aus- und wieder einstempeln muss. * * Idempotent: ohne offenen, abgelaufenen Eintrag passiert nichts. Die Schleife * deckt (selten) mehrtägiges Durchlaufen ab; das Limit ist ein Sicherheitsnetz. */ export async function applyWorkdayBoundary(db: Db, userId: string, now: Date): Promise { for (let i = 0; i < 14; i++) { const open = ( await db .select() .from(dayEntry) .where(and(eq(dayEntry.userId, userId), isNull(dayEntry.checkedOutAt))) .orderBy(desc(dayEntry.date)) .limit(1) )[0]; if (!open || !isWorkdaySealed(open.date, now)) return; const cutoff = new Date(workdayCutoffMs(open.date)); const nextDate = addCalendarDays(open.date, 1); await db.transaction(async (tx) => { // 1) Alten Tag um 06:00 beenden. await tx .update(dayEntry) .set({ checkedOutAt: cutoff, updatedAt: now }) .where(eq(dayEntry.id, open.id)); if (open.kimaiTimesheetId !== null && kimaiEnabled()) { await kimaiClockOut({ timesheetId: open.kimaiTimesheetId }); } // 2) Folge-Tag nur anlegen, wenn es ihn noch nicht gibt (unique userId+date). const existsNext = ( await tx .select({ id: dayEntry.id }) .from(dayEntry) .where(and(eq(dayEntry.userId, userId), eq(dayEntry.date, nextDate))) .limit(1) )[0]; if (existsNext) return; const [created] = await tx .insert(dayEntry) .values({ userId, date: nextDate, status: 'begun', checkedInAt: cutoff }) .returning(); if (!created) throw new Error('Folge-Tag konnte nicht angelegt werden.'); // 3) Offene ToDos in den neuen Tag übernehmen (im alten als übertragen markiert). const openTodos = await tx .select() .from(todo) .where(and(eq(todo.dayEntryId, open.id), eq(todo.status, 'open'))) .orderBy(asc(todo.order)); if (openTodos.length > 0) { await tx.insert(todo).values( openTodos.map((t, idx) => ({ dayEntryId: created.id, userId, date: nextDate, title: t.title, order: idx, bezugKind: t.bezugKind, bezugId: t.bezugId, carriedOver: true, })), ); await tx .update(todo) .set({ carriedOver: true }) .where( inArray( todo.id, openTodos.map((t) => t.id), ), ); } }); } } /** * Der aktive Arbeitstag des Nutzers nach Anwendung des 6-Uhr-Schnitts: der jüngste * Eintrag, der noch nicht versiegelt ist (offen ODER bereits ausgecheckt, aber vor * 06:00 des Folgetags — also noch fortsetzbar). `null`, wenn kein laufender/heutiger * Tag existiert (→ Frontend zeigt das Einstempel-Formular). */ export async function getActiveEntry(db: Db, userId: string, now: Date): Promise { await applyWorkdayBoundary(db, userId, now); const entry = ( await db .select() .from(dayEntry) .where(eq(dayEntry.userId, userId)) .orderBy(desc(dayEntry.date)) .limit(1) )[0]; if (!entry || isWorkdaySealed(entry.date, now)) return null; return entry; } export const dayEntryRouter = router({ /** * Heutige ToDos aller aktiven Mitarbeiter, gruppiert pro User. * Pro PRD-Änderung 2026-05-25: Heutige ToDos sind teamweit sichtbar * (ersetzt die ältere private-vor-Publikation-Regel). */ todayTeam: protectedProcedure.query(async ({ ctx }) => { const date = todayIso(); const entries = await ctx.db.select().from(dayEntry).where(eq(dayEntry.date, date)); if (entries.length === 0) return [] as Array<{ userId: string; entryStatus: string; todos: Todo[] }>; const ids = entries.map((e) => e.id); const todos = await ctx.db .select() .from(todo) .where(inArray(todo.dayEntryId, ids)) .orderBy(asc(todo.order)); const todosByEntry = new Map(); for (const t of todos) { const arr = todosByEntry.get(t.dayEntryId) ?? []; arr.push(t); todosByEntry.set(t.dayEntryId, arr); } return entries.map((e) => ({ userId: e.userId, entryStatus: e.status, todos: todosByEntry.get(e.id) ?? [], })); }), /** * Offene ToDos der vergangenen Tage, die noch nicht übertragen wurden. * Werden auf der Heute-Seite vor dem Einstempeln eingeblendet (siehe FR-22 Erweiterung). */ pendingCarryOver: protectedProcedure.query(async ({ ctx }) => { return ctx.db .select({ id: todo.id, title: todo.title, bezugKind: todo.bezugKind, bezugId: todo.bezugId, fromDate: todo.date, }) .from(todo) .where( and( eq(todo.userId, ctx.user.id), eq(todo.status, 'open'), eq(todo.carriedOver, false), lt(todo.date, todayIso()), ), ) .orderBy(asc(todo.date), asc(todo.order)) .limit(20); }), /** * Heute-Status der eigenen Person. „Heute" = der aktive Arbeitstag: ein laufender * (offener) Eintrag bleibt aktiv, auch über Mitternacht hinaus, bis 06:00 des * Folgetags. Ein bereits ausgecheckter Tag bleibt bis dahin sichtbar (fortsetzbar). */ today: protectedProcedure.query(async ({ ctx }) => { const now = new Date(); const entry = await getActiveEntry(ctx.db, ctx.user.id, now); if (!entry) { return { date: todayIso(now), entry: null, todos: [] as Todo[] }; } const todos = await ctx.db .select() .from(todo) .where(eq(todo.dayEntryId, entry.id)) .orderBy(todo.order); return { date: entry.date, entry, todos }; }), /** * Morgen-Einstempeln + ToDo-Liste setzen. */ checkIn: protectedProcedure .input(DayEntrySchemas.checkInInput) .mutation(async ({ ctx, input }) => { // Erst einen ggf. abgelaufenen Nacht-Eintrag sauber abschließen (6-Uhr-Schnitt). await applyWorkdayBoundary(ctx.db, ctx.user.id, new Date()); const date = input.date ?? todayIso(); // Verhindert Doppel-Einstempeln. Wenn bereits Einträge existieren, Fehler. const existing = await ctx.db .select() .from(dayEntry) .where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date))) .limit(1); if (existing[0]) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist heute schon locked in.', }); } const result = await ctx.db.transaction(async (tx) => { const [created] = await tx .insert(dayEntry) .values({ userId: ctx.user.id, date, status: 'begun', checkedInAt: new Date(), }) .returning(); if (!created) throw new Error('DayEntry konnte nicht angelegt werden.'); const todoRows = await tx .insert(todo) .values( input.todos.map((t, idx) => ({ dayEntryId: created.id, userId: ctx.user.id, date, title: t.title, order: idx, bezugKind: t.bezugKind ?? null, bezugId: t.bezugId ?? null, })), ) .returning(); // Alle alten offenen ToDos der Person als "übertragen" markieren. // Egal, ob sie in der heutigen Liste landeten oder nicht — der User hat // beim Einstempeln über sie entschieden. const stale = await tx .select({ id: todo.id }) .from(todo) .where( and( eq(todo.userId, ctx.user.id), eq(todo.status, 'open'), eq(todo.carriedOver, false), lt(todo.date, date), ), ); if (stale.length > 0) { await tx .update(todo) .set({ carriedOver: true }) .where( inArray( todo.id, stale.map((s) => s.id), ), ); } return { entry: created, todos: todoRows, carriedOver: stale.length }; }); // Kimai-Sync (best-effort, nicht blockierend): Check-In = Clock-In. if (kimaiEnabled()) { const tsId = await kimaiClockIn({ begin: result.entry.checkedInAt ?? new Date(), description: `ebw — ${ctx.user.name}`, }); if (tsId !== null) { await ctx.db .update(dayEntry) .set({ kimaiTimesheetId: tsId, kimaiSynced: true }) .where(eq(dayEntry.id, result.entry.id)); } } return result; }), /** * Check-Out / Arbeitsende: beendet den laufenden (offenen) Arbeitstag — egal, ob * er heute oder gestern (Nachtarbeit über Mitternacht) begonnen wurde. Best-effort * Kimai-Clock-Out, falls beim Check-In ein Timesheet gestartet wurde. */ checkOut: protectedProcedure.mutation(async ({ ctx }) => { const now = new Date(); await applyWorkdayBoundary(ctx.db, ctx.user.id, now); const entry = ( await ctx.db .select() .from(dayEntry) .where(and(eq(dayEntry.userId, ctx.user.id), isNull(dayEntry.checkedOutAt))) .orderBy(desc(dayEntry.date)) .limit(1) )[0]; if (!entry) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist gerade nicht eingestempelt.' }); } const [updated] = await ctx.db .update(dayEntry) .set({ checkedOutAt: now, updatedAt: now }) .where(eq(dayEntry.id, entry.id)) .returning(); if (entry.kimaiTimesheetId !== null && kimaiEnabled()) { await kimaiClockOut({ timesheetId: entry.kimaiTimesheetId }); } return updated; }), /** * „Arbeit fortsetzen" — hebt einen Feierabend wieder auf (versehentlich geklickt * oder doch noch weitergearbeitet). Öffnet den aktiven, noch nicht versiegelten * Tag erneut (checkedOutAt → null). Möglich, solange der Arbeitstag läuft, d. h. * vor 06:00 des Folgetags. Danach ist der Tag final → neu einstempeln. */ reopen: protectedProcedure.mutation(async ({ ctx }) => { const now = new Date(); const entry = await getActiveEntry(ctx.db, ctx.user.id, now); if (!entry) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Kein fortsetzbarer Arbeitstag — der letzte Tag ist abgeschlossen. Stempel neu ein.', }); } if (!entry.checkedOutAt) return entry; // läuft bereits const [updated] = await ctx.db .update(dayEntry) .set({ checkedOutAt: null, updatedAt: now }) .where(eq(dayEntry.id, entry.id)) .returning(); // Best-effort: Kimai wieder anlaufen lassen, falls aktiv. if (kimaiEnabled()) { const tsId = await kimaiClockIn({ begin: now, description: `ebw — ${ctx.user.name} (fortgesetzt)`, }); if (tsId !== null) { await ctx.db .update(dayEntry) .set({ kimaiTimesheetId: tsId, kimaiSynced: true }) .where(eq(dayEntry.id, entry.id)); } } return updated; }), /** * ToDos im Verlauf des Tages ändern (vor Reflexion). */ updateTodos: protectedProcedure .input(DayEntrySchemas.todoMutationInput) .mutation(async ({ ctx, input }) => { const entries = await ctx.db .select() .from(dayEntry) .where(and(eq(dayEntry.id, input.dayEntryId), eq(dayEntry.userId, ctx.user.id))) .limit(1); const entry = entries[0]; if (!entry) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' }); } if (entry.status === 'full') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du hast schon reflektiert. ToDos sind eingefroren.', }); } const result = await ctx.db.transaction(async (tx) => { await tx.delete(todo).where(eq(todo.dayEntryId, entry.id)); if (input.todos.length === 0) return { todos: [] as Todo[] }; const newTodos = await tx .insert(todo) .values( input.todos.map((t, idx) => ({ dayEntryId: entry.id, userId: ctx.user.id, date: entry.date, title: t.title, order: idx, bezugKind: t.bezugKind ?? null, bezugId: t.bezugId ?? null, })), ) .returning(); return { todos: newTodos }; }); return result; }), /** * Einzelnen ToDo-Titel korrigieren (z. B. Tippfehler) — auch noch in der * Reflexion. Erlaubt, solange der Arbeitstag des ToDos noch läuft (vor 06:00 des * Folgetags); gleiche Editierbarkeits-Regel wie die Reflexion. */ editTodoTitle: protectedProcedure .input(DayEntrySchemas.editTodoTitleInput) .mutation(async ({ ctx, input }) => { const rows = await ctx.db .select() .from(todo) .where(and(eq(todo.id, input.todoId), eq(todo.userId, ctx.user.id))) .limit(1); const existing = rows[0]; if (!existing) { throw new TRPCError({ code: 'NOT_FOUND', message: 'ToDo nicht gefunden.' }); } if (isWorkdaySealed(existing.date, new Date())) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'ToDos eines Vortags können nicht mehr bearbeitet werden.', }); } const [updated] = await ctx.db .update(todo) .set({ title: input.title }) .where(eq(todo.id, input.todoId)) .returning(); return updated; }), /** * Einzelnes ToDo mitten am Tag hinzufügen (auch nach der Reflexion möglich, * solange der Tageseintrag vom heutigen Berlin-Tag ist). */ addTodo: protectedProcedure .input(DayEntrySchemas.addTodoInput) .mutation(async ({ ctx, input }) => { const entry = await getActiveEntry(ctx.db, ctx.user.id, new Date()); if (!entry) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist gerade nicht eingestempelt.', }); } const existing = await ctx.db .select({ id: todo.id }) .from(todo) .where(eq(todo.dayEntryId, entry.id)); const [created] = await ctx.db .insert(todo) .values({ dayEntryId: entry.id, userId: ctx.user.id, date: entry.date, title: input.title, order: existing.length, bezugKind: input.bezugKind ?? null, bezugId: input.bezugId ?? null, }) .returning(); return created; }), /** * Ungeplant dazugekommenes & bereits erledigtes Item nachtragen * („btw, das habe ich heute auch noch gemacht"). Wird sofort als erledigt * und als ungeplant markiert. */ addUnplanned: protectedProcedure .input(DayEntrySchemas.addUnplannedInput) .mutation(async ({ ctx, input }) => { const entry = await getActiveEntry(ctx.db, ctx.user.id, new Date()); if (!entry) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist gerade nicht eingestempelt.', }); } const existing = await ctx.db .select({ id: todo.id }) .from(todo) .where(eq(todo.dayEntryId, entry.id)); const [created] = await ctx.db .insert(todo) .values({ dayEntryId: entry.id, userId: ctx.user.id, date: entry.date, title: input.title, order: existing.length, bezugKind: input.bezugKind ?? null, bezugId: input.bezugId ?? null, status: 'done', unplanned: true, completedAt: new Date(), }) .returning(); return created; }), /** * Abend-Reflexion. Setzt Ampel + Trend, optional Blocker. */ submitReflection: protectedProcedure .input(DayEntrySchemas.submitReflectionInput) .mutation(async ({ ctx, input }) => { const entries = await ctx.db .select() .from(dayEntry) .where(and(eq(dayEntry.id, input.dayEntryId), eq(dayEntry.userId, ctx.user.id))) .limit(1); const entry = entries[0]; if (!entry) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' }); } // Editierbar, solange der Arbeitstag noch läuft. Damit kann mehrmals am Tag // (auch nach Mitternacht bei Nachtarbeit) aktualisiert werden; ab 06:00 des // Folgetags ist der Tag final und kann nicht mehr verändert werden. if (isWorkdaySealed(entry.date, new Date())) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Reflexion eines Vortags kann nicht mehr bearbeitet werden.', }); } // Pflicht-Trend bei Bezug=Projekt (Story 2.4) const todoRows = await ctx.db.select().from(todo).where(eq(todo.dayEntryId, entry.id)); const hasProjectBezug = todoRows.some((t) => t.bezugKind === 'project'); if (hasProjectBezug && !input.trend) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Bei Projektbezug ist der Trend Pflicht.', }); } const result = await ctx.db.transaction(async (tx) => { // ToDo-Statuswechsel. for (const t of input.todos) { await tx .update(todo) .set({ status: t.status, dropReason: t.dropReason ?? null, completedAt: t.status === 'done' ? new Date() : null, droppedAt: t.status === 'dropped' ? new Date() : null, }) .where(and(eq(todo.id, t.id), eq(todo.dayEntryId, entry.id))); } let blockerRecord: { id: string } | null = null; if (input.blocker) { const [b] = await tx .insert(blocker) .values({ title: input.blocker.title, description: input.blocker.description ?? null, openedBy: ctx.user.id, }) .returning({ id: blocker.id }); blockerRecord = b ?? null; } const now = new Date(); const [updated] = await tx .update(dayEntry) .set({ status: 'full', reflectedAt: now, reflectionText: input.reflectionText, ampel: input.ampel, trend: input.trend ?? null, url: input.url ?? null, blockerId: blockerRecord?.id ?? entry.blockerId, // Edit-Lock wird datums­basiert geprüft (s. o.); kein zeitlicher Lock mehr. editLockedAt: null, updatedAt: now, }) .where(eq(dayEntry.id, entry.id)) .returning(); if (!updated) throw new Error('Update fehlgeschlagen.'); return { entry: updated, blocker: blockerRecord }; }); return result; }), /** * Konsolidierter Vortrags-Feed: alle bereits publizierten Einträge. * Nutzbar in Story 3.4 (Dashboard-Feed). */ feed: protectedProcedure.query(async ({ ctx }) => { // Volle Reflexionen vor heute (heutige bleiben privat bis morgen) — // unabhängig vom publish-Cron. const today = todayIso(); const entries = await ctx.db .select() .from(dayEntry) .where(and(lt(dayEntry.date, today), eq(dayEntry.status, 'full'))) .orderBy(desc(dayEntry.date), desc(dayEntry.reflectedAt)) .limit(50); return entries; }), /** * Team-weite Historie eines (vergangenen) Tages: was hat jede/r gemacht — * Reflexion (Ampel/Trend/Text) + ToDos. Default-Datum = vorheriger Werktag. * Datums-Navigation (prev/next Werktag) wird mitgeliefert. */ historyForDate: protectedProcedure .input(DayEntrySchemas.historyForDateInput) .query(async ({ ctx, input }) => { const today = todayIso(); const date = input.date ?? previousWerktag(today); const isToday = date === today; const entries = await ctx.db.select().from(dayEntry).where(eq(dayEntry.date, date)); const userRows = await ctx.db .select({ id: user.id, name: user.name }) .from(user) .where(eq(user.active, true)); const nameById = new Map(userRows.map((u) => [u.id, u.name])); const ids = entries.map((e) => e.id); const todoRows = ids.length ? await ctx.db .select() .from(todo) .where(inArray(todo.dayEntryId, ids)) .orderBy(asc(todo.order)) : []; const todosByEntry = new Map(); for (const t of todoRows) { const arr = todosByEntry.get(t.dayEntryId) ?? []; arr.push(t); todosByEntry.set(t.dayEntryId, arr); } const list = entries .map((e) => ({ userId: e.userId, name: nameById.get(e.userId) ?? '—', status: e.status, ampel: e.ampel, trend: e.trend, // Heutige Reflexion bleibt privat bis morgen; ToDos sind teamweit sichtbar. reflectionText: isToday ? null : e.reflectionText, url: e.url, todos: (todosByEntry.get(e.id) ?? []).map((t) => ({ id: t.id, title: t.title, status: t.status, dropReason: t.dropReason, unplanned: t.unplanned, })), })) .sort((a, b) => a.name.localeCompare(b.name)); const withEntry = new Set(entries.map((e) => e.userId)); const ohneEintrag = userRows .filter((u) => !withEntry.has(u.id)) .map((u) => u.name) .sort((a, b) => a.localeCompare(b)); const nw = nextWerktag(date); return { date, prevDate: previousWerktag(date), nextDate: nw < today ? nw : null, entries: list, ohneEintrag, }; }), /** * „Heute fehlt"-Liste (Anwesenheits-Übersicht). */ todayPresence: protectedProcedure.query(async ({ ctx }) => { const date = todayIso(); const rows = await ctx.db .select({ userId: dayEntry.userId, status: dayEntry.status, }) .from(dayEntry) .where(eq(dayEntry.date, date)); return rows; }), }); // Helper für Cron-Endpoint export async function publishYesterdaysEntries( ctx: { db: Db }, yesterdayIso: string, ): Promise { const now = new Date(); const result = await ctx.db .update(dayEntry) .set({ publishedAt: now }) .where(and(eq(dayEntry.date, yesterdayIso), isNull(dayEntry.publishedAt))); return result.rowCount ?? 0; }