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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/deal-clock:mason/303-the-agent-can-undo-the-last-move-of-a-da → main 2 commits fork

  • Merged. mason/303-the-agent-can-undo-the-last-move-of-a-da was merged into main as ad5b26c.

Review

Sign in to review this change.
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
165165 that event the time (in your Profile time zone) and location, and the
166166 reminder says "10:00 am at 12 Elm St". In the terminal press `@` on the date;
167167 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
169169 addendum that moves several dates, **Amend the contract** takes one reason and
170170 sends each party one notice. When buyer and seller agree to close on another
171171 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
9090 table) carries `deadlines[].nonBusinessDay: { next }` in the API and in MCP
9191 `get_deal` (`null` otherwise); the sheet's and deal page's *Move to* button posts
9292 `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.
9394 `deadline_move.notify` is 1 only for moves made after the moment existed.
9495 `send_reminder` and the decline and undo doors take `moment: "moved"`.
9596
src/activity.ts +1
@@ -20,6 +20,7 @@ const WORDS: Readonly<Record<string, string>> = {
2020 "reminder.release": "brought back a held reminder",
2121 "reminder.undo": "undid a declined reminder",
2222 "date.move": "moved a date",
23 "deadline.unmove": "put a moved date back",
2324 "date.done": "marked a date done",
2425 "date.waive": "waived a date",
2526 "date.owes": "changed who owes a date",
src/db.ts +2
@@ -584,6 +584,8 @@ export const migrations: string[] = [
584584 in_at TEXT
585585 );
586586 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`,
587589 ];
588590
589591 export function openDb(dir = dataDir): Database {
src/deals.ts +36
@@ -111,6 +111,8 @@ export interface Move {
111111 readonly notify: 0 | 1;
112112 /** The addendum that made this move together with others; 0 for a move made on its own. */
113113 readonly amendment: number;
114 /** 1 when this move put an earlier one back (the undo); 0 for every other. */
115 readonly undo: 0 | 1;
114116 }
115117 /** A reminder held until a later morning; keyed like a sent or declined one. */
116118 export interface Held {
@@ -1089,6 +1091,40 @@ export function moveDeadline(
10891091 return within(db, who, "date.move", dealId, ok(reread(db, deadline.id)));
10901092 }
10911093
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
10921128 /** One amendment, checked: the moves it makes, or the field that was wrong and why. */
10931129 export type Amendment =
10941130 | { 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 @@
6060 "4a52d9de76ede6d34d831d9bc6fc1d3d3a501a5d3652bf63695a9de49f2a7d39",
6161 "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3",
6262 "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5",
63 "c82fe9b3aea33d99a2c65ffb4179901d5aecde6b2ef759531b94ff12035a719a"
63 "c82fe9b3aea33d99a2c65ffb4179901d5aecde6b2ef759531b94ff12035a719a",
64 "d7c7a79baf6dcd397af5c223839a821db0f0b744f6f02358ea4ba38532ae148b"
6465 ]
src/migrations.test.ts +1 −1
@@ -13,7 +13,7 @@ test("every migration is pinned by position; a pull may only append", () => {
1313 });
1414
1515 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));
1717 const db = new Database(":memory:");
1818 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))");
1919 before.forEach((sql, i) => {
src/pages/deal.ts +2 −2
@@ -25,7 +25,7 @@ import { dealSideLabel, sideLabel, sideSelect } from "./side.ts";
2525 import { papersLine } from "../papers.ts";
2626 import { progressLine } from "./progress.ts";
2727 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";
2929
3030 const roleOptions = (selected?: string, roles: readonly (typeof PARTY_ROLES)[number][] = PARTY_ROLES): string =>
3131 roles.map(
@@ -306,7 +306,7 @@ export function dealPage(input: {
306306 <p class="small">${owedBy(deal, d)}</p>
307307 ${owesChangedLine(d)}
308308 ${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) : ""}
310310 </div>
311311 <button class="button button-quiet" popovertarget="${sheetId(d)}">Open</button>
312312 </li>`,
src/pages/sheet.ts +11 −1
@@ -5,7 +5,7 @@
55 * script; the forms post and come back.
66 */
77
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";
99 import { doneLine, relative, short, spoken, urgencyOf, type Urgency } from "../dates.ts";
1010 import { CONTACT_ROLES, isInternal, isOpen, isWaivable, LEAD_MAX, LEAD_MIN, PARTY_LABEL } from "../deadlines.ts";
1111 import { escape } from "../http.ts";
@@ -69,6 +69,16 @@ export const rollForm = (d: DeadlineView, to: string): string =>
6969 <button class="button button-quiet">Move to ${escape(spoken(d.nonBusinessDay.next))}</button>
7070 </form>`;
7171
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
7282 export const badge = (d: DeadlineView, on: string): string => {
7383 const u = urgencyOf(d, on);
7484 return `<span class="badge" data-u="${u}">${URGENCY_LABEL[u]}</span>`;
src/routes.ts +11
@@ -69,6 +69,7 @@ import {
6969 amendDeal,
7070 amendment,
7171 moveDeadline,
72 undoMove,
7273 nameOf,
7374 removeDeadline,
7475 removeParty,
@@ -1332,6 +1333,16 @@ export const routes: readonly Route[] = [
13321333 return done(moved, backTo(f.to), moved.ok ? withConflicts(c.db, c.who, moved.value.deal_id, "Date moved; nothing else did.") : undefined);
13331334 },
13341335 },
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 },
13351346 {
13361347 method: "POST",
13371348 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
857857 expect(lines(t).join("\n")).toContain("already on a business day");
858858 });
859859
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
860882 test("T sets and clears a date's note, shown beside the date, through setNote", () => {
861883 const t = terminal(db, alice, context);
862884 goTo(t, "Closing");
src/terminal.ts +12 −4
@@ -15,7 +15,7 @@
1515 * 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)
1616 *
1717 * 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
1919 * deal (address, buyer, seller, contract and closing dates, side); d, r and b move
2020 * between views; esc cancels a prompt; q leaves. / finds a deal by address or
2121 * 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 {
5454 amendDeal,
5555 line,
5656 moveDeadline,
57 undoMove,
58 undoableMove,
5759 withConflicts,
5860 removeDeadline,
5961 setNote,
@@ -250,7 +252,7 @@ export function terminal(
250252
251253 function onDate(key: string): boolean {
252254 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;
254256 if (row === undefined) {
255257 said = "No date here.";
256258 return true;
@@ -275,6 +277,12 @@ export function terminal(
275277 ask(deal, [`New date for ${date.label} (YYYY-MM-DD, now ${date.due})`, "Why it moved"], ([due, reason]) =>
276278 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.`)),
277279 );
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 }
278286 if (key === "N") {
279287 if (date.nonBusinessDay === null) said = `${date.label} is already on a business day.`;
280288 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(
10581066 : view === "timeline" || view === "file"
10591067 ? "b back to the deal · d dates · q quit"
10601068 : 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`;
10631071 lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys));
10641072 }
10651073 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs