feat(dashboard): logWorkAction and polishNoteAction
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1aaf31a4c5
commit
a54a2f2875
1 changed files with 116 additions and 0 deletions
116
src/app/actions/log-work.ts
Normal file
116
src/app/actions/log-work.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue