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
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/283-the-agent-can-clear-the-sample-deals-in → main 7 commits fork 1 thread · 1 unresolved
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 | ||
| 298 | 298 | reminders. A visitor who is not signed in sees nothing of any deal. Only the |
| 299 | 299 | agent who opened a deal, or an admin, can remove it, so the sample deals can be |
| 300 | 300 | 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"; | ||
| 11 | 11 | import { permit } from "./contract.ts"; |
| 12 | 12 | import { emit } from "./live.ts"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import type { Who } from "./who.ts"; |
| 16 | 16 | |
| 17 | 17 | export type { Failure, Result } from "./result.ts"; |
| @@ -107,3 +107,28 @@ export function restoreSamples(db: Database, who: Who): Result<{ id: number; ids | ||
| 107 | 107 | } |
| 108 | 108 | return audited(db, who, "deal", "delete", out.ok ? out.value.id : undefined, out); |
| 109 | 109 | } |
| 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: { | ||
| 210 | 210 | const nothing = |
| 211 | 211 | q !== "" || side !== "" |
| 212 | 212 | ? `<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>`; | |
| 214 | 214 | const timeline = |
| 215 | 215 | deals.length === 0 && closed.length === 0 && fell.length === 0 |
| 216 | 216 | ? nothing |
src/pages/profile.ts +9
| @@ -10,6 +10,7 @@ import type { Profile } from "../profile.ts"; | ||
| 10 | 10 | import type { Agent } from "../reminders.ts"; |
| 11 | 11 | import { VOICES, type Voice } from "../wording.ts"; |
| 12 | 12 | import type { Who } from "../who.ts"; |
| 13 | import { confirmRemove } from "./confirm.ts"; | |
| 13 | 14 | import { layout } from "./layout.ts"; |
| 14 | 15 | |
| 15 | 16 | export interface ProfileState { |
| @@ -26,6 +27,8 @@ export function profilePage(input: { | ||
| 26 | 27 | profile: Profile; |
| 27 | 28 | /** Whether the table lets `who` save it. */ |
| 28 | 29 | mayChange: boolean; |
| 30 | /** Whether the table lets `who` clear the sample deals. */ | |
| 31 | mayClear?: boolean | undefined; | |
| 29 | 32 | notice?: string | undefined; |
| 30 | 33 | /** The first sample deal's first reminder in each voice, for the choice's sample; none without a sample deal. */ |
| 31 | 34 | samples?: Readonly<Record<Voice, string>> | undefined; |
| @@ -84,6 +87,11 @@ export function profilePage(input: { | ||
| 84 | 87 | <label>Your deal-clock file<input class="input" type="file" name="file" accept="application/json,.json" required></label> |
| 85 | 88 | <div class="actions"><button class="button">Bring deals back</button></div> |
| 86 | 89 | </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>`; | |
| 87 | 95 | const body = `<p class="crumb"><a href="/">← The clock</a></p> |
| 88 | 96 | <section class="card narrow"> |
| 89 | 97 | <h1>Profile</h1> |
| @@ -92,6 +100,7 @@ export function profilePage(input: { | ||
| 92 | 100 | ${samples === undefined ? "" : `<h2>How each voice reads</h2>${sample("warm")}${sample("brief")}`} |
| 93 | 101 | <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> |
| 94 | 102 | ${bringBack} |
| 103 | ${clearing} | |
| 95 | 104 | </section>`; |
| 96 | 105 | return layout({ body, who, agent, path: "/profile", heading: "Profile", notice, waiting: input.waiting, tour: input.tour, live: false }); |
| 97 | 106 | } |
src/routes.ts +20 −1
| @@ -17,6 +17,8 @@ import { | ||
| 17 | 17 | getAudit, |
| 18 | 18 | listAudit, |
| 19 | 19 | restoreSamples, |
| 20 | clearSampleDeals, | |
| 21 | mayClear, | |
| 20 | 22 | type Failure, |
| 21 | 23 | type Result, |
| 22 | 24 | } from "./actions.ts"; |
| @@ -618,6 +620,12 @@ async function form(c: Context): Promise<Record<string, string> | Response> { | ||
| 618 | 620 | return out; |
| 619 | 621 | } |
| 620 | 622 | |
| 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 | ||
| 621 | 629 | /** A removal posted without `confirm=yes`: refused like any invalid form, and nothing goes. */ |
| 622 | 630 | const unconfirmed = (c: Context, path: string): Response => |
| 623 | 631 | html( |
| @@ -1080,6 +1088,17 @@ export const routes: readonly Route[] = [ | ||
| 1080 | 1088 | path: "/samples/restore", |
| 1081 | 1089 | handle: (c) => done(restoreSamples(c.db, c.who), "/", "Sample deals restored."), |
| 1082 | 1090 | }, |
| 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 | }, | |
| 1083 | 1102 | { |
| 1084 | 1103 | method: "GET", |
| 1085 | 1104 | path: "/profile", |
| @@ -1092,7 +1111,7 @@ export const routes: readonly Route[] = [ | ||
| 1092 | 1111 | STATUS[got.error], |
| 1093 | 1112 | clear, |
| 1094 | 1113 | ); |
| 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); | |
| 1096 | 1115 | }, |
| 1097 | 1116 | }, |
| 1098 | 1117 | { |
src/sample.ts +76 −23
| @@ -321,6 +321,33 @@ export function isUntouched(state: SampleState, anchor: string): boolean { | ||
| 321 | 321 | ); |
| 322 | 322 | } |
| 323 | 323 | |
| 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 | ||
| 324 | 351 | /** |
| 325 | 352 | * Bring the sample deals nobody has touched up to `on`, then remember `on` as |
| 326 | 353 | * 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 { | ||
| 336 | 363 | "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", |
| 337 | 364 | ).all(SAMPLE.id); |
| 338 | 365 | 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); | |
| 361 | 367 | if (!isUntouched(state, anchor)) continue; |
| 362 | 368 | let shifted = shiftSample( |
| 363 | 369 | { |
| @@ -493,7 +499,7 @@ export function seed(db: Database, on = today(zoneOf(db))): void { | ||
| 493 | 499 | // Asked and written in one write transaction, taken before the question: |
| 494 | 500 | // the server and a terminal opening a new file at once seed it once. |
| 495 | 501 | 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; | |
| 497 | 503 | writeSamples(db, on); |
| 498 | 504 | }).immediate(); |
| 499 | 505 | } |
| @@ -508,7 +514,54 @@ export function seed(db: Database, on = today(zoneOf(db))): void { | ||
| 508 | 514 | export function restore(db: Database, on = today(zoneOf(db))): number[] { |
| 509 | 515 | return db.transaction(() => { |
| 510 | 516 | db.query("DELETE FROM deal WHERE author_id = ?1").run(SAMPLE.id); |
| 517 | db.query("DELETE FROM app_meta WHERE key = ?1").run(CLEARED); | |
| 511 | 518 | writeSamples(db, on); |
| 512 | 519 | return db.query<{ id: number }, [string]>("SELECT id FROM deal WHERE author_id = ?1 ORDER BY id").all(SAMPLE.id).map((r) => r.id); |
| 513 | 520 | }).immediate(); |
| 514 | 521 | } |
| 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 | } |
new above
offline