Public: anyone can read this; the crew can change it.
The agent can undo the last move of a date from the deal page #307
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/303-the-agent-can-undo-the-last-move-of-a-da → main 2 commits fork
13 files
changed +233
−10
README.md +1 −1
| @@ -165,7 +165,7 @@ sees them beside the date on the client timeline, your calendar file gives | ||
| 165 | 165 | that event the time (in your Profile time zone) and location, and the |
| 166 | 166 | reminder says "10:00 am at 12 Elm St". In the terminal press `@` on the date; |
| 167 | 167 | over MCP use `set_appointment`; both show it on the date. A date on a weekend or |
| 168 | federal holiday says so and offers **Move to** the next business day. For an | |
| 168 | federal holiday says so and offers **Move to** the next business day. Slid a date by mistake? Its row on the deal page offers **Undo move (back to Nov 20)**: one tap puts it back, with its reminders (in the terminal, **U** on the date). It is offered only to whoever made that move, once (one level), and nothing once the date is done, waived or moved again by someone else. For an | |
| 169 | 169 | addendum that moves several dates, **Amend the contract** takes one reason and |
| 170 | 170 | sends each party one notice. When buyer and seller agree to close on another |
| 171 | 171 | day, **Closing moved** takes the new date and a reason, then lists every open |
docs/TECHNICAL.md +1
| @@ -90,6 +90,7 @@ An open date due on a weekend or US federal holiday (`src/dates.ts`, computed, n | ||
| 90 | 90 | table) carries `deadlines[].nonBusinessDay: { next }` in the API and in MCP |
| 91 | 91 | `get_deal` (`null` otherwise); the sheet's and deal page's *Move to* button posts |
| 92 | 92 | `POST /deadlines/:id/move` with the reason "Rolled to the next business day". |
| 93 | `POST /deadlines/:id/unmove` (same-origin, `permit(update)` on the deal, audited as `deadline.unmove`) puts a date back where its latest move found it: `undoableMove` offers it only to whoever made the latest move, while that move was made alone, is not itself an undo (`deadline_move.undo = 1`) and the date is open at the date the move gave it. The undo is a further row in the date's history with `notify` 0, so no "date changed" notice is drafted for it; a notice already sent for the undone move stays sent. | |
| 93 | 94 | `deadline_move.notify` is 1 only for moves made after the moment existed. |
| 94 | 95 | `send_reminder` and the decline and undo doors take `moment: "moved"`. |
| 95 | 96 |
src/activity.ts +1
| @@ -20,6 +20,7 @@ const WORDS: Readonly<Record<string, string>> = { | ||
| 20 | 20 | "reminder.release": "brought back a held reminder", |
| 21 | 21 | "reminder.undo": "undid a declined reminder", |
| 22 | 22 | "date.move": "moved a date", |
| 23 | "deadline.unmove": "put a moved date back", | |
| 23 | 24 | "date.done": "marked a date done", |
| 24 | 25 | "date.waive": "waived a date", |
| 25 | 26 | "date.owes": "changed who owes a date", |
src/db.ts +2
| @@ -584,6 +584,8 @@ export const migrations: string[] = [ | ||
| 584 | 584 | in_at TEXT |
| 585 | 585 | ); |
| 586 | 586 | CREATE INDEX IF NOT EXISTS paper_deadline ON paper (deadline_id)`, |
| 587 | // A move that put an earlier move back: it offers no undo of its own, so undoing is single level. | |
| 588 | `ALTER TABLE deadline_move ADD COLUMN undo INTEGER NOT NULL DEFAULT 0`, | |
| 587 | 589 | ]; |
| 588 | 590 | |
| 589 | 591 | export function openDb(dir = dataDir): Database { |
src/deals.ts +36
| @@ -111,6 +111,8 @@ export interface Move { | ||
| 111 | 111 | readonly notify: 0 | 1; |
| 112 | 112 | /** The addendum that made this move together with others; 0 for a move made on its own. */ |
| 113 | 113 | readonly amendment: number; |
| 114 | /** 1 when this move put an earlier one back (the undo); 0 for every other. */ | |
| 115 | readonly undo: 0 | 1; | |
| 114 | 116 | } |
| 115 | 117 | /** A reminder held until a later morning; keyed like a sent or declined one. */ |
| 116 | 118 | export interface Held { |
| @@ -1089,6 +1091,40 @@ export function moveDeadline( | ||
| 1089 | 1091 | return within(db, who, "date.move", dealId, ok(reread(db, deadline.id))); |
| 1090 | 1092 | } |
| 1091 | 1093 | |
| 1094 | /** | |
| 1095 | * The move `by` can still put back: the date's latest, if `by` made it alone | |
| 1096 | * (not an amendment, not itself an undo) and the date is still open at the | |
| 1097 | * date that move gave it. The offer is the mover's: when someone else moves | |
| 1098 | * the date again it is theirs and the first mover's is gone. One level: after | |
| 1099 | * an undo the latest move is the undo, which offers nothing. | |
| 1100 | */ | |
| 1101 | export function undoableMove(deadline: Deadline & { moves: readonly Move[] }, by: string): Move | undefined { | |
| 1102 | const last = deadline.moves[deadline.moves.length - 1]; | |
| 1103 | if (last === undefined || by === "" || last.by_name !== by) return undefined; | |
| 1104 | if (last.undo === 1 || last.amendment !== 0) return undefined; | |
| 1105 | if (deadline.done_at !== null || deadline.waived_at !== null || deadline.due !== last.to_due) return undefined; | |
| 1106 | return last; | |
| 1107 | } | |
| 1108 | ||
| 1109 | /** Put a date back where the caller's latest move found it; the move stays in its history, followed by the undo. */ | |
| 1110 | export function undoMove(db: Database, who: Who, input: { id: number }): Result<Deadline> { | |
| 1111 | const WHAT = "deadline.unmove"; | |
| 1112 | const { dealId, found } = deadlineFor(db, who, input.id); | |
| 1113 | if (!found.ok) return within(db, who, WHAT, dealId, found); | |
| 1114 | const { deadline } = found.value; | |
| 1115 | const moves = db.query<Move, [number]>("SELECT * FROM deadline_move WHERE deadline_id = ?1 ORDER BY id").all(deadline.id); | |
| 1116 | const last = undoableMove({ ...deadline, moves }, authorOf(who).name); | |
| 1117 | if (last === undefined) | |
| 1118 | return within(db, who, WHAT, dealId, fail("invalid", "There is no move of yours to undo on this date.")); | |
| 1119 | db.transaction(() => { | |
| 1120 | db.query("UPDATE deadline SET due = ?1 WHERE id = ?2").run(last.from_due, deadline.id); | |
| 1121 | db.query( | |
| 1122 | "INSERT INTO deadline_move (deadline_id, from_due, to_due, reason, by_name, notify, undo) VALUES (?1, ?2, ?3, ?4, ?5, 0, 1)", | |
| 1123 | ).run(deadline.id, deadline.due, last.from_due, "Undid the last move", authorOf(who).name); | |
| 1124 | })(); | |
| 1125 | return within(db, who, WHAT, dealId, ok(reread(db, deadline.id))); | |
| 1126 | } | |
| 1127 | ||
| 1092 | 1128 | /** One amendment, checked: the moves it makes, or the field that was wrong and why. */ |
| 1093 | 1129 | export type Amendment = |
| 1094 | 1130 | | { readonly ok: true; readonly moves: readonly { readonly deadline: Deadline; readonly to: string }[]; readonly reason: string } |
src/migrations.pin.json +2 −1
| @@ -60,5 +60,6 @@ | ||
| 60 | 60 | "4a52d9de76ede6d34d831d9bc6fc1d3d3a501a5d3652bf63695a9de49f2a7d39", |
| 61 | 61 | "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3", |
| 62 | 62 | "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5", |
| 63 | "c82fe9b3aea33d99a2c65ffb4179901d5aecde6b2ef759531b94ff12035a719a" | |
| 63 | "c82fe9b3aea33d99a2c65ffb4179901d5aecde6b2ef759531b94ff12035a719a", | |
| 64 | "d7c7a79baf6dcd397af5c223839a821db0f0b744f6f02358ea4ba38532ae148b" | |
| 64 | 65 | ] |
src/migrations.test.ts +1 −1
| @@ -13,7 +13,7 @@ test("every migration is pinned by position; a pull may only append", () => { | ||
| 13 | 13 | }); |
| 14 | 14 | |
| 15 | 15 | test("a database made before the Profile line gets the column and reads the agent", () => { |
| 16 | const before = migrations.filter((m) => !/profile ADD COLUMN line|commission_rate|profile ADD COLUMN zone|ADD COLUMN cash|'anniversary'|deadline ADD COLUMN (time|place)|party ADD COLUMN reach|reminder_hold ADD COLUMN back_on|TABLE IF NOT EXISTS paper/.test(m)); | |
| 16 | const before = migrations.filter((m) => !/profile ADD COLUMN line|commission_rate|profile ADD COLUMN zone|ADD COLUMN cash|'anniversary'|deadline ADD COLUMN (time|place)|party ADD COLUMN reach|reminder_hold ADD COLUMN back_on|TABLE IF NOT EXISTS paper|deadline_move ADD COLUMN undo/.test(m)); | |
| 17 | 17 | const db = new Database(":memory:"); |
| 18 | 18 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"); |
| 19 | 19 | before.forEach((sql, i) => { |
src/pages/deal.ts +2 −2
| @@ -25,7 +25,7 @@ import { dealSideLabel, sideLabel, sideSelect } from "./side.ts"; | ||
| 25 | 25 | import { papersLine } from "../papers.ts"; |
| 26 | 26 | import { progressLine } from "./progress.ts"; |
| 27 | 27 | import { appointmentForm, appointmentText, type AppointmentProblem } from "./appointment.ts"; |
| 28 | import { badge, conflictLines, nonBusinessLine, noteLine, owesChangedLine, rollForm, draftItem, dueLine, outboxLine, owedBy, releaseForm, sheet, sheetId } from "./sheet.ts"; | |
| 28 | import { badge, conflictLines, nonBusinessLine, noteLine, owesChangedLine, rollForm, undoMoveForm, draftItem, dueLine, outboxLine, owedBy, releaseForm, sheet, sheetId } from "./sheet.ts"; | |
| 29 | 29 | |
| 30 | 30 | const roleOptions = (selected?: string, roles: readonly (typeof PARTY_ROLES)[number][] = PARTY_ROLES): string => |
| 31 | 31 | roles.map( |
| @@ -306,7 +306,7 @@ export function dealPage(input: { | ||
| 306 | 306 | <p class="small">${owedBy(deal, d)}</p> |
| 307 | 307 | ${owesChangedLine(d)} |
| 308 | 308 | ${noteLine(d)} |
| 309 | ${nonBusinessLine(d)}${may ? rollForm(d, path) : ""}${may ? appointmentForm(d, path, input.appointment) : ""} | |
| 309 | ${nonBusinessLine(d)}${may ? rollForm(d, path) : ""}${may ? undoMoveForm(d, who, path) : ""}${may ? appointmentForm(d, path, input.appointment) : ""} | |
| 310 | 310 | </div> |
| 311 | 311 | <button class="button button-quiet" popovertarget="${sheetId(d)}">Open</button> |
| 312 | 312 | </li>`, |
src/pages/sheet.ts +11 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * script; the forms post and come back. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { BODY_MAX, canChange, NOTE_MAX, ROLL_REASON, REASON_MAX, SUBJECT_MAX, type DealView, type DeadlineView } from "../deals.ts"; | |
| 8 | import { BODY_MAX, canChange, undoableMove, NOTE_MAX, ROLL_REASON, REASON_MAX, SUBJECT_MAX, type DealView, type DeadlineView } from "../deals.ts"; | |
| 9 | 9 | import { doneLine, relative, short, spoken, urgencyOf, type Urgency } from "../dates.ts"; |
| 10 | 10 | import { CONTACT_ROLES, isInternal, isOpen, isWaivable, LEAD_MAX, LEAD_MIN, PARTY_LABEL } from "../deadlines.ts"; |
| 11 | 11 | import { escape } from "../http.ts"; |
| @@ -69,6 +69,16 @@ export const rollForm = (d: DeadlineView, to: string): string => | ||
| 69 | 69 | <button class="button button-quiet">Move to ${escape(spoken(d.nonBusinessDay.next))}</button> |
| 70 | 70 | </form>`; |
| 71 | 71 | |
| 72 | /** "Undo move" for a date's latest move, naming the due date it goes back to; nothing once it cannot. */ | |
| 73 | export const undoMoveForm = (d: DeadlineView, who: Who, to: string): string => { | |
| 74 | const move = who.role === "anon" ? undefined : undoableMove(d, who.name); | |
| 75 | return move === undefined | |
| 76 | ? "" | |
| 77 | : `<form method="post" action="/deadlines/${d.id}/unmove">${hidden({ to })} | |
| 78 | <button class="button button-quiet">Undo move <span class="muted">(back to ${escape(short(move.from_due))})</span></button> | |
| 79 | </form>`; | |
| 80 | }; | |
| 81 | ||
| 72 | 82 | export const badge = (d: DeadlineView, on: string): string => { |
| 73 | 83 | const u = urgencyOf(d, on); |
| 74 | 84 | return `<span class="badge" data-u="${u}">${URGENCY_LABEL[u]}</span>`; |
src/routes.ts +11
| @@ -69,6 +69,7 @@ import { | ||
| 69 | 69 | amendDeal, |
| 70 | 70 | amendment, |
| 71 | 71 | moveDeadline, |
| 72 | undoMove, | |
| 72 | 73 | nameOf, |
| 73 | 74 | removeDeadline, |
| 74 | 75 | removeParty, |
| @@ -1332,6 +1333,16 @@ export const routes: readonly Route[] = [ | ||
| 1332 | 1333 | return done(moved, backTo(f.to), moved.ok ? withConflicts(c.db, c.who, moved.value.deal_id, "Date moved; nothing else did.") : undefined); |
| 1333 | 1334 | }, |
| 1334 | 1335 | }, |
| 1336 | { | |
| 1337 | method: "POST", | |
| 1338 | path: "/deadlines/:id/unmove", | |
| 1339 | handle: async (c) => { | |
| 1340 | const f = await form(c); | |
| 1341 | if (f instanceof Response) return f; | |
| 1342 | const undone = undoMove(c.db, c.who, { id: Number(c.params.id) }); | |
| 1343 | return done(undone, backTo(f.to), undone.ok ? withConflicts(c.db, c.who, undone.value.deal_id, "Date put back.") : undefined); | |
| 1344 | }, | |
| 1345 | }, | |
| 1335 | 1346 | { |
| 1336 | 1347 | method: "POST", |
| 1337 | 1348 | path: "/deadlines/:id/owes", |
src/terminal.test.ts +22
| @@ -857,6 +857,28 @@ test("a date on a weekend shows its next business day, and N rolls it there with | ||
| 857 | 857 | expect(lines(t).join("\n")).toContain("already on a business day"); |
| 858 | 858 | }); |
| 859 | 859 | |
| 860 | test("U puts a date back after your own move, naming the old date; a second U and another person's U do nothing", () => { | |
| 861 | const t = terminal(db, alice, context); | |
| 862 | goTo(t, "Appraisal due — 12 Elm St"); | |
| 863 | const was = date("appraisal").due; | |
| 864 | const to = addDays(today(DEFAULT_ZONE), 30); | |
| 865 | press(t, "m", to, "\r", "Client asked", "\r"); | |
| 866 | expect(date("appraisal").due).toBe(to); | |
| 867 | const bob = terminal(db, { role: "user", id: "u2", name: "bob" }, context); | |
| 868 | goTo(bob, "Appraisal due — 12 Elm St"); | |
| 869 | bob.press("U"); | |
| 870 | expect(lines(bob).join("\n")).toContain("No move of yours to undo"); | |
| 871 | expect(date("appraisal").due).toBe(to); | |
| 872 | goTo(t, "Appraisal due — 12 Elm St"); | |
| 873 | t.press("U"); | |
| 874 | expect(lines(t).join("\n")).toContain(`Put Appraisal due back to ${spoken(was)}`); | |
| 875 | expect(date("appraisal").due).toBe(was); | |
| 876 | goTo(t, "Appraisal due — 12 Elm St"); | |
| 877 | t.press("U"); | |
| 878 | expect(lines(t).join("\n")).toContain("No move of yours to undo"); | |
| 879 | expect(date("appraisal").due).toBe(was); | |
| 880 | }); | |
| 881 | ||
| 860 | 882 | test("T sets and clears a date's note, shown beside the date, through setNote", () => { |
| 861 | 883 | const t = terminal(db, alice, context); |
| 862 | 884 | goTo(t, "Closing"); |
src/terminal.ts +12 −4
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | * deal one deal's dates and parties; a adds a date, p a party, A corrects the address, C sets the closing time and place, M amends the contract, V moves the closing and shifts the dates that hang on it (previewed first), S drafts a status update for the party under the cursor, D removes it, F marks it fell through (or brings it back), Y changes the side (buyer, listing or both) |
| 16 | 16 | * |
| 17 | 17 | * On a date (dates or deal view): x marks it done, W waives it (the buyer gave it up), u reopens it or un-waives it, m moves it |
| 18 | * (a new date, then the reason), N moves one flagged "not a business day" to the next business day, l sets how many days before it reminds, @ sets an inspection's or walkthrough's time and place, i lists its papers and adds, ticks in or out, removes or suggests one (x asks once when papers are open). o or enter opens its deal; n opens a new | |
| 18 | * (a new date, then the reason), U puts it back after your own move, N moves one flagged "not a business day" to the next business day, l sets how many days before it reminds, @ sets an inspection's or walkthrough's time and place, i lists its papers and adds, ticks in or out, removes or suggests one (x asks once when papers are open). o or enter opens its deal; n opens a new | |
| 19 | 19 | * deal (address, buyer, seller, contract and closing dates, side); d, r and b move |
| 20 | 20 | * between views; esc cancels a prompt; q leaves. / finds a deal by address or |
| 21 | 21 | * party and narrows dates, week and reminders to it; / then enter clears it. E writes every deal you may read to one JSON file |
| @@ -54,6 +54,8 @@ import { | ||
| 54 | 54 | amendDeal, |
| 55 | 55 | line, |
| 56 | 56 | moveDeadline, |
| 57 | undoMove, | |
| 58 | undoableMove, | |
| 57 | 59 | withConflicts, |
| 58 | 60 | removeDeadline, |
| 59 | 61 | setNote, |
| @@ -250,7 +252,7 @@ export function terminal( | ||
| 250 | 252 | |
| 251 | 253 | function onDate(key: string): boolean { |
| 252 | 254 | const row = dateRows()[cursor]; |
| 253 | if (!["x", "u", "W", "m", "N", "l", "T", "@", "i", "o", "\r"].includes(key)) return false; | |
| 255 | if (!["x", "u", "W", "m", "N", "U", "l", "T", "@", "i", "o", "\r"].includes(key)) return false; | |
| 254 | 256 | if (row === undefined) { |
| 255 | 257 | said = "No date here."; |
| 256 | 258 | return true; |
| @@ -275,6 +277,12 @@ export function terminal( | ||
| 275 | 277 | ask(deal, [`New date for ${date.label} (YYYY-MM-DD, now ${date.due})`, "Why it moved"], ([due, reason]) => |
| 276 | 278 | report(moveDeadline(db, me, { id: date.id, due, reason }), () => withConflicts(db, me, deal.id, `Moved ${date.label} to ${isDate(due) ? spoken(due!) : due}; nothing else moved.`)), |
| 277 | 279 | ); |
| 280 | if (key === "U") { | |
| 281 | const back = undoableMove(date, me.role === "anon" ? "" : me.name); | |
| 282 | if (!canChange(me, deal)) said = me.role === "anon" ? "Sign in to change a deal." : "You may not change this deal."; | |
| 283 | else if (back === undefined) said = `No move of yours to undo on ${date.label}.`; | |
| 284 | else said = report(undoMove(db, me, { id: date.id }), () => `Put ${date.label} back to ${spoken(back.from_due)}.`); | |
| 285 | } | |
| 278 | 286 | if (key === "N") { |
| 279 | 287 | if (date.nonBusinessDay === null) said = `${date.label} is already on a business day.`; |
| 280 | 288 | else if (!canChange(me, deal)) said = me.role === "anon" ? "Sign in to change a deal." : "You may not change this deal."; |
| @@ -1058,8 +1066,8 @@ export function terminal( | ||
| 1058 | 1066 | : view === "timeline" || view === "file" |
| 1059 | 1067 | ? "b back to the deal · d dates · q quit" |
| 1060 | 1068 | : view === "deal" |
| 1061 | ? "j/k move · t client timeline · f broker file · s messages · x done · W waive · u reopen/un-waive · m move · N next business day · l remind lead · T note · @ time and place (inspection, walkthrough) · a add date · p add party · e edit party · A edit address · C closing time and place · M amend the contract · V closing moved · S status update to the party · X remove date/party · D remove deal · b back · q quit" | |
| 1062 | : `j/k move · x done · W waive · u reopen · m move · N next business day · l lead · T note · @ time and place · o open deal · n new deal · c check dates · / find · R restore samples · O bring deals back · h ${showDone ? "hide" : "show"} done · w this week · r reminders · P people · I profile · E export · L activity · q quit`; | |
| 1069 | ? "j/k move · t client timeline · f broker file · s messages · x done · W waive · u reopen/un-waive · m move · U undo move · N next business day · l remind lead · T note · @ time and place (inspection, walkthrough) · a add date · p add party · e edit party · A edit address · C closing time and place · M amend the contract · V closing moved · S status update to the party · X remove date/party · D remove deal · b back · q quit" | |
| 1070 | : `j/k move · x done · W waive · u reopen · m move · U undo move · N next business day · l lead · T note · @ time and place · o open deal · n new deal · c check dates · / find · R restore samples · O bring deals back · h ${showDone ? "hide" : "show"} done · w this week · r reminders · P people · I profile · E export · L activity · q quit`; | |
| 1063 | 1071 | lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys)); |
| 1064 | 1072 | } |
| 1065 | 1073 | return lines; |
src/unmove.test.ts added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | /** "Undo move": one POST puts a date back, once; it is audited, same-origin and refused when it no longer applies. */ | |
| 2 | import { afterAll, beforeEach, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { drafts } from "./reminders.ts"; | |
| 7 | import { serve } from "./server.ts"; | |
| 8 | import { forgetRationing } from "./security.ts"; | |
| 9 | import type { DealView } from "./deals.ts"; | |
| 10 | import { sign } from "./who.ts"; | |
| 11 | ||
| 12 | const KEY = "a-key-only-the-forge-holds"; | |
| 13 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-unmove-")), identityKey: KEY }); | |
| 14 | const base = `http://${server.hostname}:${server.port}`; | |
| 15 | afterAll(() => server.stop(true)); | |
| 16 | beforeEach(forgetRationing); | |
| 17 | const mint = (id: string, name: string, role: string) => { | |
| 18 | const identity = `${id}|${name}|${role}|${Date.now() + 600_000}`; | |
| 19 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 20 | }; | |
| 21 | const me = mint("u11", "nia", "user"); | |
| 22 | const admin = mint("u12", "root", "admin"); | |
| 23 | ||
| 24 | const open = async (): Promise<DealView> => { | |
| 25 | const made = await fetch(`${base}/api/deals`, { | |
| 26 | method: "POST", | |
| 27 | headers: { ...me, "content-type": "application/json" }, | |
| 28 | body: JSON.stringify({ address: "3 Undo St", open_anyway: true, buyer: "A", seller: "B", contract_date: "2026-10-01", closing_date: "2026-12-01" }), | |
| 29 | }); | |
| 30 | return (await made.json()) as DealView; | |
| 31 | }; | |
| 32 | const read = async (id: number): Promise<DealView> => (await (await fetch(`${base}/api/deals/${id}`, { headers: me })).json()) as DealView; | |
| 33 | const form = (path: string, fields: Record<string, string>, headers: Record<string, string> = me) => { | |
| 34 | const body = new FormData(); | |
| 35 | for (const [k, v] of Object.entries(fields)) body.set(k, v); | |
| 36 | return fetch(`${base}${path}`, { method: "POST", body, headers, redirect: "manual" }); | |
| 37 | }; | |
| 38 | const move = (id: number, due: string, headers = me) => form(`/deadlines/${id}/move`, { due, reason: "Client asked", to: "/" }, headers); | |
| 39 | const undo = (id: number, headers = me, extra: Record<string, string> = {}) => form(`/deadlines/${id}/unmove`, { to: "/", ...extra }, headers); | |
| 40 | const page = async (id: number) => (await fetch(`${base}/deals/${id}`, { headers: me })).text(); | |
| 41 | const find = (d: DealView) => d.deadlines.find((x) => x.kind === "inspection")!; | |
| 42 | ||
| 43 | test("after a move the page offers Undo move naming the old date; one POST restores the date and its drafts, then offers nothing", async () => { | |
| 44 | const deal = await open(); | |
| 45 | const date = find(deal); | |
| 46 | const before = drafts(deal, date, "2026-10-02").map((r) => [r.moment, r.on, r.subject]); | |
| 47 | expect(await page(deal.id)).not.toContain("Undo move"); | |
| 48 | const to = date.due === "2026-11-20" ? "2026-11-21" : "2026-11-20"; | |
| 49 | expect((await move(date.id, to)).status).toBe(303); | |
| 50 | const moved = await read(deal.id); | |
| 51 | expect(find(moved).due).toBe(to); | |
| 52 | const html = await page(deal.id); | |
| 53 | expect(html).toContain("Undo move"); | |
| 54 | expect(html).toContain(`/deadlines/${date.id}/unmove`); | |
| 55 | ||
| 56 | expect((await undo(date.id)).status).toBe(303); | |
| 57 | const back = await read(deal.id); | |
| 58 | expect(find(back).due).toBe(date.due); | |
| 59 | expect(drafts(back, find(back), "2026-10-02").map((r) => [r.moment, r.on, r.subject])).toEqual(before); | |
| 60 | expect(await page(deal.id)).not.toContain("Undo move"); | |
| 61 | // Single level: a second undo has nothing to put back. | |
| 62 | await undo(date.id); | |
| 63 | expect(find(await read(deal.id)).due).toBe(date.due); | |
| 64 | }); | |
| 65 | ||
| 66 | test("the undo is audited and shows on Activity", async () => { | |
| 67 | const deal = await open(); | |
| 68 | const date = find(deal); | |
| 69 | await move(date.id, date.due === "2026-11-20" ? "2026-11-21" : "2026-11-20"); | |
| 70 | await undo(date.id); | |
| 71 | const audit = (await (await fetch(`${base}/api/audit`, { headers: admin })).json()) as { rows?: { what?: string; outcome?: string }[] } | { what?: string; outcome?: string }[]; | |
| 72 | const rows = Array.isArray(audit) ? audit : (audit.rows ?? []); | |
| 73 | expect(rows.some((r) => r.what === "deadline.unmove")).toBe(true); | |
| 74 | const activity = await (await fetch(`${base}/activity`, { headers: admin })).text(); | |
| 75 | expect(activity).toContain("put a moved date back"); | |
| 76 | }); | |
| 77 | ||
| 78 | const unmoves = async (): Promise<number> => { | |
| 79 | const audit = (await (await fetch(`${base}/api/audit`, { headers: admin })).json()) as { rows?: { what?: string; outcome?: string }[] } | { what?: string; outcome?: string }[]; | |
| 80 | return (Array.isArray(audit) ? audit : (audit.rows ?? [])).filter((r) => r.what === "deadline.unmove" && r.outcome === "ok").length; | |
| 81 | }; | |
| 82 | ||
| 83 | test("undo is refused after the date is done", async () => { | |
| 84 | const deal = await open(); | |
| 85 | const date = find(deal); | |
| 86 | const first = date.due === "2026-11-20" ? "2026-11-21" : "2026-11-20"; | |
| 87 | await move(date.id, first); | |
| 88 | await form(`/deadlines/${date.id}/done`, { done: "1", to: "/" }); | |
| 89 | await undo(date.id); | |
| 90 | expect(find(await read(deal.id)).due).toBe(first); | |
| 91 | expect(await page(deal.id)).not.toContain(`/deadlines/${date.id}/unmove`); | |
| 92 | }); | |
| 93 | ||
| 94 | test("the offer is the mover's: moved again by someone else it disappears for the first mover; their own re-move is theirs to undo", async () => { | |
| 95 | const deal = await open(); | |
| 96 | const date = find(deal); | |
| 97 | const first = date.due === "2026-11-20" ? "2026-11-21" : "2026-11-20"; | |
| 98 | const eve = mint("u13", "eve", "user"); | |
| 99 | await move(date.id, first); | |
| 100 | await move(date.id, "2026-11-25", eve); | |
| 101 | const seenByNia = await page(deal.id); | |
| 102 | expect(seenByNia).not.toContain(`/deadlines/${date.id}/unmove`); | |
| 103 | const seenByEve = await (await fetch(`${base}/deals/${deal.id}`, { headers: eve })).text(); | |
| 104 | expect(seenByEve).toContain(`/deadlines/${date.id}/unmove`); | |
| 105 | // Nia cannot undo Eve's move. | |
| 106 | const before = await unmoves(); | |
| 107 | await undo(date.id); | |
| 108 | expect(find(await read(deal.id)).due).toBe("2026-11-25"); | |
| 109 | expect(await unmoves()).toBe(before); | |
| 110 | // Eve can, once. | |
| 111 | await undo(date.id, eve); | |
| 112 | expect(find(await read(deal.id)).due).toBe(first); | |
| 113 | expect(await unmoves()).toBe(before + 1); | |
| 114 | // The same person moving again makes the newer move theirs. | |
| 115 | await move(date.id, "2026-11-26", eve); | |
| 116 | await undo(date.id, eve); | |
| 117 | expect(find(await read(deal.id)).due).toBe(first); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a cross-origin POST and a request with no identity change nothing and leave no deadline.unmove row", async () => { | |
| 121 | const deal = await open(); | |
| 122 | const date = find(deal); | |
| 123 | const to = date.due === "2026-11-20" ? "2026-11-21" : "2026-11-20"; | |
| 124 | await move(date.id, to); | |
| 125 | const before = await unmoves(); | |
| 126 | expect((await undo(date.id, { ...me, origin: "https://evil.example" })).status).toBe(403); | |
| 127 | await undo(date.id, {}); | |
| 128 | await form("/deadlines/abc/unmove", { to: "/" }); | |
| 129 | expect(find(await read(deal.id)).due).toBe(to); | |
| 130 | expect(await unmoves()).toBe(before); | |
| 131 | }); |
new above
offline