Public: anyone can read this; the crew can change it.

The agent can clear the sample deals in one confirmed step and start with their own book #288

Merged opened this

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

Merged

mason/deal-clock:mason/283-the-agent-can-clear-the-sample-deals-in → main 7 commits fork 1 thread · 1 unresolved

  • Merged. mason/283-the-agent-can-clear-the-sample-deals-in was merged into main as 56cdf8d.

Review

Sign in to review this change.
7 files changed +241 −26
README.md +9
@@ -298,3 +298,12 @@ and to open deals, mark dates done, move them, add dates and parties, and send
298298 reminders. A visitor who is not signed in sees nothing of any deal. Only the
299299 agent who opened a deal, or an admin, can remove it, so the sample deals can be
300300 removed by an admin only.
301
302 ## Starting with your own deals
303
304 After the sales meeting, open **Profile**, choose **Clear sample deals…** and
305 confirm. Every sample deal you have not changed goes, with its dates, contacts
306 and messages; a sample you edited and every deal you opened stay. The clock then
307 says how to add your first deal, and the samples are not written again when the
308 app restarts. (An admin can still bring them back with **Restore the sample
309 deals** on the clock.) Only an admin can clear them.
src/actions.ts +26 −1
@@ -11,7 +11,7 @@ import { HOLD_WHAT, MESSAGE_WHATS, type AuditRow } from "./audit.ts";
1111 import { permit } from "./contract.ts";
1212 import { emit } from "./live.ts";
1313 import { audited, fail, ok, refuse, type Result } from "./result.ts";
14 import { restore, SAMPLE } from "./sample.ts";
14 import { clearSamples, restore, SAMPLE } from "./sample.ts";
1515 import type { Who } from "./who.ts";
1616
1717 export type { Failure, Result } from "./result.ts";
@@ -107,3 +107,28 @@ export function restoreSamples(db: Database, who: Who): Result<{ id: number; ids
107107 }
108108 return audited(db, who, "deal", "delete", out.ok ? out.value.id : undefined, out);
109109 }
110
111 /**
112 * Whether `who` may clear the samples: the table's `deal` `delete` cell on a
113 * row the sample identity authored, the cell restoring asks and nothing new.
114 */
115 export const mayClear = (who: Who): Result<null> =>
116 permit(who, "deal", "delete", { author_id: SAMPLE.id }) === "yes"
117 ? ok(null)
118 : refuse(who, "Sign in to clear the sample deals.", "Only an admin may clear the sample deals.");
119
120 /**
121 * Remove the sample deals nobody has touched, so the agent starts with their
122 * own book. Deals the agent opened or edited stay. Audited as a delete.
123 */
124 export function clearSampleDeals(db: Database, who: Who): Result<{ ids: number[] }> {
125 const may = mayClear(who);
126 let out: Result<{ ids: number[] }>;
127 if (!may.ok) out = may;
128 else {
129 const ids = clearSamples(db);
130 emit("deal.changed", { id: ids[0] });
131 out = ok({ ids });
132 }
133 return audited(db, who, "deal", "delete", out.ok ? out.value.ids[0] : undefined, out);
134 }
src/clear.test.ts added +100
@@ -0,0 +1,100 @@
1 import { afterAll, expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { clearSampleDeals, restoreSamples } from "./actions.ts";
6 import { addDays, today, DEFAULT_ZONE } from "./dates.ts";
7 import { openDb } from "./db.ts";
8 import { addParty, createDeal, listDeals, updateDeal, type DealView } from "./deals.ts";
9 import { forgetRationing } from "./security.ts";
10 import { serve } from "./server.ts";
11 import { sign, type Who } from "./who.ts";
12
13 const dir = mkdtempSync(join(tmpdir(), "app-clear-"));
14 const db = openDb(dir);
15 const root: Who = { role: "admin", id: "u0", name: "root" };
16 const alice: Who = { role: "user", id: "u1", name: "alice" };
17 const on = today(DEFAULT_ZONE);
18 const all = (): DealView[] => {
19 const got = listDeals(db, root);
20 if (!got.ok) throw new Error(got.message);
21 return got.value;
22 };
23 const samples = () => all().filter((d) => d.author_id === "sample");
24 const count = (table: string) => db.query<{ n: number }, []>(`SELECT count(*) AS n FROM ${table}`).get()!.n;
25
26 const mine = createDeal(db, alice, { openAnyway: true, address: "12 Elm St", buyer: "Ann Buyer", seller: "Sam Seller", contractDate: on, closingDate: addDays(on, 45) });
27 if (!mine.ok) throw new Error(mine.message);
28
29 test("clearing removes only untouched samples; an edited or extended sample and the agent's deal stay", () => {
30 expect(samples().length).toBe(8);
31 const juniper = samples().find((d) => d.address.includes("Juniper"))!;
32 addParty(db, alice, { dealId: juniper.id, role: "title", name: "Demo Title", contact: "" });
33 const alder = samples().find((d) => d.address.includes("Alder"))!;
34 const renamed = updateDeal(db, alice, { id: alder.id, address: "1418 Alder Lane Unit 2" } as never);
35 expect(renamed.ok).toBe(true);
36 const cleared = clearSampleDeals(db, root);
37 expect(cleared.ok && cleared.value.ids.length).toBe(6);
38 expect(samples().map((d) => d.id).sort()).toEqual([alder.id, juniper.id].sort());
39 expect(all().some((d) => d.id === mine.value.id)).toBe(true);
40 // Their reminders and parties went with them.
41 expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder WHERE deal_id NOT IN (SELECT id FROM deal)").get()!.n).toBe(0);
42 expect(count("reminder")).toBe(1); // Alder Lane's sent message stays with Alder Lane
43 expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM party WHERE deal_id NOT IN (SELECT id FROM deal)").get()!.n).toBe(0);
44 });
45
46 test("a user may not clear, and nothing changes", () => {
47 const before = all().length;
48 const r = clearSampleDeals(db, alice);
49 expect(r.ok).toBe(false);
50 expect(all().length).toBe(before);
51 });
52
53 test("a boot after clearing does not write the samples again", () => {
54 const before = samples().length;
55 expect(restoreSamples(db, root).ok).toBe(true);
56 expect(samples().length).toBe(8);
57 expect(clearSampleDeals(db, root).ok).toBe(true);
58 expect(samples().length).toBe(0);
59 const emptied = samples().length;
60 db.query("DELETE FROM app_meta WHERE key = 'seeded'").run();
61 db.close();
62 const again = openDb(dir);
63 const n = again.query<{ n: number }, []>("SELECT count(*) AS n FROM deal WHERE author_id = 'sample'").get()!.n;
64 again.close();
65 expect(n).toBe(emptied);
66 expect(before).toBeGreaterThan(0);
67 });
68
69 let stop = (): void => {};
70 afterAll(() => stop());
71
72 test("the page: Profile offers the button, a post without confirm removes nothing, and the empty clock says how to add a deal", async () => {
73 forgetRationing();
74 const KEY = "a-key-only-the-forge-holds";
75 const d2 = mkdtempSync(join(tmpdir(), "app-clear2-"));
76 const server = serve({ port: 0, dataDir: d2, identityKey: KEY });
77 stop = () => server.stop(true);
78 const base = `http://${server.hostname}:${server.port}`;
79 const identity = `u0|root|admin|${Date.now() + 60_000}`;
80 const h = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
81 const post = (fields: Record<string, string>, site = "same-origin") =>
82 fetch(`${base}/samples/clear`, { method: "POST", redirect: "manual", headers: { "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": site, ...h }, body: new URLSearchParams(fields) });
83 const rita = `u-rita|rita|user|${Date.now() + 60_000}`;
84 const notice = (r: Response) => decodeURIComponent(r.headers.get("set-cookie") ?? "");
85 const asRita = { "x-forge-identity": rita, "x-forge-signature": sign(rita, KEY) };
86 const bare = { method: "POST", redirect: "manual" as const, headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ confirm: "yes" }) };
87 expect(notice(await fetch(`${base}/samples/clear`, bare))).toContain("Sign in to clear");
88 expect(notice(await fetch(`${base}/samples/clear`, { ...bare, headers: { ...bare.headers, ...asRita } }))).toContain("Only an admin");
89 expect(await (await fetch(`${base}/`, { headers: h })).text()).toContain("Alder Lane");
90 expect(await (await fetch(`${base}/profile`, { headers: h })).text()).toContain('action="/samples/clear"');
91 expect((await post({})).status).toBe(422);
92 expect((await post({ confirm: "yes" }, "cross-site")).status).toBe(403);
93 expect(await (await fetch(`${base}/`, { headers: h })).text()).toContain("Alder Lane");
94 const r = await post({ confirm: "yes" });
95 expect(r.status).toBe(303);
96 const clock = await (await fetch(`${base}/`, { headers: h })).text();
97 expect(clock).not.toContain("Alder Lane");
98 expect(clock).toContain("To add your first deal");
99 expect(await (await fetch(`${base}/tour`, { headers: h })).status).toBeLessThan(500);
100 });
src/pages/clock.ts +1 −1
@@ -210,7 +210,7 @@ function render(input: {
210210 const nothing =
211211 q !== "" || side !== ""
212212 ? `<p class="empty">No deal matches${q === "" ? " that side" : ` “${escape(q)}”`}. <a href="/">Clear</a></p>`
213 : `<p class="empty">No deals yet. <a href="/deals/new">Open the first one</a>.</p>`;
213 : `<p class="empty">No deals yet. To add your first deal, choose <b>New deal</b> and enter the address, the clients and the contract and closing dates; its clock starts at once. <a href="/deals/new">Open the first one</a>.</p>`;
214214 const timeline =
215215 deals.length === 0 && closed.length === 0 && fell.length === 0
216216 ? nothing
src/pages/profile.ts +9
@@ -10,6 +10,7 @@ import type { Profile } from "../profile.ts";
1010 import type { Agent } from "../reminders.ts";
1111 import { VOICES, type Voice } from "../wording.ts";
1212 import type { Who } from "../who.ts";
13 import { confirmRemove } from "./confirm.ts";
1314 import { layout } from "./layout.ts";
1415
1516 export interface ProfileState {
@@ -26,6 +27,8 @@ export function profilePage(input: {
2627 profile: Profile;
2728 /** Whether the table lets `who` save it. */
2829 mayChange: boolean;
30 /** Whether the table lets `who` clear the sample deals. */
31 mayClear?: boolean | undefined;
2932 notice?: string | undefined;
3033 /** The first sample deal's first reminder in each voice, for the choice's sample; none without a sample deal. */
3134 samples?: Readonly<Record<Voice, string>> | undefined;
@@ -84,6 +87,11 @@ export function profilePage(input: {
8487 <label>Your deal-clock file<input class="input" type="file" name="file" accept="application/json,.json" required></label>
8588 <div class="actions"><button class="button">Bring deals back</button></div>
8689 </form>`;
90 const clearing = input.mayClear !== true ? "" : `<section class="stack" id="clear-samples">
91 <h2>Start with your own deals</h2>
92 <p class="muted small">Remove the sample deals you have not changed, with their dates, contacts and messages. Deals you opened or edited are kept.</p>
93 ${confirmRemove({ summary: "Clear sample deals…", goes: "The untouched sample deals and their reminders and messages are removed. This cannot be undone here.", action: "/samples/clear", name: "Yes, clear the sample deals", yes: "Yes, clear them" })}
94 </section>`;
8795 const body = `<p class="crumb"><a href="/">← The clock</a></p>
8896 <section class="card narrow">
8997 <h1>Profile</h1>
@@ -92,6 +100,7 @@ export function profilePage(input: {
92100 ${samples === undefined ? "" : `<h2>How each voice reads</h2>${sample("warm")}${sample("brief")}`}
93101 <p class="small"><a href="/deals/export.json" download>Download all my deals</a> <span class="muted">One file with every deal you can read: dates, parties, notes, moves and the words of every message sent.</span></p>
94102 ${bringBack}
103 ${clearing}
95104 </section>`;
96105 return layout({ body, who, agent, path: "/profile", heading: "Profile", notice, waiting: input.waiting, tour: input.tour, live: false });
97106 }
src/routes.ts +20 −1
@@ -17,6 +17,8 @@ import {
1717 getAudit,
1818 listAudit,
1919 restoreSamples,
20 clearSampleDeals,
21 mayClear,
2022 type Failure,
2123 type Result,
2224 } from "./actions.ts";
@@ -618,6 +620,12 @@ async function form(c: Context): Promise<Record<string, string> | Response> {
618620 return out;
619621 }
620622
623 /** What the clock says after the samples were cleared. */
624 function clearedNotice(removed: number): string {
625 if (removed === 0) return "No untouched sample deals were left.";
626 return `Cleared ${removed} sample deal${removed === 1 ? "" : "s"}. Add your first deal when you are ready.`;
627 }
628
621629 /** A removal posted without `confirm=yes`: refused like any invalid form, and nothing goes. */
622630 const unconfirmed = (c: Context, path: string): Response =>
623631 html(
@@ -1080,6 +1088,17 @@ export const routes: readonly Route[] = [
10801088 path: "/samples/restore",
10811089 handle: (c) => done(restoreSamples(c.db, c.who), "/", "Sample deals restored."),
10821090 },
1091 {
1092 method: "POST",
1093 path: "/samples/clear",
1094 handle: async (c) => {
1095 const f = await form(c);
1096 if (f instanceof Response) return f;
1097 if (f.confirm !== "yes") return unconfirmed(c, "/profile");
1098 const cleared = clearSampleDeals(c.db, c.who);
1099 return done(cleared, "/", cleared.ok ? clearedNotice(cleared.value.ids.length) : undefined);
1100 },
1101 },
10831102 {
10841103 method: "GET",
10851104 path: "/profile",
@@ -1092,7 +1111,7 @@ export const routes: readonly Route[] = [
10921111 STATUS[got.error],
10931112 clear,
10941113 );
1095 return html(profilePage({ who: c.who, ...headerFor(c), profile: got.value, mayChange: permit(c.who, "profile", "update") === "yes", notice, samples: voiceSamples(c.db, c.who, got.value) }), 200, clear);
1114 return html(profilePage({ who: c.who, ...headerFor(c), profile: got.value, mayChange: permit(c.who, "profile", "update") === "yes", mayClear: mayClear(c.who).ok, notice, samples: voiceSamples(c.db, c.who, got.value) }), 200, clear);
10961115 },
10971116 },
10981117 {
src/sample.ts +76 −23
@@ -321,6 +321,33 @@ export function isUntouched(state: SampleState, anchor: string): boolean {
321321 );
322322 }
323323
324 /** The dates, moves and what a person could have changed in one sample deal, read as `isUntouched` asks for them. */
325 function stateOf(db: Database, dealId: number, sample: string | null) {
326 const deadlines = db.query<{ id: number; kind: string; due: string; done_at: string | null; waived_at: string | null }, [number]>(
327 "SELECT id, kind, due, done_at, waived_at FROM deadline WHERE deal_id = ?1",
328 ).all(dealId);
329 const moves = db.query<{ id: number; from_due: string; to_due: string; by_name: string }, [number]>(
330 "SELECT m.id, m.from_due, m.to_due, m.by_name FROM deadline_move m JOIN deadline d ON d.id = m.deadline_id WHERE d.deal_id = ?1",
331 ).all(dealId);
332 const parties = db.query<{ n: number }, [number]>(
333 "SELECT count(*) AS n FROM party WHERE deal_id = ?1 AND role NOT IN ('buyer', 'seller')",
334 ).get(dealId)!.n;
335 const reminders = db.query<{ n: number }, [number]>(
336 "SELECT count(*) AS n FROM reminder WHERE deal_id = ?1 AND sample = 0",
337 ).get(dealId)!.n;
338 const state: SampleState = {
339 sample,
340 parties,
341 customs: deadlines.filter((d) => d.kind === "custom").length,
342 reminders,
343 doneAts: deadlines.map((d) => d.done_at),
344 dues: deadlines.map((d) => d.due),
345 waivedAts: deadlines.map((d) => d.waived_at),
346 moveAuthors: moves.map((m) => m.by_name),
347 };
348 return { deadlines, moves, state };
349 }
350
324351 /**
325352 * Bring the sample deals nobody has touched up to `on`, then remember `on` as
326353 * the day they are anchored to. A deal a person changed is left where it is.
@@ -336,28 +363,7 @@ export function refresh(db: Database, on = today(zoneOf(db))): void {
336363 "SELECT d.id, s.sample, d.contract_date FROM deal d LEFT JOIN sample_deal s ON s.deal_id = d.id WHERE d.author_id = ?1",
337364 ).all(SAMPLE.id);
338365 for (const deal of deals) {
339 const deadlines = db.query<{ id: number; kind: string; due: string; done_at: string | null; waived_at: string | null }, [number]>(
340 "SELECT id, kind, due, done_at, waived_at FROM deadline WHERE deal_id = ?1",
341 ).all(deal.id);
342 const moves = db.query<{ id: number; from_due: string; to_due: string; by_name: string }, [number]>(
343 "SELECT m.id, m.from_due, m.to_due, m.by_name FROM deadline_move m JOIN deadline d ON d.id = m.deadline_id WHERE d.deal_id = ?1",
344 ).all(deal.id);
345 const parties = db.query<{ n: number }, [number]>(
346 "SELECT count(*) AS n FROM party WHERE deal_id = ?1 AND role NOT IN ('buyer', 'seller')",
347 ).get(deal.id)!.n;
348 const reminders = db.query<{ n: number }, [number]>(
349 "SELECT count(*) AS n FROM reminder WHERE deal_id = ?1 AND sample = 0",
350 ).get(deal.id)!.n;
351 const state: SampleState = {
352 sample: deal.sample,
353 parties,
354 customs: deadlines.filter((d) => d.kind === "custom").length,
355 reminders,
356 doneAts: deadlines.map((d) => d.done_at),
357 dues: deadlines.map((d) => d.due),
358 waivedAts: deadlines.map((d) => d.waived_at),
359 moveAuthors: moves.map((m) => m.by_name),
360 };
366 const { deadlines, moves, state } = stateOf(db, deal.id, deal.sample);
361367 if (!isUntouched(state, anchor)) continue;
362368 let shifted = shiftSample(
363369 {
@@ -493,7 +499,7 @@ export function seed(db: Database, on = today(zoneOf(db))): void {
493499 // Asked and written in one write transaction, taken before the question:
494500 // the server and a terminal opening a new file at once seed it once.
495501 db.transaction(() => {
496 if (db.query("SELECT 1 FROM app_meta WHERE key = 'seeded'").get() !== null) return;
502 if (db.query("SELECT 1 FROM app_meta WHERE key IN ('seeded', ?1)").get(CLEARED) !== null) return;
497503 writeSamples(db, on);
498504 }).immediate();
499505 }
@@ -508,7 +514,54 @@ export function seed(db: Database, on = today(zoneOf(db))): void {
508514 export function restore(db: Database, on = today(zoneOf(db))): number[] {
509515 return db.transaction(() => {
510516 db.query("DELETE FROM deal WHERE author_id = ?1").run(SAMPLE.id);
517 db.query("DELETE FROM app_meta WHERE key = ?1").run(CLEARED);
511518 writeSamples(db, on);
512519 return db.query<{ id: number }, [string]>("SELECT id FROM deal WHERE author_id = ?1 ORDER BY id").all(SAMPLE.id).map((r) => r.id);
513520 }).immediate();
514521 }
522
523 /** The app_meta key that is set once the agent has cleared the samples; a boot never writes them again while it stands. */
524 const CLEARED = "samples_cleared";
525
526 /** Whether the agent has cleared the samples (and not asked for them back). */
527 export function samplesCleared(db: Database): boolean {
528 return db.query("SELECT 1 FROM app_meta WHERE key = ?1").get(CLEARED) !== null;
529 }
530
531 /**
532 * Whether a deal is still exactly a sample as `seed` wrote it: written by the
533 * sample identity, listed in `sample_deal`, with the address, price and clients
534 * it was written with and nothing a person added or finished since.
535 */
536 function isStillSample(db: Database, id: number): boolean {
537 const row = db.query<{ address: string; sale_price: number | null; sample: string | null }, [number, string]>(
538 "SELECT d.address, d.sale_price, s.sample FROM deal d LEFT JOIN sample_deal s ON s.deal_id = d.id WHERE d.id = ?1 AND d.author_id = ?2",
539 ).get(id, SAMPLE.id);
540 const written = row === null ? undefined : SAMPLES.find((x) => x.address === row.sample);
541 if (row === null || written === undefined) return false;
542 if (row.address !== written.address || row.sale_price !== written.price) return false;
543 const names = new Map(db.query<{ role: string; name: string }, [number]>("SELECT role, name FROM party WHERE deal_id = ?1 AND role IN ('buyer', 'seller')").all(id).map((p) => [p.role, p.name]));
544 if (names.get("buyer") !== written.buyer || names.get("seller") !== written.seller) return false;
545 const anchor = db.query<{ value: string }, []>("SELECT value FROM app_meta WHERE key = 'sample_anchor'").get()?.value ?? today(zoneOf(db));
546 const { deadlines, state } = stateOf(db, id, row.sample);
547 // The anniversary sample's closing is stamped on its own due day, never before it: as written, it is untouched.
548 const doneAts = state.doneAts.map((d, i) =>
549 written.anniversary !== undefined && deadlines[i]!.kind === "closing" && d !== null && d.endsWith(DONE_TIME) && d.slice(0, 10) <= state.dues[i]! ? `${addDays(state.dues[i]!, -1)}${DONE_TIME}` : d,
550 );
551 return isUntouched({ ...state, doneAts }, anchor);
552 }
553
554 /**
555 * Remove the sample deals nobody has touched, with their dates, parties and
556 * reminders (the schema cascades), and remember that the agent cleared them so
557 * no boot writes them again. A sample the agent edited, and every deal they
558 * opened, stays. Answers the ids removed.
559 */
560 export function clearSamples(db: Database): number[] {
561 return db.transaction(() => {
562 const ids = db.query<{ id: number }, [string]>("SELECT id FROM deal WHERE author_id = ?1 ORDER BY id").all(SAMPLE.id).map((r) => r.id).filter((id) => isStillSample(db, id));
563 for (const id of ids) db.query("DELETE FROM deal WHERE id = ?1 AND author_id = ?2").run(id, SAMPLE.id);
564 db.query("INSERT OR REPLACE INTO app_meta (key, value) VALUES (?1, ?2)").run(CLEARED, today(zoneOf(db)));
565 return ids;
566 }).immediate();
567 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs