Files
everythingbutwork/apps/api/src/routers/day-entry.ts
T
Raffael SheikhandClaude Opus 4.8 4311eadf50 chore(format): Prettier auf day-entry.ts + TopNav.tsx anwenden
Alt-Bestand nie formatiert — isolierter Format-Lauf vor der myWeek-Query und
dem Woche-Nav-Tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:37:59 +02:00

710 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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<DayEntry | null> {
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<string, Todo[]>();
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<string, Todo[]>();
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<number> {
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;
}