feat(activity): derive audit rows from card patches
This commit is contained in:
parent
6a6df8076b
commit
30c1405f72
3 changed files with 144 additions and 9 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
70
src/lib/activity/diff.test.ts
Normal file
70
src/lib/activity/diff.test.ts
Normal 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
63
src/lib/activity/diff.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue