feat(dashboard): logWorkAction and polishNoteAction

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-19 11:04:32 +02:00
parent 1aaf31a4c5
commit a54a2f2875

116
src/app/actions/log-work.ts Normal file
View file

@ -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<CommitRecord>({
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",
};
}
}