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>
710 lines
23 KiB
TypeScript
710 lines
23 KiB
TypeScript
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 datumsbasiert 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;
|
||
}
|