kanban-dev-webapp/pocketbase/pb_migrations/1789601200_added_card_proposals.js
Dave b3aac9ba4f 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.
2026-09-19 18:35:33 +02:00

86 lines
2.6 KiB
JavaScript

/// <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);
}
);