From a54a2f28753e3a2cda035eb6a924d55e54c34639 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 19 Sep 2026 11:04:32 +0200 Subject: [PATCH] feat(dashboard): logWorkAction and polishNoteAction Co-Authored-By: Claude Haiku 4.5 --- src/app/actions/log-work.ts | 116 ++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/app/actions/log-work.ts diff --git a/src/app/actions/log-work.ts b/src/app/actions/log-work.ts new file mode 100644 index 0000000..7e94893 --- /dev/null +++ b/src/app/actions/log-work.ts @@ -0,0 +1,116 @@ +"use server"; + +import { randomUUID } from "node:crypto"; +import { revalidatePath } from "next/cache"; + +import { requireSession } from "@/lib/auth/session"; +import { getAdminPb } from "@/lib/pb/admin"; +import { recomputeMetrics } from "@/lib/metrics/store"; +import { aiConfigured } from "@/lib/ai/settings"; +import { polishNote } from "@/lib/ai/polish"; +import { AiError } from "@/lib/ai"; +import type { CommitRecord } from "@/lib/pb/types"; + +const MAX_MESSAGE_LENGTH = 2000; +const MAX_HOURS = 24; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +/** See the security note at the top of src/app/actions/cards.ts. */ +async function requireOwner() { + const session = await requireSession(); + if (session.role !== "owner") throw new Error("Not found"); + return session; +} + +/** + * Log a block of off-repo work as a commits row. + * + * Writes through the superuser connection, not the owner's own token — + * `commits.createRule` is `null` (superuser-only) because every OTHER row in + * this collection comes from the ingestion pipeline, which already writes + * this way. The owner-only check above is what stands in for a collection + * rule here, exactly as it does for every client-manager write in this app. + */ +export async function logWorkAction(input: { + projectId: string; + clientSlug: string; + projectSlug: string; + date: string; + hours: number; + message: string; + cardId?: string; +}): Promise<{ ok: boolean; message?: string }> { + await requireOwner(); + + const message = input.message.trim().slice(0, MAX_MESSAGE_LENGTH); + if (!message) return { ok: false, message: "A message is required" }; + + if (!Number.isFinite(input.hours) || input.hours <= 0 || input.hours > MAX_HOURS) { + return { ok: false, message: `Hours must be between 0 and ${MAX_HOURS}` }; + } + + if (!ISO_DATE.test(input.date)) { + return { ok: false, message: "Invalid date" }; + } + + const pb = await getAdminPb(); + const cardId = input.cardId ?? ""; + + await pb.collection("commits").create({ + project: input.projectId, + // Guaranteed not to collide with a real 40-character git sha, and unique + // enough to satisfy the (project, sha) index every other commit relies on. + sha: `manual-${randomUUID()}`, + short_sha: "manual", + message, + subject: message, + type: "", + scope: "", + author_name: "", + author_email: "", + authored_at: `${input.date} 12:00:00.000Z`, + is_merge: false, + additions: 0, + deletions: 0, + card: cardId, + // Reuses the existing meaning of "manual" on link_source: a card link the + // owner chose by hand, not one the linking rules derived. + link_source: cardId ? "manual" : "none", + url: "", + summary: message, + summary_version: 0, + summary_attempts: 0, + source: "manual", + manual_minutes: Math.round(input.hours * 60), + }); + + // So the dashboard's KPIs reflect it on the very next load, not only after + // the owner separately clicks Recompute. + await recomputeMetrics(input.projectId); + + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/dashboard`); + return { ok: true }; +} + +export async function polishNoteAction(input: { + text: string; +}): Promise<{ ok: boolean; text?: string; message?: string }> { + await requireOwner(); + + const text = input.text.trim().slice(0, MAX_MESSAGE_LENGTH); + if (!text) return { ok: false, message: "Nothing to polish yet" }; + + if (!(await aiConfigured())) { + return { ok: false, message: "No AI provider is configured" }; + } + + try { + const polished = await polishNote(text); + return { ok: true, text: polished }; + } catch (error) { + return { + ok: false, + message: error instanceof AiError ? error.message : "The assistant is unavailable", + }; + } +}