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