feat(activity): derive audit rows from card patches

This commit is contained in:
Dave 2026-09-16 09:42:42 +02:00
parent 6a6df8076b
commit 30c1405f72
3 changed files with 144 additions and 9 deletions

View file

@ -1831,7 +1831,6 @@ import { BoardColumn } from "@/components/board/board-column";
import { CardTile } from "@/components/board/card-tile";
import { ClientCardControls } from "@/components/board/client-card-controls";
import { computeDropPosition } from "@/lib/board/move";
import { pbBrowser } from "@/lib/pb/browser";
import type { CardRecord, ColumnRecord, Role } from "@/lib/pb/types";
function SortableCard({
@ -1964,14 +1963,17 @@ export function Board({
);
setCards(optimistic);
try {
// The owner writes straight to PocketBase — the owner-only write rule
// from Plan 1a permits it, so no route handler is needed.
await pbBrowser
.collection("cards")
.update(card.id, { column: destinationColumnId, position });
} catch (error) {
console.error("[board] move failed", error);
// Through the server route, not the browser SDK: the auth token lives in
// an httpOnly cookie that JavaScript cannot read, so the browser-side
// PocketBase instance has no credentials. See Task 10.
const response = await fetch(`/api/owner/cards/${card.id}/move`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ column: destinationColumnId, position }),
});
if (!response.ok) {
console.error("[board] move failed", response.status);
setCards(previous);
}
}

View file

@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { buildActivityRows } from "@/lib/activity/diff";
import type { CardRecord } from "@/lib/pb/types";
const card = {
id: "card_1",
project: "proj_1",
priority: "medium",
target_date: "2026-10-01",
} as CardRecord;
describe("buildActivityRows", () => {
it("emits one row per changed field", () => {
const rows = buildActivityRows(card, { priority: "high" }, "u_wf");
expect(rows).toEqual([
{
project: "proj_1",
card: "card_1",
actor: "u_wf",
action: "priority_changed",
field: "priority",
old_value: "medium",
new_value: "high",
},
]);
});
it("emits nothing when the value is unchanged", () => {
expect(buildActivityRows(card, { priority: "medium" }, "u_wf")).toEqual([]);
});
it("emits nothing for an empty patch", () => {
expect(buildActivityRows(card, {}, "u_wf")).toEqual([]);
});
it("uses date_changed for target_date", () => {
const rows = buildActivityRows(card, { target_date: "2026-11-15" }, "u_wf");
expect(rows[0].action).toBe("date_changed");
expect(rows[0].old_value).toBe("2026-10-01");
expect(rows[0].new_value).toBe("2026-11-15");
});
it("records clearing a date as an empty new_value", () => {
const rows = buildActivityRows(card, { target_date: "" }, "u_wf");
expect(rows[0].new_value).toBe("");
expect(rows[0].old_value).toBe("2026-10-01");
});
it("emits one row per field when both change", () => {
const rows = buildActivityRows(
card,
{ priority: "low", target_date: "2026-12-01" },
"u_wf"
);
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.field).sort()).toEqual(["priority", "target_date"]);
});
it("normalises a PocketBase datetime to a plain date before comparing", () => {
// PocketBase returns date fields as "2026-10-01 00:00:00.000Z". Comparing
// that raw against "2026-10-01" would log a change on every save.
const pbCard = {
...card,
target_date: "2026-10-01 00:00:00.000Z",
} as CardRecord;
expect(buildActivityRows(pbCard, { target_date: "2026-10-01" }, "u_wf")).toEqual(
[]
);
});
});

63
src/lib/activity/diff.ts Normal file
View file

@ -0,0 +1,63 @@
/**
* Turn a card patch into audit rows.
*
* Only genuine changes produce rows. A client saving a form without altering
* anything must not generate log noise, or the log stops being readable and
* therefore stops being used.
*/
import type { ActivityAction, CardRecord } from "@/lib/pb/types";
import type { ClientCardPatch } from "@/lib/cards/client-patch";
export interface ActivityRowInput {
project: string;
card: string;
actor: string;
action: ActivityAction;
field: string;
old_value: string;
new_value: string;
}
const ACTION_FOR_FIELD: Record<keyof ClientCardPatch, ActivityAction> = {
priority: "priority_changed",
target_date: "date_changed",
};
/**
* PocketBase returns a date field as "2026-10-01 00:00:00.000Z" but accepts
* "2026-10-01". Compare on the date part alone, or every save looks like an
* edit.
*/
function normalise(value: string): string {
if (!value) return "";
return value.slice(0, 10);
}
export function buildActivityRows(
card: CardRecord,
patch: ClientCardPatch,
actorId: string
): ActivityRowInput[] {
const rows: ActivityRowInput[] = [];
for (const key of Object.keys(patch) as Array<keyof ClientCardPatch>) {
const next = patch[key];
if (next === undefined) continue;
const current = card[key] ?? "";
if (normalise(String(current)) === normalise(String(next))) continue;
rows.push({
project: card.project,
card: card.id,
actor: actorId,
action: ACTION_FOR_FIELD[key],
field: key,
old_value: String(current),
new_value: String(next),
});
}
return rows;
}