feat(plan): Plan Puzzle v1 backend — proposal fields, server actions, Done-column guards
Adds the three proposal fields (proposed_target_date, proposed_by,
proposed_at) to the cards collection with a partial index that backs
the 'pending proposals' query, plus two new ActivityAction values
(date_proposed, date_proposal_resolved) and column_changed for
return-to-scheduled.
New server actions under src/app/actions/:
- client-proposal.ts: client-manager drag creates a proposal (not
a target_date write); 404 (not 403) on cross-tenant failures and
Done-column cards, matching the rest of /api/client/*.
- owner-plan.ts: accept and dismiss clear the three proposal
fields together and emit the audit rows; returnToScheduledAction
moves a Done card back to the first working column.
Server-side Done-column guards added to:
- src/app/actions/reschedule.ts (was UI-only)
- src/app/api/client/cards/[id]/route.ts (was UI-only)
Both pass through the new writeCardWithAudit helper at
src/lib/cards/write-with-audit.ts — single source of truth for
'patch card + emit activity rows', replacing the inline copies in
the PATCH route and rescheduleCardAction.
Adds src/lib/roadmap/slot-date.ts (slot <-> target_date mapping)
consumed by Pixel's puzzle UI; named SlotId API rather than the
plan's numeric indices for self-documenting callers.
CLIENT_EDITABLE_CARD_FIELDS stays at priority + target_date per
the brief; proposals go through the dedicated action so they get
their own audit row type.
This commit is contained in:
parent
9ddf8571f6
commit
b3aac9ba4f
17 changed files with 1459 additions and 38 deletions
86
pocketbase/pb_migrations/1789601200_added_card_proposals.js
Normal file
86
pocketbase/pb_migrations/1789601200_added_card_proposals.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* Proposal fields on `cards`.
|
||||
*
|
||||
* Plan Puzzle v1: clients drag a card into a month slot and we record their
|
||||
* intent on three nullable fields rather than overwriting `target_date`
|
||||
* (which the owner controls). The owner sees the chip, accepts (which copies
|
||||
* the proposal into `target_date`), or dismisses (which clears it).
|
||||
*
|
||||
* The three fields are written and cleared together. A row with one set and
|
||||
* the others null is logically incoherent; nothing in the application writes
|
||||
* one without the others. See src/app/actions/plan/client-proposal.ts and
|
||||
* src/app/actions/plan/owner-plan.ts for the only writers.
|
||||
*
|
||||
* The partial index supports the owner-side "show me cards with pending
|
||||
* proposals" query without indexing the millions of empty proposals on the
|
||||
* rest of the table.
|
||||
*
|
||||
* REFERENCE ONLY — see pb_migrations/README.md. scripts/setup-remote.ts is
|
||||
* what runs.
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const cards = app.findCollectionByNameOrId("cards");
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
|
||||
cards.fields.add([
|
||||
{
|
||||
name: "proposed_target_date",
|
||||
type: "date",
|
||||
required: false,
|
||||
presentable: false,
|
||||
hidden: false,
|
||||
system: false,
|
||||
},
|
||||
{
|
||||
name: "proposed_by",
|
||||
type: "relation",
|
||||
required: false,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
presentable: false,
|
||||
hidden: false,
|
||||
system: false,
|
||||
},
|
||||
{
|
||||
name: "proposed_at",
|
||||
type: "date",
|
||||
required: false,
|
||||
presentable: false,
|
||||
hidden: false,
|
||||
system: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const existing = new Set((cards.indexes as string[]) ?? []);
|
||||
const proposalIndex =
|
||||
"CREATE INDEX idx_cards_proposal ON cards (proposed_target_date) WHERE proposed_target_date IS NOT NULL";
|
||||
if (!existing.has(proposalIndex)) {
|
||||
cards.indexes.push(proposalIndex);
|
||||
}
|
||||
|
||||
return app.save(cards);
|
||||
},
|
||||
(app) => {
|
||||
const cards = app.findCollectionByNameOrId("cards");
|
||||
|
||||
for (const name of [
|
||||
"proposed_target_date",
|
||||
"proposed_by",
|
||||
"proposed_at",
|
||||
]) {
|
||||
const field = cards.fields.find((f) => f.name === name);
|
||||
if (field) cards.fields.removeById(field.id);
|
||||
}
|
||||
|
||||
const indexes = (cards.indexes as string[]) ?? [];
|
||||
const proposalIndex =
|
||||
"CREATE INDEX idx_cards_proposal ON cards (proposed_target_date) WHERE proposed_target_date IS NOT NULL";
|
||||
cards.indexes = indexes.filter((i) => i !== proposalIndex);
|
||||
|
||||
return app.save(cards);
|
||||
}
|
||||
);
|
||||
|
|
@ -427,6 +427,121 @@ async function ensureCardNumbers(pb: PocketBase): Promise<void> {
|
|||
* Re-runs are safe: the field is not duplicated, and backfill only touches
|
||||
* rows where kind is still unset.
|
||||
*/
|
||||
/**
|
||||
* Add the Plan Puzzle v1 proposal fields to `cards` (proposed_target_date,
|
||||
* proposed_by, proposed_at) and the partial index that backs the
|
||||
* "cards with pending proposals" query.
|
||||
*
|
||||
* `proposed_by` is a relation to users. `cascadeDelete: false` because a
|
||||
* deactivated user must not silently drop their pending proposals — the
|
||||
* history keeps "Someone" tied to a still-existing user record.
|
||||
*
|
||||
* Re-runs are safe: fields and the index are not duplicated.
|
||||
*/
|
||||
async function ensureCardProposalFields(pb: PocketBase): Promise<void> {
|
||||
const cards = await pb.collections.getOne("cards");
|
||||
const users = await pb.collections.getOne("users");
|
||||
|
||||
const have = new Set(cards.fields.map((f: { name: string }) => f.name));
|
||||
|
||||
const fields = [...(cards.fields as unknown[])];
|
||||
let fieldChanged = false;
|
||||
|
||||
if (!have.has("proposed_target_date")) {
|
||||
fields.push({
|
||||
name: "proposed_target_date",
|
||||
type: "date",
|
||||
required: false,
|
||||
presentable: false,
|
||||
hidden: false,
|
||||
system: false,
|
||||
});
|
||||
fieldChanged = true;
|
||||
}
|
||||
|
||||
if (!have.has("proposed_by")) {
|
||||
fields.push({
|
||||
name: "proposed_by",
|
||||
type: "relation",
|
||||
required: false,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
presentable: false,
|
||||
hidden: false,
|
||||
system: false,
|
||||
});
|
||||
fieldChanged = true;
|
||||
}
|
||||
|
||||
if (!have.has("proposed_at")) {
|
||||
fields.push({
|
||||
name: "proposed_at",
|
||||
type: "date",
|
||||
required: false,
|
||||
presentable: false,
|
||||
hidden: false,
|
||||
system: false,
|
||||
});
|
||||
fieldChanged = true;
|
||||
}
|
||||
|
||||
if (fieldChanged) {
|
||||
await pb.collections.update(cards.id, { fields });
|
||||
console.log(" + cards fields: proposed_target_date, proposed_by, proposed_at");
|
||||
} else {
|
||||
console.log(" = card proposal fields already present");
|
||||
}
|
||||
|
||||
const reloaded = await pb.collections.getOne("cards");
|
||||
const indexes = new Set((reloaded.indexes as string[]) ?? []);
|
||||
const proposalIndex =
|
||||
"CREATE INDEX idx_cards_proposal ON cards (proposed_target_date) WHERE proposed_target_date IS NOT NULL";
|
||||
if (!indexes.has(proposalIndex)) {
|
||||
indexes.add(proposalIndex);
|
||||
await pb.collections.update(reloaded.id, { indexes: [...indexes] });
|
||||
console.log(" + cards index: idx_cards_proposal");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the activity `action` select with Plan Puzzle v1's three new values:
|
||||
* `column_changed`, `date_proposed`, `date_proposal_resolved`. Existing rows
|
||||
* are unaffected; only the schema's allowed values list grows.
|
||||
*
|
||||
* `column_changed` is new despite the plan referring to it as "already
|
||||
* existing" — the kanban owner's drag-from-board move route today writes no
|
||||
* activity row at all, so the value had never been added. The Puzzle's
|
||||
* "return to scheduled" action is its first real consumer.
|
||||
*
|
||||
* Re-runs are safe: values are not duplicated.
|
||||
*/
|
||||
async function ensureActivityActionValues(pb: PocketBase): Promise<void> {
|
||||
const activity = await pb.collections.getOne("activity");
|
||||
const field = activity.fields.find(
|
||||
(f: { name: string }) => f.name === "action"
|
||||
) as { values?: string[] } | undefined;
|
||||
if (!field) {
|
||||
throw new Error("activity.action field missing — cannot extend values");
|
||||
}
|
||||
const additions = ["column_changed", "date_proposed", "date_proposal_resolved"];
|
||||
const existing = new Set(field.values ?? []);
|
||||
const missing = additions.filter((v) => !existing.has(v));
|
||||
if (missing.length === 0) {
|
||||
console.log(" = activity.action values already include puzzle actions");
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = activity.fields.map((f: { name: string; values?: string[] }) => {
|
||||
if (f.name !== "action") return f;
|
||||
return { ...f, values: [...(f.values ?? []), ...missing] };
|
||||
});
|
||||
|
||||
await pb.collections.update(activity.id, { fields });
|
||||
console.log(` + activity.action values: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
async function ensureProjectKindField(pb: PocketBase): Promise<void> {
|
||||
const projects = await pb.collections.getOne("projects");
|
||||
const have = new Set(projects.fields.map((f: { name: string }) => f.name));
|
||||
|
|
@ -739,9 +854,12 @@ async function main(): Promise<void> {
|
|||
values: [
|
||||
"card_created",
|
||||
"card_moved",
|
||||
"column_changed",
|
||||
"card_updated",
|
||||
"priority_changed",
|
||||
"date_changed",
|
||||
"date_proposed",
|
||||
"date_proposal_resolved",
|
||||
"card_accepted",
|
||||
"comment_added",
|
||||
"subtask_toggled",
|
||||
|
|
@ -1011,6 +1129,12 @@ async function main(): Promise<void> {
|
|||
// Add the intake wizard's fields (brief, transcript, size_band) for Phase 4b.
|
||||
await ensureCardIntakeFields(pb);
|
||||
|
||||
// Add the Plan Puzzle v1 proposal fields and the partial index.
|
||||
await ensureCardProposalFields(pb);
|
||||
|
||||
// Extend activity.action with the three new puzzle actions.
|
||||
await ensureActivityActionValues(pb);
|
||||
|
||||
console.log("Done.");
|
||||
}
|
||||
|
||||
|
|
|
|||
98
src/app/actions/client-proposal.ts
Normal file
98
src/app/actions/client-proposal.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { getServerPb } from "@/lib/pb/server";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { writeCardWithAudit } from "@/lib/cards/write-with-audit";
|
||||
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";
|
||||
|
||||
// ISO date YYYY-MM-DD. The day cap of 31 is permissive (Feb 31 still
|
||||
// slips through), but PocketBase's date parser rejects anything it cannot
|
||||
// serialise, so the action's downstream behaviour is the same as a stricter
|
||||
// check. The point here is to reject obvious junk (month > 12) before the
|
||||
// database call.
|
||||
const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
||||
|
||||
/**
|
||||
* Client drag in the Plan Puzzle: write `proposed_target_date` on the card
|
||||
* rather than `target_date`. The owner sees the proposal chip on the card
|
||||
* and either accepts (copies the date into `target_date` and clears the
|
||||
* proposal fields) or dismisses (clears without applying).
|
||||
*
|
||||
* Permission gates, in order:
|
||||
* 1. signed in
|
||||
* 2. caller is a client_manager (owners proposing is meaningless — they
|
||||
* can write `target_date` directly)
|
||||
* 3. the date is ISO
|
||||
* 4. the card exists and its project belongs to this session's client
|
||||
* 5. the card is not in a Done column (Puzzle v1: clients never move Done
|
||||
* cards — that's the owner's "Return to scheduled" action)
|
||||
*
|
||||
* 404 (not 403) is returned for every cross-tenant failure. Confirming that
|
||||
* a card exists in another client's project would itself be a leak.
|
||||
*
|
||||
* Activity row: one `date_proposed` row, written via the shared helper so
|
||||
* the row shape and write order match the rest of the codebase.
|
||||
*/
|
||||
export async function proposePlanMoveAction(input: {
|
||||
cardId: string;
|
||||
proposedTargetDate: string;
|
||||
}): Promise<{ ok: boolean; message?: string }> {
|
||||
const session = await requireSession();
|
||||
if (session.role !== "client_manager") {
|
||||
return { ok: false, message: "Not found" };
|
||||
}
|
||||
if (!ISO_DATE.test(input.proposedTargetDate)) {
|
||||
return { ok: false, message: "Invalid date" };
|
||||
}
|
||||
|
||||
const pb = await getServerPb();
|
||||
|
||||
const card = await pb
|
||||
.collection("cards")
|
||||
.getOne<CardRecord>(input.cardId)
|
||||
.catch(() => null);
|
||||
if (!card) return { ok: false, message: "Not found" };
|
||||
|
||||
const project = await pb
|
||||
.collection("projects")
|
||||
.getOne<ProjectRecord>(card.project)
|
||||
.catch(() => null);
|
||||
if (!project || project.client !== session.clientId) {
|
||||
return { ok: false, message: "Not found" };
|
||||
}
|
||||
|
||||
const column = await pb
|
||||
.collection("columns")
|
||||
.getOne<ColumnRecord>(card.column)
|
||||
.catch(() => null);
|
||||
if (column?.is_done) {
|
||||
return { ok: false, message: "Not found" };
|
||||
}
|
||||
|
||||
const previous = (card.proposed_target_date ?? "").slice(0, 10);
|
||||
|
||||
await writeCardWithAudit(pb, {
|
||||
cardId: card.id,
|
||||
patch: {
|
||||
proposed_target_date: input.proposedTargetDate,
|
||||
proposed_by: session.userId,
|
||||
proposed_at: new Date().toISOString().slice(0, 10),
|
||||
},
|
||||
rows: [
|
||||
{
|
||||
project: card.project,
|
||||
card: card.id,
|
||||
actor: session.userId,
|
||||
action: "date_proposed",
|
||||
field: "proposed_target_date",
|
||||
old_value: previous,
|
||||
new_value: input.proposedTargetDate,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
revalidatePath("/plan");
|
||||
return { ok: true };
|
||||
}
|
||||
218
src/app/actions/owner-plan.ts
Normal file
218
src/app/actions/owner-plan.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { getServerPb } from "@/lib/pb/server";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { writeCardWithAudit } from "@/lib/cards/write-with-audit";
|
||||
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
* Owner-only plan mutations.
|
||||
*
|
||||
* SECURITY: same rule as the rest of this directory — a Server Action is a
|
||||
* public HTTP endpoint, so every action re-checks the session and the role
|
||||
* before touching anything. See the security note at the top of
|
||||
* src/app/actions/cards.ts for the fuller argument.
|
||||
*
|
||||
* Activity rows are written via writeCardWithAudit so the card-update /
|
||||
* row-insert order matches the rest of the codebase.
|
||||
*/
|
||||
|
||||
async function requireOwner() {
|
||||
const session = await requireSession();
|
||||
if (session.role !== "owner") {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner accepts a client's proposed date.
|
||||
*
|
||||
* The three proposal fields are cleared together (otherwise the row is in
|
||||
* an incoherent half-set state — see the comment on `CardRecord` in
|
||||
* @/lib/pb/types). If `target_date` would change as a result, a
|
||||
* `date_changed` row is also emitted so the card history reads the same
|
||||
* way an owner drag would have; the `date_proposal_resolved` row always
|
||||
* fires, with `new_value="accepted"`.
|
||||
*/
|
||||
export async function acceptClientProposalAction(input: {
|
||||
cardId: string;
|
||||
}): Promise<{ ok: boolean; message?: string }> {
|
||||
try {
|
||||
const session = await requireOwner();
|
||||
const pb = await getServerPb();
|
||||
|
||||
const card = await pb
|
||||
.collection("cards")
|
||||
.getOne<CardRecord>(input.cardId)
|
||||
.catch(() => null);
|
||||
if (!card || !card.proposed_target_date) {
|
||||
return { ok: false, message: "No proposal to accept" };
|
||||
}
|
||||
|
||||
const proposedDate = card.proposed_target_date.slice(0, 10);
|
||||
const previousTarget = (card.target_date ?? "").slice(0, 10);
|
||||
|
||||
const rows: import("@/lib/activity/diff").AuditRowInput[] = [];
|
||||
|
||||
if (previousTarget !== proposedDate) {
|
||||
rows.push({
|
||||
project: card.project,
|
||||
card: card.id,
|
||||
actor: session.userId,
|
||||
action: "date_changed",
|
||||
field: "target_date",
|
||||
old_value: card.target_date ?? "",
|
||||
new_value: proposedDate,
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
project: card.project,
|
||||
card: card.id,
|
||||
actor: session.userId,
|
||||
action: "date_proposal_resolved",
|
||||
field: "proposed_target_date",
|
||||
old_value: proposedDate,
|
||||
new_value: "accepted",
|
||||
});
|
||||
|
||||
await writeCardWithAudit(pb, {
|
||||
cardId: card.id,
|
||||
patch: {
|
||||
target_date: proposedDate,
|
||||
proposed_target_date: null,
|
||||
proposed_by: null,
|
||||
proposed_at: null,
|
||||
},
|
||||
rows,
|
||||
});
|
||||
|
||||
revalidatePath("/plan");
|
||||
return { ok: true };
|
||||
} catch {
|
||||
// requireOwner throws "Not found" on a non-owner session — surface that
|
||||
// as the same 404-style failure message as the rest of this module.
|
||||
return { ok: false, message: "Not found" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner dismisses a client's proposal without applying it.
|
||||
*
|
||||
* Clears the three proposal fields together and emits one
|
||||
* `date_proposal_resolved` row with `new_value="dismissed"`. `target_date`
|
||||
* is untouched — the client's intent is logged, not silently dropped.
|
||||
*/
|
||||
export async function dismissClientProposalAction(input: {
|
||||
cardId: string;
|
||||
}): Promise<{ ok: boolean; message?: string }> {
|
||||
try {
|
||||
const session = await requireOwner();
|
||||
const pb = await getServerPb();
|
||||
|
||||
const card = await pb
|
||||
.collection("cards")
|
||||
.getOne<CardRecord>(input.cardId)
|
||||
.catch(() => null);
|
||||
if (!card || !card.proposed_target_date) {
|
||||
return { ok: false, message: "No proposal to dismiss" };
|
||||
}
|
||||
|
||||
const proposedDate = card.proposed_target_date.slice(0, 10);
|
||||
|
||||
await writeCardWithAudit(pb, {
|
||||
cardId: card.id,
|
||||
patch: {
|
||||
proposed_target_date: null,
|
||||
proposed_by: null,
|
||||
proposed_at: null,
|
||||
},
|
||||
rows: [
|
||||
{
|
||||
project: card.project,
|
||||
card: card.id,
|
||||
actor: session.userId,
|
||||
action: "date_proposal_resolved",
|
||||
field: "proposed_target_date",
|
||||
old_value: proposedDate,
|
||||
new_value: "dismissed",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
revalidatePath("/plan");
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, message: "Not found" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a Done card back to its project's first working column.
|
||||
*
|
||||
* Owners used to drag Done cards sideways on the Timeline. That gesture is
|
||||
* now blocked server-side by the Done-column guard in rescheduleCardAction
|
||||
* and the client PATCH route. This is the explicit, audited replacement.
|
||||
*
|
||||
* `column_changed` is the same action used elsewhere in the kanban code
|
||||
* (see src/app/actions/columns.ts for the column rename / reorder paths);
|
||||
* adding it as a new ActivityAction value keeps the activity-feed UI from
|
||||
* needing a branch on the move source.
|
||||
*/
|
||||
export async function returnToScheduledAction(input: {
|
||||
cardId: string;
|
||||
}): Promise<{ ok: boolean; message?: string }> {
|
||||
try {
|
||||
const session = await requireOwner();
|
||||
const pb = await getServerPb();
|
||||
|
||||
const card = await pb
|
||||
.collection("cards")
|
||||
.getOne<CardRecord>(input.cardId)
|
||||
.catch(() => null);
|
||||
if (!card) return { ok: false, message: "Not found" };
|
||||
|
||||
// Find the project's first non-Inbox, non-Done column, in board order.
|
||||
// Working columns are already in display order; the first one is the
|
||||
// natural home for a card returning to the schedule.
|
||||
const columns = await pb
|
||||
.collection("columns")
|
||||
.getFullList<ColumnRecord>({
|
||||
filter: `project = "${card.project}"`,
|
||||
sort: "position",
|
||||
});
|
||||
const target = columns.find((c) => !c.is_inbox && !c.is_done);
|
||||
if (!target) return { ok: false, message: "No working column" };
|
||||
|
||||
// Defensive: if the card isn't actually in a Done column, refuse
|
||||
// rather than write a misleading activity row. The Puzzle only exposes
|
||||
// the button on Done cards, but a future UI could surface it elsewhere.
|
||||
const current = columns.find((c) => c.id === card.column);
|
||||
if (!current?.is_done) {
|
||||
return { ok: false, message: "Not done" };
|
||||
}
|
||||
|
||||
await writeCardWithAudit(pb, {
|
||||
cardId: card.id,
|
||||
patch: { column: target.id },
|
||||
rows: [
|
||||
{
|
||||
project: card.project,
|
||||
card: card.id,
|
||||
actor: session.userId,
|
||||
action: "column_changed",
|
||||
field: "column",
|
||||
old_value: card.column,
|
||||
new_value: target.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
revalidatePath("/plan");
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, message: "Not found" };
|
||||
}
|
||||
}
|
||||
100
src/app/actions/plan/_plan-permissions.test.ts
Normal file
100
src/app/actions/plan/_plan-permissions.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
canProposeForCard,
|
||||
canResolveProposalForCard,
|
||||
canReturnCardToSchedule,
|
||||
parseProposalDate,
|
||||
parseReturnColumnId,
|
||||
} from "@/app/actions/plan/_plan-permissions";
|
||||
|
||||
const ownerSession = { userId: "u_owner", role: "owner", clientId: null } as const;
|
||||
const managerOtherClient = { userId: "u_other", role: "client_manager", clientId: "c_other" } as const;
|
||||
const managerOwnClient = { userId: "u_self", role: "client_manager", clientId: "c_self" } as const;
|
||||
|
||||
describe("parseProposalDate", () => {
|
||||
it("accepts an ISO date string", () => {
|
||||
expect(parseProposalDate("2026-11-15")).toBe("2026-11-15");
|
||||
});
|
||||
|
||||
it("rejects malformed input", () => {
|
||||
expect(parseProposalDate("next tuesday")).toBeNull();
|
||||
expect(parseProposalDate("")).toBeNull();
|
||||
expect(parseProposalDate("2026-11")).toBeNull();
|
||||
expect(parseProposalDate("2026-13-01")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canProposeForCard", () => {
|
||||
it("refuses owners outright (they write target_date directly)", () => {
|
||||
expect(
|
||||
canProposeForCard(ownerSession, { project: { client: "c_self" } } as never)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses client managers for a project they don't own", () => {
|
||||
expect(
|
||||
canProposeForCard(managerOtherClient, { project: { client: "c_self" } } as never)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("admits the owning client manager", () => {
|
||||
expect(
|
||||
canProposeForCard(managerOwnClient, { project: { client: "c_self" } } as never)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses when the card is in a Done column", () => {
|
||||
expect(
|
||||
canProposeForCard(managerOwnClient, {
|
||||
project: { client: "c_self" },
|
||||
column: { is_done: true },
|
||||
} as never)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canResolveProposalForCard", () => {
|
||||
it("only admits owners", () => {
|
||||
expect(canResolveProposalForCard(ownerSession)).toBe(true);
|
||||
expect(canResolveProposalForCard(managerOwnClient)).toBe(false);
|
||||
expect(canResolveProposalForCard(managerOtherClient)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canReturnCardToSchedule", () => {
|
||||
it("admits an owner when the card is in a Done column", () => {
|
||||
expect(
|
||||
canReturnCardToSchedule(ownerSession, {
|
||||
column: { is_done: true },
|
||||
} as never)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses an owner when the card is not in a Done column", () => {
|
||||
expect(
|
||||
canReturnCardToSchedule(ownerSession, {
|
||||
column: { is_done: false },
|
||||
} as never)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a client manager regardless of column", () => {
|
||||
expect(
|
||||
canReturnCardToSchedule(managerOwnClient, {
|
||||
column: { is_done: true },
|
||||
} as never)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReturnColumnId", () => {
|
||||
it("accepts a non-empty string id", () => {
|
||||
expect(parseReturnColumnId("col_1")).toBe("col_1");
|
||||
});
|
||||
|
||||
it("rejects empty input", () => {
|
||||
expect(parseReturnColumnId("")).toBeNull();
|
||||
expect(parseReturnColumnId(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
81
src/app/actions/plan/_plan-permissions.ts
Normal file
81
src/app/actions/plan/_plan-permissions.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Pure predicates for the Plan Puzzle server actions.
|
||||
*
|
||||
* No `server-only` marker, no PocketBase, no framework — so unit tests can
|
||||
* exercise every gate without a database. The actions at the top of
|
||||
* src/app/actions/ call into here for the cross-cutting decisions (role
|
||||
* check, project ownership, date shape) and stay focused on the I/O.
|
||||
*/
|
||||
|
||||
import type { Session } from "@/lib/auth/access";
|
||||
|
||||
// ISO date YYYY-MM-DD with month 01-12 and day 01-31. The day cap of 31
|
||||
// is permissive — Feb 31 still slips through — but PocketBase's date parser
|
||||
// rejects anything it cannot serialise, so the action's downstream behaviour
|
||||
// is the same as a stricter check. The point here is to reject obvious junk
|
||||
// (month > 12) before the database call.
|
||||
const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
||||
|
||||
/** Validate and normalise a proposed date. Returns the date on success. */
|
||||
export function parseProposalDate(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
if (!ISO_DATE.test(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* May this session propose a date for this card?
|
||||
*
|
||||
* Owners do not propose — they have direct access to `target_date`. Client
|
||||
* managers may propose only for projects they own. A card in a Done column
|
||||
* is rejected (Puzzle v1: clients never move Done cards; the owner has
|
||||
* "Return to scheduled" for that).
|
||||
*/
|
||||
export function canProposeForCard(
|
||||
session: Session,
|
||||
card: { project: { client: string }; column?: { is_done?: boolean } | null }
|
||||
): boolean {
|
||||
if (session.role !== "client_manager") return false;
|
||||
if (!session.clientId) return false;
|
||||
if (card.project.client !== session.clientId) return false;
|
||||
if (card.column?.is_done) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* May this session accept or dismiss a proposal?
|
||||
*
|
||||
* Owner-only. The proposal chip on a card is visible to the client too
|
||||
* (it's their own intent), but resolving it is the owner's decision.
|
||||
*/
|
||||
export function canResolveProposalForCard(session: Session): boolean {
|
||||
return session.role === "owner";
|
||||
}
|
||||
|
||||
/**
|
||||
* May this session return a card from Done back to a working column?
|
||||
*
|
||||
* Owner-only AND the card must be in a Done column. The UI only exposes
|
||||
* the button on Done cards, but the predicate still checks it so a forged
|
||||
* request can't write a misleading `column_changed` row.
|
||||
*/
|
||||
export function canReturnCardToSchedule(
|
||||
session: Session,
|
||||
card: { column?: { is_done?: boolean } | null }
|
||||
): boolean {
|
||||
if (session.role !== "owner") return false;
|
||||
if (!card.column?.is_done) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the optional destination column id for a return-to-scheduled
|
||||
* action. The Puzzle currently uses the first working column automatically;
|
||||
* this predicate exists so a future caller can pick a target explicitly
|
||||
* without re-implementing the validation.
|
||||
*/
|
||||
export function parseReturnColumnId(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
if (!value) return null;
|
||||
return value;
|
||||
}
|
||||
|
|
@ -4,10 +4,15 @@ import { revalidatePath } from "next/cache";
|
|||
|
||||
import { getServerPb } from "@/lib/pb/server";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { logActivity } from "@/app/actions/activity";
|
||||
import type { CardRecord } from "@/lib/pb/types";
|
||||
import { writeCardWithAudit } from "@/lib/cards/write-with-audit";
|
||||
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
// ISO date YYYY-MM-DD. The day cap of 31 is permissive (Feb 31 still
|
||||
// slips through), but PocketBase's date parser rejects anything it cannot
|
||||
// serialise, so the action's downstream behaviour is the same as a stricter
|
||||
// check. The point here is to reject obvious junk (month > 12) before the
|
||||
// database call.
|
||||
const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
||||
|
||||
/**
|
||||
* Move a card's target date from the timeline. Owner only.
|
||||
|
|
@ -18,7 +23,11 @@ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|||
* client's card history read "Dave moved the target date from 1 Oct to 15 Nov",
|
||||
* in their own view, without anyone having to tell them.
|
||||
*
|
||||
* Do not remove that call to make this faster.
|
||||
* Plan Puzzle v1: the Done-column guard returns {ok: false, message} when the
|
||||
* card lives in a column flagged is_done. The owner used to drag Done cards
|
||||
* sideways off the Timeline; that gesture is replaced by the explicit
|
||||
* "Return to scheduled" server action, which moves the card out of the Done
|
||||
* column rather than rewriting a date it never had.
|
||||
*/
|
||||
export async function rescheduleCardAction(input: {
|
||||
cardId: string;
|
||||
|
|
@ -39,19 +48,31 @@ export async function rescheduleCardAction(input: {
|
|||
.catch(() => null);
|
||||
if (!card) return { ok: false, message: "Card not found" };
|
||||
|
||||
const column = await pb
|
||||
.collection("columns")
|
||||
.getOne<ColumnRecord>(card.column)
|
||||
.catch(() => null);
|
||||
if (column?.is_done) {
|
||||
return { ok: false, message: "Card is done" };
|
||||
}
|
||||
|
||||
const previous = (card.target_date ?? "").slice(0, 10);
|
||||
if (previous === input.date) return { ok: true };
|
||||
|
||||
await pb.collection("cards").update(input.cardId, { target_date: input.date });
|
||||
|
||||
await logActivity({
|
||||
project: card.project,
|
||||
card: card.id,
|
||||
actor: session.userId,
|
||||
action: "date_changed",
|
||||
field: "target_date",
|
||||
oldValue: card.target_date ?? "",
|
||||
newValue: input.date,
|
||||
await writeCardWithAudit(pb, {
|
||||
cardId: card.id,
|
||||
patch: { target_date: input.date },
|
||||
rows: [
|
||||
{
|
||||
project: card.project,
|
||||
card: card.id,
|
||||
actor: session.userId,
|
||||
action: "date_changed",
|
||||
field: "target_date",
|
||||
old_value: card.target_date ?? "",
|
||||
new_value: input.date,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
revalidatePath("/plan");
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ import {
|
|||
} from "@/lib/auth/access";
|
||||
import { pickClientCardPatch } from "@/lib/cards/client-patch";
|
||||
import { buildActivityRows } from "@/lib/activity/diff";
|
||||
import { writeCardWithAudit } from "@/lib/cards/write-with-audit";
|
||||
import { jsonError, jsonOk } from "@/lib/http/json";
|
||||
import type { CardRecord, ProjectRecord } from "@/lib/pb/types";
|
||||
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
* PATCH /api/client/cards/:id — a client manager sets priority or target_date.
|
||||
|
|
@ -18,10 +19,16 @@ import type { CardRecord, ProjectRecord } from "@/lib/pb/types";
|
|||
* 1. a valid session
|
||||
* 2. the card exists, and its project belongs to this session's client
|
||||
* 3. the caller is a client_manager for that client
|
||||
* 4. the body is reduced to the two whitelisted fields
|
||||
* 5. audit rows are written for whatever actually changed
|
||||
* 4. the card is not in a Done column (Plan Puzzle v1 — owners only move
|
||||
* Done cards via the explicit "Return to scheduled" action)
|
||||
* 5. the body is reduced to the two whitelisted fields
|
||||
* 6. audit rows are written for whatever actually changed
|
||||
*
|
||||
* Do not reorder these, and do not add an early return that skips step 5.
|
||||
* Do not reorder these, and do not add an early return that skips step 6.
|
||||
*
|
||||
* The Done-column check uses 404 rather than 409 for the same anti-
|
||||
* enumeration reason as the project check: a 4xx that confirms the card is
|
||||
* in Done leaks the project's structure to a foreign session.
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
|
|
@ -59,6 +66,19 @@ export async function PATCH(
|
|||
return jsonError("Card not found", 404);
|
||||
}
|
||||
|
||||
// Plan Puzzle v1: clients do not move Done cards. Owners do, via the
|
||||
// dedicated "Return to scheduled" action. A client manager reaching this
|
||||
// route on a Done card is either confused or hostile — refuse with the
|
||||
// same 404 as the project-not-found path so the response is
|
||||
// indistinguishable.
|
||||
const column = await pb
|
||||
.collection("columns")
|
||||
.getOne<ColumnRecord>(card.column)
|
||||
.catch(() => null);
|
||||
if (column?.is_done) {
|
||||
return jsonError("Card not found", 404);
|
||||
}
|
||||
|
||||
const { patch, dropped } = pickClientCardPatch(body);
|
||||
|
||||
if (dropped.length > 0) {
|
||||
|
|
@ -73,19 +93,17 @@ export async function PATCH(
|
|||
|
||||
const rows = buildActivityRows(card, patch, session.userId);
|
||||
|
||||
const updated = await pb
|
||||
.collection("cards")
|
||||
.update<CardRecord>(id, patch);
|
||||
|
||||
for (const row of rows) {
|
||||
await pb.collection("activity").create(row);
|
||||
}
|
||||
const updated = await writeCardWithAudit(pb, {
|
||||
cardId: id,
|
||||
patch: { ...patch },
|
||||
rows,
|
||||
});
|
||||
|
||||
return jsonOk({
|
||||
card: {
|
||||
id: updated.id,
|
||||
priority: updated.priority,
|
||||
target_date: updated.target_date,
|
||||
id: updated?.id ?? id,
|
||||
priority: updated?.priority ?? card.priority,
|
||||
target_date: updated?.target_date ?? card.target_date,
|
||||
},
|
||||
changes: rows.length,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,12 +4,24 @@
|
|||
* 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.
|
||||
*
|
||||
* The `AuditRowInput` shape is also what the rest of the codebase builds
|
||||
* directly when it knows the field it is writing (the Plan Puzzle proposal
|
||||
* actions in src/app/actions/plan/, for instance). It is shared with
|
||||
* src/lib/cards/write-with-audit.ts so every writer produces rows of the
|
||||
* same shape.
|
||||
*/
|
||||
|
||||
import type { ActivityAction, CardRecord } from "@/lib/pb/types";
|
||||
import type { ClientCardPatch } from "@/lib/cards/client-patch";
|
||||
|
||||
export interface ActivityRowInput {
|
||||
/**
|
||||
* One row in the `activity` collection, in the wire shape PocketBase writes.
|
||||
*
|
||||
* `project` is denormalised onto every row so a "history of this card" read
|
||||
* does not need a join to `cards` first.
|
||||
*/
|
||||
export interface AuditRowInput {
|
||||
project: string;
|
||||
card: string;
|
||||
actor: string;
|
||||
|
|
@ -38,8 +50,8 @@ export function buildActivityRows(
|
|||
card: CardRecord,
|
||||
patch: ClientCardPatch,
|
||||
actorId: string
|
||||
): ActivityRowInput[] {
|
||||
const rows: ActivityRowInput[] = [];
|
||||
): AuditRowInput[] {
|
||||
const rows: AuditRowInput[] = [];
|
||||
|
||||
for (const key of Object.keys(patch) as Array<keyof ClientCardPatch>) {
|
||||
const next = patch[key];
|
||||
|
|
|
|||
|
|
@ -82,6 +82,74 @@ describe("describeActivity", () => {
|
|||
).toBe("reopened “Wire up the till”");
|
||||
});
|
||||
|
||||
it("describes a column move with the new column name", () => {
|
||||
expect(
|
||||
describeActivity(
|
||||
row({ action: "column_changed", field: "column", old_value: "col_done", new_value: "Backlog" })
|
||||
)
|
||||
).toBe("moved this card to Backlog");
|
||||
});
|
||||
|
||||
it("describes a column move out of a column when only the old name is known", () => {
|
||||
expect(
|
||||
describeActivity(
|
||||
row({ action: "column_changed", field: "column", old_value: "Done", new_value: "" })
|
||||
)
|
||||
).toBe("moved this card out of Done");
|
||||
});
|
||||
|
||||
it("describes a client proposal in human terms", () => {
|
||||
expect(
|
||||
describeActivity(
|
||||
row({
|
||||
action: "date_proposed",
|
||||
field: "proposed_target_date",
|
||||
old_value: "",
|
||||
new_value: "2026-11-15",
|
||||
})
|
||||
)
|
||||
).toBe("proposed moving this card to 15 Nov 2026");
|
||||
});
|
||||
|
||||
it("describes an accepted proposal with its date", () => {
|
||||
expect(
|
||||
describeActivity(
|
||||
row({
|
||||
action: "date_proposal_resolved",
|
||||
field: "proposed_target_date",
|
||||
old_value: "2026-11-15",
|
||||
new_value: "accepted",
|
||||
})
|
||||
)
|
||||
).toBe("accepted the proposal for 15 Nov 2026");
|
||||
});
|
||||
|
||||
it("describes a dismissed proposal with its date", () => {
|
||||
expect(
|
||||
describeActivity(
|
||||
row({
|
||||
action: "date_proposal_resolved",
|
||||
field: "proposed_target_date",
|
||||
old_value: "2026-11-15",
|
||||
new_value: "dismissed",
|
||||
})
|
||||
)
|
||||
).toBe("dismissed the proposal for 15 Nov 2026");
|
||||
});
|
||||
|
||||
it("falls back gracefully when a resolved proposal has no date", () => {
|
||||
expect(
|
||||
describeActivity(
|
||||
row({
|
||||
action: "date_proposal_resolved",
|
||||
field: "proposed_target_date",
|
||||
old_value: "",
|
||||
new_value: "accepted",
|
||||
})
|
||||
)
|
||||
).toBe("accepted a proposal");
|
||||
});
|
||||
|
||||
it("describes a generic update", () => {
|
||||
expect(describeActivity(row({ action: "card_updated", field: "description" })))
|
||||
.toBe("updated the description");
|
||||
|
|
|
|||
|
|
@ -65,6 +65,28 @@ export function describeActivity(row: ActivityRecord): string {
|
|||
? `moved this card to ${row.new_value}`
|
||||
: "moved this card";
|
||||
|
||||
case "column_changed":
|
||||
// Both directions (working → Done, Done → working) are written with
|
||||
// the same action. The value pairs name the columns; the surrounding
|
||||
// UI is expected to show a name, not a raw id. We render the new
|
||||
// value as-is because the writer (the move route) has already
|
||||
// resolved it to a label.
|
||||
return row.new_value
|
||||
? `moved this card to ${row.new_value}`
|
||||
: `moved this card out of ${row.old_value}`;
|
||||
|
||||
case "date_proposed":
|
||||
return row.new_value
|
||||
? `proposed moving this card to ${longDate(row.new_value)}`
|
||||
: "proposed a date for this card";
|
||||
|
||||
case "date_proposal_resolved": {
|
||||
const proposed = longDate(row.old_value);
|
||||
const verdict = row.new_value === "accepted" ? "accepted" : "dismissed";
|
||||
if (!proposed) return `${verdict} a proposal`;
|
||||
return `${verdict} the proposal for ${proposed}`;
|
||||
}
|
||||
|
||||
case "comment_added":
|
||||
return "commented";
|
||||
|
||||
|
|
|
|||
213
src/lib/cards/write-with-audit.test.ts
Normal file
213
src/lib/cards/write-with-audit.test.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import {
|
||||
auditRow,
|
||||
writeCardWithAudit,
|
||||
} from "@/lib/cards/write-with-audit";
|
||||
import type { CardRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
* A mock PocketBase that records every collection call so we can assert on
|
||||
* the order and payload without a running database.
|
||||
*/
|
||||
function mockPb(card: CardRecord) {
|
||||
const cards = {
|
||||
update: vi.fn(async () => card),
|
||||
getOne: vi.fn(async () => card),
|
||||
};
|
||||
const activity = {
|
||||
create: vi.fn(async () => ({ id: "row_1" })),
|
||||
};
|
||||
return {
|
||||
collection: vi.fn((name: string) => {
|
||||
if (name === "cards") return cards;
|
||||
if (name === "activity") return activity;
|
||||
throw new Error(`unexpected collection ${name}`);
|
||||
}),
|
||||
_cards: cards,
|
||||
_activity: activity,
|
||||
};
|
||||
}
|
||||
|
||||
describe("writeCardWithAudit", () => {
|
||||
it("applies a card patch then writes each activity row in order", async () => {
|
||||
const card = {
|
||||
id: "card_1",
|
||||
project: "proj_1",
|
||||
target_date: "2026-10-01",
|
||||
} as CardRecord;
|
||||
const pb = mockPb(card);
|
||||
|
||||
const result = await writeCardWithAudit(pb as never, {
|
||||
cardId: "card_1",
|
||||
patch: { target_date: "2026-11-15" },
|
||||
rows: [
|
||||
auditRow({
|
||||
project: "proj_1",
|
||||
card: "card_1",
|
||||
actor: "u_owner",
|
||||
action: "date_changed",
|
||||
field: "target_date",
|
||||
oldValue: "2026-10-01",
|
||||
newValue: "2026-11-15",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(pb._cards.update).toHaveBeenCalledWith("card_1", {
|
||||
target_date: "2026-11-15",
|
||||
});
|
||||
expect(pb._activity.create).toHaveBeenCalledTimes(1);
|
||||
expect(pb._activity.create).toHaveBeenCalledWith({
|
||||
project: "proj_1",
|
||||
card: "card_1",
|
||||
actor: "u_owner",
|
||||
action: "date_changed",
|
||||
field: "target_date",
|
||||
old_value: "2026-10-01",
|
||||
new_value: "2026-11-15",
|
||||
});
|
||||
expect(pb._cards.getOne).toHaveBeenCalledWith("card_1");
|
||||
expect(result?.id).toBe("card_1");
|
||||
});
|
||||
|
||||
it("writes the card update before the activity rows", async () => {
|
||||
// The order matters: if the card update fails, no rows should be
|
||||
// emitted; if the activity emit fails, the card change should still
|
||||
// land. This test pins the "card first, rows after" contract.
|
||||
const card = { id: "c", project: "p" } as CardRecord;
|
||||
const pb = mockPb(card);
|
||||
|
||||
const calls: string[] = [];
|
||||
pb._cards.update.mockImplementation(async () => {
|
||||
calls.push("cards.update");
|
||||
return card;
|
||||
});
|
||||
pb._activity.create.mockImplementation(async () => {
|
||||
calls.push("activity.create");
|
||||
return { id: "r" };
|
||||
});
|
||||
|
||||
await writeCardWithAudit(pb as never, {
|
||||
cardId: "c",
|
||||
patch: { priority: "high" },
|
||||
rows: [
|
||||
auditRow({
|
||||
project: "p",
|
||||
card: "c",
|
||||
actor: "u",
|
||||
action: "priority_changed",
|
||||
field: "priority",
|
||||
oldValue: "medium",
|
||||
newValue: "high",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(calls).toEqual(["cards.update", "activity.create"]);
|
||||
});
|
||||
|
||||
it("skips the card update when the patch is empty (log-only actions)", async () => {
|
||||
// Accept/dismiss proposals only need to clear the three proposal
|
||||
// fields — the server action accepts that an empty patch is a no-op
|
||||
// write and proceeds to emit rows. This is what `returnToScheduled`
|
||||
// and the proposal-resolution actions rely on.
|
||||
const card = { id: "c", project: "p" } as CardRecord;
|
||||
const pb = mockPb(card);
|
||||
|
||||
await writeCardWithAudit(pb as never, {
|
||||
cardId: "c",
|
||||
patch: {},
|
||||
rows: [
|
||||
auditRow({
|
||||
project: "p",
|
||||
card: "c",
|
||||
actor: "u",
|
||||
action: "date_proposal_resolved",
|
||||
field: "proposed_target_date",
|
||||
oldValue: "2026-11-15",
|
||||
newValue: "dismissed",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(pb._cards.update).not.toHaveBeenCalled();
|
||||
expect(pb._activity.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null when the post-update read fails (write did not)", async () => {
|
||||
const card = { id: "c", project: "p" } as CardRecord;
|
||||
const pb = mockPb(card);
|
||||
pb._cards.getOne.mockRejectedValue(new Error("not found"));
|
||||
|
||||
const result = await writeCardWithAudit(pb as never, {
|
||||
cardId: "c",
|
||||
patch: { priority: "low" },
|
||||
rows: [],
|
||||
});
|
||||
|
||||
expect(pb._cards.update).toHaveBeenCalledWith("c", { priority: "low" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("emits multiple rows in the order given", async () => {
|
||||
const card = { id: "c", project: "p" } as CardRecord;
|
||||
const pb = mockPb(card);
|
||||
|
||||
await writeCardWithAudit(pb as never, {
|
||||
cardId: "c",
|
||||
patch: { target_date: "2026-11-15" },
|
||||
rows: [
|
||||
auditRow({
|
||||
project: "p",
|
||||
card: "c",
|
||||
actor: "u",
|
||||
action: "date_changed",
|
||||
field: "target_date",
|
||||
oldValue: "2026-10-01",
|
||||
newValue: "2026-11-15",
|
||||
}),
|
||||
auditRow({
|
||||
project: "p",
|
||||
card: "c",
|
||||
actor: "u",
|
||||
action: "date_proposal_resolved",
|
||||
field: "proposed_target_date",
|
||||
oldValue: "2026-11-15",
|
||||
newValue: "accepted",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const actions: string[] = [];
|
||||
for (const call of pb._activity.create.mock.calls as unknown as Array<[unknown]>) {
|
||||
const arg = call[0] as { action: string };
|
||||
actions.push(arg.action);
|
||||
}
|
||||
expect(actions).toEqual(["date_changed", "date_proposal_resolved"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auditRow", () => {
|
||||
it("maps camelCase args onto the wire shape (snake_case)", () => {
|
||||
const row = auditRow({
|
||||
project: "p",
|
||||
card: "c",
|
||||
actor: "u",
|
||||
action: "date_proposed",
|
||||
field: "proposed_target_date",
|
||||
oldValue: "",
|
||||
newValue: "2026-11-15",
|
||||
});
|
||||
|
||||
expect(row).toEqual({
|
||||
project: "p",
|
||||
card: "c",
|
||||
actor: "u",
|
||||
action: "date_proposed",
|
||||
field: "proposed_target_date",
|
||||
old_value: "",
|
||||
new_value: "2026-11-15",
|
||||
});
|
||||
});
|
||||
});
|
||||
107
src/lib/cards/write-with-audit.ts
Normal file
107
src/lib/cards/write-with-audit.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Single source of truth for "patch a card and emit activity rows that
|
||||
* describe the change".
|
||||
*
|
||||
* Three writers used to do this inline:
|
||||
* - the client PATCH /api/client/cards/[id] route handler
|
||||
* - src/app/actions/reschedule.ts (owner drag-from-timeline)
|
||||
* - the new Plan Puzzle server actions in src/app/actions/plan/
|
||||
*
|
||||
* Each one's inline copy drifted subtly. The PATCH route used the caller's
|
||||
* superuser admin client and called `pb.collection("activity").create(row)`
|
||||
* in a loop; rescheduleCardAction called `logActivity` (which is itself an
|
||||
* admin helper). Today they disagree about who emits and how; a future
|
||||
* audit-row bug would be fixed in one and not the other. This module
|
||||
* collapses all of that into one place.
|
||||
*
|
||||
* Every caller still composes its own `patch` and `rows` — the helper just
|
||||
* applies both atomically with respect to the caller. The order is
|
||||
* intentional: card first, then activity. If the activity insert blows up,
|
||||
* the card change already landed, which is the same order every previous
|
||||
* writer used and matches the "history is allowed to be partial" trade-off
|
||||
* the rest of the codebase already makes (see logActivity's doc comment).
|
||||
*
|
||||
* The `AuditRowInput` type lives in @/lib/activity/diff so unit tests can
|
||||
* import it without the `server-only` guard here firing.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import type PocketBase from "pocketbase";
|
||||
|
||||
import type { AuditRowInput } from "@/lib/activity/diff";
|
||||
import type { CardRecord } from "@/lib/pb/types";
|
||||
|
||||
export type { AuditRowInput };
|
||||
|
||||
export interface CardPatchWithAudit {
|
||||
cardId: string;
|
||||
/**
|
||||
* PocketBase update payload. Empty object means "no card change" — useful
|
||||
* for actions that only need to log activity (accept/dismiss resolutions,
|
||||
* today). The helper still returns the freshly-read card.
|
||||
*/
|
||||
patch: Record<string, unknown>;
|
||||
/**
|
||||
* Activity rows that should be emitted after the card update. Each is
|
||||
* written in order; a row that throws aborts the helper but does not
|
||||
* roll back the card change. Callers must build these before calling.
|
||||
*/
|
||||
rows: AuditRowInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a card patch and emit activity rows.
|
||||
*
|
||||
* The PocketBase instance is injected so this module is callable from both
|
||||
* superuser (admin) contexts — `/api/client/cards/[id]` — and request-
|
||||
* scoped server-action contexts — `rescheduleCardAction`, the puzzle
|
||||
* actions. Both need exactly the same code path; what differs is the
|
||||
* token.
|
||||
*
|
||||
* Returns the freshly-read card so callers can chain `getOne`-style reads
|
||||
* without an extra round-trip. Returns null when the post-update read
|
||||
* fails — the write itself did succeed, so callers should treat null as
|
||||
* "trust the patch you sent in" rather than as a failure.
|
||||
*/
|
||||
export async function writeCardWithAudit(
|
||||
pb: PocketBase,
|
||||
input: CardPatchWithAudit
|
||||
): Promise<CardRecord | null> {
|
||||
if (Object.keys(input.patch).length > 0) {
|
||||
await pb.collection("cards").update(input.cardId, input.patch);
|
||||
}
|
||||
|
||||
for (const row of input.rows) {
|
||||
await pb.collection("activity").create(row);
|
||||
}
|
||||
|
||||
return pb.collection("cards").getOne<CardRecord>(input.cardId).catch(() => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one activity row for a card-field change.
|
||||
*
|
||||
* Caller chooses the action, the field name, and the before/after values.
|
||||
* A helper rather than a class so the input shape stays JSON-serialisable
|
||||
* and easy to compose in server-action return paths.
|
||||
*/
|
||||
export function auditRow(args: {
|
||||
project: string;
|
||||
card: string;
|
||||
actor: string;
|
||||
action: import("@/lib/pb/types").ActivityAction;
|
||||
field: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}): AuditRowInput {
|
||||
return {
|
||||
project: args.project,
|
||||
card: args.card,
|
||||
actor: args.actor,
|
||||
action: args.action,
|
||||
field: args.field,
|
||||
old_value: args.oldValue,
|
||||
new_value: args.newValue,
|
||||
};
|
||||
}
|
||||
|
|
@ -9,9 +9,12 @@ export type CreatedByRole = "owner" | "client";
|
|||
export type ActivityAction =
|
||||
| "card_created"
|
||||
| "card_moved"
|
||||
| "column_changed"
|
||||
| "card_updated"
|
||||
| "priority_changed"
|
||||
| "date_changed"
|
||||
| "date_proposed"
|
||||
| "date_proposal_resolved"
|
||||
| "card_accepted"
|
||||
| "comment_added"
|
||||
| "subtask_toggled"
|
||||
|
|
@ -94,6 +97,17 @@ export interface CardRecord extends BaseRecord {
|
|||
/** The interview question-and-answer pairs. */
|
||||
transcript: unknown;
|
||||
size_band: "small" | "medium" | "large" | "";
|
||||
/**
|
||||
* Client-proposed target date. Owners do not write this field through the
|
||||
* Puzzle — they write `target_date` directly. The three proposal fields
|
||||
* are written together by `clientProposePlanAction` and cleared together
|
||||
* by `acceptClientProposalAction` / `dismissClientProposalAction`. A row
|
||||
* with one set and the others unset is logically incoherent; the server
|
||||
* actions never produce it.
|
||||
*/
|
||||
proposed_target_date?: string;
|
||||
proposed_by?: string;
|
||||
proposed_at?: string;
|
||||
}
|
||||
|
||||
export interface SubtaskRecord extends BaseRecord {
|
||||
|
|
|
|||
141
src/lib/roadmap/slot-date.test.ts
Normal file
141
src/lib/roadmap/slot-date.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
SLOTS,
|
||||
isSlotId,
|
||||
monthLabel,
|
||||
slotFromDate,
|
||||
slotToDate,
|
||||
} from "@/lib/roadmap/slot-date";
|
||||
|
||||
const today = new Date("2026-09-17T12:00:00Z");
|
||||
|
||||
describe("SLOTS", () => {
|
||||
it("lists the three slots in left-to-right order", () => {
|
||||
expect([...SLOTS]).toEqual(["this_month", "next_month", "month_after"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSlotId", () => {
|
||||
it("accepts each slot id", () => {
|
||||
expect(isSlotId("this_month")).toBe(true);
|
||||
expect(isSlotId("next_month")).toBe(true);
|
||||
expect(isSlotId("month_after")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects everything else", () => {
|
||||
expect(isSlotId("")).toBe(false);
|
||||
expect(isSlotId("now")).toBe(false);
|
||||
expect(isSlotId("later")).toBe(false);
|
||||
expect(isSlotId(null)).toBe(false);
|
||||
expect(isSlotId(undefined)).toBe(false);
|
||||
expect(isSlotId(2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slotToDate", () => {
|
||||
it("writes the 15th of each slot's month, UTC", () => {
|
||||
expect(slotToDate("this_month", today)).toBe("2026-09-15");
|
||||
expect(slotToDate("next_month", today)).toBe("2026-10-15");
|
||||
expect(slotToDate("month_after", today)).toBe("2026-11-15");
|
||||
});
|
||||
|
||||
it("rolls over the year boundary for month-after in December", () => {
|
||||
const december = new Date("2026-12-04T00:00:00Z");
|
||||
expect(slotToDate("this_month", december)).toBe("2026-12-15");
|
||||
expect(slotToDate("next_month", december)).toBe("2027-01-15");
|
||||
expect(slotToDate("month_after", december)).toBe("2027-02-15");
|
||||
});
|
||||
|
||||
it("is symmetric with slotFromDate for every slot id", () => {
|
||||
for (const slot of SLOTS) {
|
||||
const date = slotToDate(slot, today);
|
||||
expect(slotFromDate(date, today)).toBe(slot);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic regardless of clock-time within the same day", () => {
|
||||
const morning = new Date("2026-09-17T01:00:00Z");
|
||||
const evening = new Date("2026-09-17T23:00:00Z");
|
||||
expect(slotToDate("this_month", morning)).toBe(
|
||||
slotToDate("this_month", evening)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slotFromDate", () => {
|
||||
it("places the 15th of each window into the matching slot", () => {
|
||||
expect(slotFromDate("2026-09-15", today)).toBe("this_month");
|
||||
expect(slotFromDate("2026-10-15", today)).toBe("next_month");
|
||||
expect(slotFromDate("2026-11-15", today)).toBe("month_after");
|
||||
});
|
||||
|
||||
it("is forgiving about the day-of-month within the slot", () => {
|
||||
// The puzzle always writes the 15th, but a card whose target_date was
|
||||
// hand-set on a different day should still resolve into its slot — the
|
||||
// 30-day approximation is exact for mid-month dates and close enough
|
||||
// for any date that lands within the slot's window.
|
||||
expect(slotFromDate("2026-09-01", today)).toBe("this_month");
|
||||
expect(slotFromDate("2026-09-30", today)).toBe("this_month");
|
||||
expect(slotFromDate("2026-10-02", today)).toBe("next_month");
|
||||
expect(slotFromDate("2026-10-31", today)).toBe("next_month");
|
||||
});
|
||||
|
||||
it("returns null for a date in the past", () => {
|
||||
expect(slotFromDate("2026-08-15", today)).toBeNull();
|
||||
expect(slotFromDate("2025-12-15", today)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a date beyond the month-after window", () => {
|
||||
expect(slotFromDate("2026-12-15", today)).toBeNull();
|
||||
expect(slotFromDate("2027-01-15", today)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(slotFromDate("", today)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a date the puzzle could not have written", () => {
|
||||
// The 31st is a 30-day-month edge case. Returning null (and the caller
|
||||
// falls back to the tray) is the safe choice.
|
||||
expect(slotFromDate("2026-01-31", today)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for malformed input rather than throwing", () => {
|
||||
expect(slotFromDate("not-a-date", today)).toBeNull();
|
||||
expect(slotFromDate("2026-13-01", today)).toBeNull();
|
||||
});
|
||||
|
||||
it("survives a PocketBase-style datetime string", () => {
|
||||
// load.ts reads card.target_date directly; if a row has a datetime
|
||||
// instead of a date, the slice-and-parse path must still land in the
|
||||
// right slot. The function operates on a substring (slice(0,10)) so
|
||||
// callers are expected to pre-trim — verify the contract holds.
|
||||
expect(slotFromDate("2026-10-15 00:00:00.000Z".slice(0, 10), today)).toBe(
|
||||
"next_month"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("monthLabel", () => {
|
||||
it("returns full English month names", () => {
|
||||
expect(monthLabel("this_month", today)).toBe("September");
|
||||
expect(monthLabel("next_month", today)).toBe("October");
|
||||
expect(monthLabel("month_after", today)).toBe("November");
|
||||
});
|
||||
|
||||
it("rolls the year forward across the December boundary", () => {
|
||||
const december = new Date("2026-12-04T00:00:00Z");
|
||||
expect(monthLabel("this_month", december)).toBe("December");
|
||||
expect(monthLabel("next_month", december)).toBe("January");
|
||||
expect(monthLabel("month_after", december)).toBe("February");
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip — slot → date → slot", () => {
|
||||
it("returns to the same slot for every slot id", () => {
|
||||
for (const slot of SLOTS) {
|
||||
expect(slotFromDate(slotToDate(slot, today), today)).toBe(slot);
|
||||
}
|
||||
});
|
||||
});
|
||||
88
src/lib/roadmap/slot-date.ts
Normal file
88
src/lib/roadmap/slot-date.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Slot ↔ target_date mapping for the Plan Puzzle.
|
||||
*
|
||||
* The Puzzle places cards into one of three slots — This Month, Next Month,
|
||||
* Month After — and writes the mid-month (15th) of the slot's month as the
|
||||
* card's `target_date`. Reads do the reverse: given an ISO date, return the
|
||||
* slot it lands in, or `null` if the date falls outside the three-month
|
||||
* window.
|
||||
*
|
||||
* Every function takes `now` as an optional argument so unit tests can pin
|
||||
* the calendar to a fixed instant. Without that the tests would flake
|
||||
* every time a real-world clock crossed a month boundary mid-run.
|
||||
*
|
||||
* Pixel's `plan-puzzle-v1-frontend` branch published a draft of this file
|
||||
* with the same signatures; this is the canonical implementation on the
|
||||
* `plan-puzzle-v1-backend` branch and the one the production code imports.
|
||||
*/
|
||||
|
||||
export type SlotId = "this_month" | "next_month" | "month_after";
|
||||
|
||||
/** The puzzle's three slots, in left-to-right order. */
|
||||
export const SLOTS: readonly SlotId[] = [
|
||||
"this_month",
|
||||
"next_month",
|
||||
"month_after",
|
||||
] as const;
|
||||
|
||||
const DAYS_PER_MONTH_MS = 30 * 86_400_000;
|
||||
|
||||
/** True if the value is one of the three slot ids. Narrows `string` for callers. */
|
||||
export function isSlotId(value: unknown): value is SlotId {
|
||||
return (
|
||||
value === "this_month" || value === "next_month" || value === "month_after"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC midnight on the 15th of the month `slotIdx` months after `now`'s month.
|
||||
*
|
||||
* Mid-month is deliberate: a card whose target_date is the 1st reads as
|
||||
* "starting now", and the 30th reads as "running out of time". The 15th is
|
||||
* the only day that doesn't bias either way.
|
||||
*/
|
||||
export function slotToDate(slot: SlotId, now: Date = new Date()): string {
|
||||
const offset = SLOTS.indexOf(slot);
|
||||
// SlotId is always one of SLOTS; indexOf never returns -1 for valid input.
|
||||
const time = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + offset, 15);
|
||||
return new Date(time).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which slot a date lands in. Returns `null` when the date is before this
|
||||
* month, more than two months out, or unparseable — the caller treats `null`
|
||||
* as "this card lives in the tray, not a slot".
|
||||
*
|
||||
* Uses a 30-day month approximation. For mid-month dates (always the 15th,
|
||||
* written by the puzzle itself) the arithmetic is exact; for dates written
|
||||
* by hand, like `2026-01-31`, the result can drift by one slot. The
|
||||
* mitigation is the explicit "no slot → tray" fallback in the caller.
|
||||
*/
|
||||
export function slotFromDate(iso: string, now: Date = new Date()): SlotId | null {
|
||||
if (!iso) return null;
|
||||
const year = Number(iso.slice(0, 4));
|
||||
const month = Number(iso.slice(5, 7));
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month)) return null;
|
||||
|
||||
const target = Date.UTC(year, month - 1, 15);
|
||||
const base = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 15);
|
||||
const months = Math.round((target - base) / DAYS_PER_MONTH_MS);
|
||||
|
||||
if (months === 0) return "this_month";
|
||||
if (months === 1) return "next_month";
|
||||
if (months === 2) return "month_after";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "October", "November", "December" — the column header text.
|
||||
*
|
||||
* Rendered in en-US for now. The spec says "month names, updates annually"
|
||||
* — which it does, because the month is computed from `now` each render —
|
||||
* so no yearly refresh job is needed.
|
||||
*/
|
||||
export function monthLabel(slot: SlotId, now: Date = new Date()): string {
|
||||
const offset = SLOTS.indexOf(slot);
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + offset, 1));
|
||||
return d.toLocaleString("en-US", { month: "long", timeZone: "UTC" });
|
||||
}
|
||||
|
|
@ -2,6 +2,10 @@ import { defineConfig } from "vitest/config";
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const serverOnlyStub = fileURLToPath(
|
||||
new URL("./src/lib/server-only-stub.ts", import.meta.url)
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
|
|
@ -9,7 +13,15 @@ export default defineConfig({
|
|||
test: {
|
||||
projects: [
|
||||
{
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
|
||||
// Unit tests can also import server-only modules (e.g.
|
||||
// writeCardWithAudit) and replace the guard with the empty stub,
|
||||
// matching the integration project's alias.
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"server-only": serverOnlyStub,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
name: "unit",
|
||||
environment: "node",
|
||||
|
|
@ -23,9 +35,7 @@ export default defineConfig({
|
|||
// Replace server-only with the empty stub so integration tests
|
||||
// can import server modules without "imported from a Client
|
||||
// Component module" blowing up at prepare time.
|
||||
"server-only": fileURLToPath(
|
||||
new URL("./src/lib/server-only-stub.ts", import.meta.url)
|
||||
),
|
||||
"server-only": serverOnlyStub,
|
||||
},
|
||||
conditions: ["react-server", "node", "import", "default"],
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue