Public: anyone can read this; the crew can change it.
Put back an asset's earlier words: each asset keeps its last five versions and one tap restores one #56
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/39-put-back-an-asset-s-earlier-words-each-a → main 5 commits fork
16 files
changed +607
−33
README.md +17 −6
| @@ -28,20 +28,31 @@ to make a listing of your own. | ||
| 28 | 28 | or Bold) and press **Rewrite all**. Every asset is written again in that |
| 29 | 29 | tone, your edits are replaced, and the approval is cleared: it says |
| 30 | 30 | "0 of 6 approved" again, because you have not read the new words yet. |
| 31 | 6. **Drop the price.** Press **Edit facts** at the top of your listing. The | |
| 31 | 6. **Put back your MLS words.** Under the MLS box, press **Earlier versions | |
| 32 | (1)**: it lists the words the rewrite replaced, when, by whom and why | |
| 33 | ("rewrite in Bold"), and the first line of each. Press **Restore** by | |
| 34 | your own words. They are back in the box, checked again, and the | |
| 35 | approval is clear until you read them again. Every asset keeps its last | |
| 36 | five versions; restoring keeps the words it replaces, so it can be undone. | |
| 37 | Only you (and an admin) see the list: it holds drafts and words you took | |
| 38 | out on purpose, which a reader of your listing never sees. In the terminal, | |
| 39 | open the listing, move to the asset and press `v` to list its earlier | |
| 40 | versions, then `1` to `5` to put one back. An assistant connected to the | |
| 41 | app does the same with the `list_versions` and `restore_asset` tools. | |
| 42 | 7. **Drop the price.** Press **Edit facts** at the top of your listing. The | |
| 32 | 43 | launch form opens filled in with this listing's address, beds, baths, |
| 33 | 44 | square feet, lot, year, price, features and brand. Lower the price and |
| 34 | 45 | press **Save and write all again**: the new price is on the flyer, every |
| 35 | 46 | asset is written again from the facts (your edits are replaced), and the |
| 36 | 47 | approval is cleared, so it says "0 of 6 approved". The photos stay. |
| 37 | 7. **Add an open house.** Press **Edit facts** again. Under "Open house" | |
| 48 | 8. **Add an open house.** Press **Edit facts** again. Under "Open house" | |
| 38 | 49 | pick a date, a start and an end time (Alder Lane's sample already has one), |
| 39 | 50 | then press **Save and write all again**. The flyer gets a band under the |
| 40 | 51 | price, the email a line above its body, and the MLS description and every |
| 41 | 52 | post a closing line, such as "Open Sunday, March 7, 1–3 pm", all in the |
| 42 | 53 | listing's tone. Clearing the three boxes takes it off again. The times are |
| 43 | 54 | kept as you type them: no time zone is applied. |
| 44 | 8. **Mark it sold.** On your listing, pick **Sold** in the status box beside | |
| 55 | 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside | |
| 45 | 56 | the tone, add the price it sold at if you want it said, and press **Set |
| 46 | 57 | status**. Two more assets appear, a status post under Social and a status |
| 47 | 58 | email under Email ("Just sold — 9 Walkthrough Way"), written in the |
| @@ -62,15 +73,15 @@ to make a listing of your own. | ||
| 62 | 73 | **Find one.** Above the list, **All · Active · Under contract · Sold · |
| 63 | 74 | Coming soon · Needs approval** are links with their counts, and the |
| 64 | 75 | search box matches the address, any case, together with the filter. |
| 65 | 9. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with | |
| 76 | 10. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with | |
| 66 | 77 | the photo, price, facts, description and your name. Print it or save it |
| 67 | 78 | as a PDF. |
| 68 | 10. **Download the launch kit.** Press **Download launch kit** at the top of | |
| 79 | 11. **Download the launch kit.** Press **Download launch kit** at the top of | |
| 69 | 80 | the listing: one text file named from the address, with the price and |
| 70 | 81 | facts, then every asset under its name, each marked "Approved by … on …" |
| 71 | 82 | or "DRAFT — not approved", with its Fair Housing findings beneath it. Hand |
| 72 | 83 | it to an assistant or a coordinator. |
| 73 | 11. **Show buyers the home.** Under the facts on the listing, the property | |
| 84 | 12. **Show buyers the home.** Under the facts on the listing, the property | |
| 74 | 85 | page link (`/homes/:id`) has a Copy button; the flyer prints it and the |
| 75 | 86 | email's "See the home" button points to it. Open it on a phone: photos, |
| 76 | 87 | price, status, open house, your contact details, and the MLS description |
contract/openapi.json +6 −2
| @@ -579,7 +579,7 @@ | ||
| 579 | 579 | } |
| 580 | 580 | }, |
| 581 | 581 | "patch": { |
| 582 | "summary": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 582 | "summary": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 583 | 583 | "operationId": "patchApiListingsId", |
| 584 | 584 | "parameters": [ |
| 585 | 585 | { |
| @@ -775,6 +775,10 @@ | ||
| 775 | 775 | } |
| 776 | 776 | } |
| 777 | 777 | }, |
| 778 | "restore": { | |
| 779 | "type": "integer", | |
| 780 | "description": "Put back an earlier version of the asset `kind` (its id from earlier_versions in the listing page or list_versions): a new save, checked again, approval cleared. With `seen`, a conflict (409) if the words have changed since." | |
| 781 | }, | |
| 778 | 782 | "approved": { |
| 779 | 783 | "type": "boolean" |
| 780 | 784 | }, |
| @@ -789,7 +793,7 @@ | ||
| 789 | 793 | }, |
| 790 | 794 | "responses": { |
| 791 | 795 | "200": { |
| 792 | "description": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 796 | "description": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 793 | 797 | "content": { |
| 794 | 798 | "application/json": { |
| 795 | 799 | "schema": { |
docs/design/areas/versions.md added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | # Earlier versions | |
| 2 | ||
| 3 | New component class, built from existing tokens: | |
| 4 | ||
| 5 | - `versions` — the `<details>` under an asset's box, "Earlier versions (n)", opening a list of the words each save replaced (when, by whom, why, the first line) with a Restore button on each. It carries no style of its own beyond the browser's disclosure; only whoever may edit the listing sees it. |
src/a11y.test.ts +1 −1
| @@ -16,7 +16,7 @@ const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "a11y-")), i | ||
| 16 | 16 | const base = `http://${server.hostname}:${server.port}`; |
| 17 | 17 | afterAll(() => server.stop(true)); |
| 18 | 18 | |
| 19 | const identity = `u1|realtor|admin|${Date.now() + 600_000}`; | |
| 19 | const identity = `a11y-admin|realtor|admin|${Date.now() + 600_000}`; | |
| 20 | 20 | const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; |
| 21 | 21 | const page = async (path: string, signedIn = true): Promise<string> => { |
| 22 | 22 | const res = await fetch(base + path, { headers: signedIn ? headers : {} }); |
src/actions.ts +143 −8
| @@ -352,7 +352,12 @@ function launched(db: Database, id: number): Launched | undefined { | ||
| 352 | 352 | * default, or only `only`. A kind the listing has no words for now (a status |
| 353 | 353 | * post once it is active again) is removed. |
| 354 | 354 | */ |
| 355 | function writeAssets(db: Database, listing: Listing, only: readonly Kind[] = KINDS): void { | |
| 355 | function writeAssets( | |
| 356 | db: Database, | |
| 357 | listing: Listing, | |
| 358 | only: readonly Kind[] = KINDS, | |
| 359 | by?: { who: Who; why: string }, | |
| 360 | ): void { | |
| 356 | 361 | const words = generate({ facts: listing, agent: agentOf(listing) }); |
| 357 | 362 | const put = db.query( |
| 358 | 363 | `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0) |
| @@ -364,10 +369,36 @@ function writeAssets(db: Database, listing: Listing, only: readonly Kind[] = KIN | ||
| 364 | 369 | for (const kind of only) { |
| 365 | 370 | const body = words[kind]; |
| 366 | 371 | if (body === undefined) drop.run(listing.id, kind); |
| 367 | else put.run(listing.id, kind, body); | |
| 372 | else { | |
| 373 | if (by !== undefined) keepWords(db, listing.id, kind, body, by.who, by.why); | |
| 374 | put.run(listing.id, kind, body); | |
| 375 | } | |
| 368 | 376 | } |
| 369 | 377 | } |
| 370 | 378 | |
| 379 | /** How many earlier versions of one asset's words are kept. */ | |
| 380 | export const VERSIONS_KEPT = 5; | |
| 381 | ||
| 382 | /** | |
| 383 | * Before a save puts `next` over an asset, keep the words it replaces — who is | |
| 384 | * replacing them, and why — and forget all but the newest `VERSIONS_KEPT`. | |
| 385 | * Nothing is replaced (so nothing kept) where the asset is new or the words | |
| 386 | * are the same. | |
| 387 | */ | |
| 388 | function keepWords(db: Database, id: number, kind: string, next: string, who: Who, why: string): void { | |
| 389 | const now = db | |
| 390 | .query<{ body: string }, [number, string]>("SELECT body FROM asset WHERE listing_id = ?1 AND kind = ?2") | |
| 391 | .get(id, kind); | |
| 392 | if (now === null || now.body === next) return; | |
| 393 | db.query("INSERT INTO asset_version (listing_id, kind, body, by_id, by_name, reason) VALUES (?1, ?2, ?3, ?4, ?5, ?6)").run( | |
| 394 | id, kind, now.body, who.role === "anon" ? "" : who.id, who.role === "anon" ? "" : who.name, why, | |
| 395 | ); | |
| 396 | db.query( | |
| 397 | `DELETE FROM asset_version WHERE listing_id = ?1 AND kind = ?2 AND id NOT IN | |
| 398 | (SELECT id FROM asset_version WHERE listing_id = ?1 AND kind = ?2 ORDER BY id DESC LIMIT ?3)`, | |
| 399 | ).run(id, kind, VERSIONS_KEPT); | |
| 400 | } | |
| 401 | ||
| 371 | 402 | /** Insert a listing and its assets as one, for whoever is named. */ |
| 372 | 403 | export function insertListing( |
| 373 | 404 | db: Database, |
| @@ -635,6 +666,7 @@ function edit( | ||
| 635 | 666 | db: Database, |
| 636 | 667 | who: Who, |
| 637 | 668 | input: { id: number; kind: string; text: unknown }, |
| 669 | why = "edited", | |
| 638 | 670 | ): Result<Launched> { |
| 639 | 671 | const found = listingFor(db, who, "update", input.id, { |
| 640 | 672 | signIn: "Sign in to edit this listing.", |
| @@ -646,6 +678,7 @@ function edit( | ||
| 646 | 678 | const text = assetText(input.text); |
| 647 | 679 | if (!text.ok) return fail("invalid", text.message); |
| 648 | 680 | db.transaction(() => { |
| 681 | keepWords(db, found.value.id, input.kind, text.value, who, why); | |
| 649 | 682 | db.query( |
| 650 | 683 | "UPDATE asset SET body = ?1, edited = 1, approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?2 AND kind = ?3", |
| 651 | 684 | ).run(text.value, found.value.id, input.kind); |
| @@ -693,11 +726,113 @@ function suggest( | ||
| 693 | 726 | return fail("conflict", "These words changed since you read them. Read them again before taking the suggestion."); |
| 694 | 727 | if (finding.dropIn === null) |
| 695 | 728 | return fail("invalid", "That finding has advice, not a phrase to put in its place."); |
| 696 | return edit(db, who, { | |
| 697 | id: found.value.id, | |
| 698 | kind: input.kind, | |
| 699 | text: asset.text.slice(0, finding.start) + finding.dropIn + asset.text.slice(finding.end), | |
| 729 | return edit( | |
| 730 | db, | |
| 731 | who, | |
| 732 | { | |
| 733 | id: found.value.id, | |
| 734 | kind: input.kind, | |
| 735 | text: asset.text.slice(0, finding.start) + finding.dropIn + asset.text.slice(finding.end), | |
| 736 | }, | |
| 737 | "suggestion used", | |
| 738 | ); | |
| 739 | } | |
| 740 | ||
| 741 | /** One earlier version of an asset's words, as the page lists it. */ | |
| 742 | export interface Version { | |
| 743 | readonly id: number; | |
| 744 | readonly kind: Kind; | |
| 745 | readonly text: string; | |
| 746 | /** When these words were replaced. */ | |
| 747 | readonly at: string; | |
| 748 | readonly by_name: string; | |
| 749 | /** Why: "edited", "suggestion used", "rewrite in Bold", "facts changed", "restored". */ | |
| 750 | readonly why: string; | |
| 751 | } | |
| 752 | ||
| 753 | interface VersionRow { | |
| 754 | id: number; | |
| 755 | kind: Kind; | |
| 756 | body: string; | |
| 757 | replaced_at: string; | |
| 758 | by_name: string; | |
| 759 | reason: string; | |
| 760 | } | |
| 761 | ||
| 762 | const versionOf = (r: VersionRow): Version => ({ | |
| 763 | id: r.id, | |
| 764 | kind: r.kind, | |
| 765 | text: r.body, | |
| 766 | at: r.replaced_at, | |
| 767 | by_name: r.by_name, | |
| 768 | why: r.reason, | |
| 769 | }); | |
| 770 | ||
| 771 | /** | |
| 772 | * A listing's earlier versions, newest first, by asset kind: only whoever may | |
| 773 | * update the listing sees them — they hold drafts and words taken out on | |
| 774 | * purpose, which the property page never shows a reader. | |
| 775 | */ | |
| 776 | export function listVersions( | |
| 777 | db: Database, | |
| 778 | who: Who, | |
| 779 | input: { id: number }, | |
| 780 | ): Result<Readonly<Record<string, readonly Version[]>>> { | |
| 781 | const found = listingFor(db, who, "update", input.id, { | |
| 782 | signIn: "Sign in to see this listing's earlier versions.", | |
| 783 | no: "Only the listing's agent or an admin can see its earlier versions.", | |
| 700 | 784 | }); |
| 785 | if (!found.ok) return found; | |
| 786 | const rows = db | |
| 787 | .query<VersionRow, [number]>( | |
| 788 | "SELECT id, kind, body, replaced_at, by_name, reason FROM asset_version WHERE listing_id = ?1 ORDER BY id DESC", | |
| 789 | ) | |
| 790 | .all(found.value.id); | |
| 791 | const by: Record<string, Version[]> = {}; | |
| 792 | for (const r of rows) (by[r.kind] ??= []).push(versionOf(r)); | |
| 793 | return ok(by); | |
| 794 | } | |
| 795 | ||
| 796 | /** | |
| 797 | * Put an earlier version's words back as a new save: checked again, audited, | |
| 798 | * approval cleared, and the words it replaces kept in turn. `version` is the | |
| 799 | * version's id (the newest of the asset's when left out); `seen`, when given, | |
| 800 | * is the asset's `updated_at` as the person read it, and a conflict if the | |
| 801 | * words have been saved over since. | |
| 802 | */ | |
| 803 | export const restoreAsset = ( | |
| 804 | db: Database, | |
| 805 | who: Who, | |
| 806 | input: { id: number; kind: string; version?: unknown; seen?: unknown }, | |
| 807 | ): Result<Launched> => | |
| 808 | audited(db, who, "listing", "update", input.id, restore(db, who, input)); | |
| 809 | ||
| 810 | function restore( | |
| 811 | db: Database, | |
| 812 | who: Who, | |
| 813 | input: { id: number; kind: string; version?: unknown; seen?: unknown }, | |
| 814 | ): Result<Launched> { | |
| 815 | const found = listingFor(db, who, "update", input.id, { | |
| 816 | signIn: "Sign in to edit this listing.", | |
| 817 | no: "Only the listing's agent or an admin can edit it.", | |
| 818 | }); | |
| 819 | if (!found.ok) return found; | |
| 820 | if (!isKind(input.kind)) | |
| 821 | return fail("invalid", `An asset is one of ${KINDS.join(", ")}.`); | |
| 822 | const asset = found.value.assets.find((a) => a.kind === input.kind); | |
| 823 | if (asset === undefined) return fail("not_found", "No such asset."); | |
| 824 | if (input.seen !== undefined && input.seen !== asset.updated_at) | |
| 825 | return fail("conflict", "These words changed since you read them. Read them again before putting an earlier version back."); | |
| 826 | if (input.version !== undefined && !Number.isInteger(input.version)) | |
| 827 | return fail("invalid", "A version is a whole number."); | |
| 828 | const row = db | |
| 829 | .query<VersionRow, [number, string, number | null]>( | |
| 830 | `SELECT id, kind, body, replaced_at, by_name, reason FROM asset_version | |
| 831 | WHERE listing_id = ?1 AND kind = ?2 AND (?3 IS NULL OR id = ?3) ORDER BY id DESC LIMIT 1`, | |
| 832 | ) | |
| 833 | .get(found.value.id, input.kind, (input.version as number | undefined) ?? null); | |
| 834 | if (row === null) return fail("not_found", "No such earlier version."); | |
| 835 | return edit(db, who, { id: found.value.id, kind: input.kind, text: row.body }, "restored"); | |
| 701 | 836 | } |
| 702 | 837 | |
| 703 | 838 | /** How far a short post is over X's limit; 0 for any other asset or one that fits. */ |
| @@ -823,7 +958,7 @@ function retone( | ||
| 823 | 958 | found.value.id, |
| 824 | 959 | ); |
| 825 | 960 | touch(db, found.value.id); |
| 826 | writeAssets(db, { ...found.value, tone }); | |
| 961 | writeAssets(db, { ...found.value, tone }, KINDS, { who, why: `rewrite in ${tone[0]!.toUpperCase()}${tone.slice(1)}` }); | |
| 827 | 962 | })(); |
| 828 | 963 | emit(db, "listing.changed", { id: found.value.id }); |
| 829 | 964 | return ok(launched(db, found.value.id)!); |
| @@ -889,7 +1024,7 @@ function changeFacts( | ||
| 889 | 1024 | const was = v.price < now.price ? (now.was_price ?? now.price) : now.was_price !== null && v.price < now.was_price ? now.was_price : null; |
| 890 | 1025 | db.query("UPDATE listing SET was_price = ?1 WHERE id = ?2").run(was, now.id); |
| 891 | 1026 | touch(db, now.id); |
| 892 | writeAssets(db, { ...now, ...v, was_price: was }); | |
| 1027 | writeAssets(db, { ...now, ...v, was_price: was }, KINDS, { who, why: "facts changed" }); | |
| 893 | 1028 | })(); |
| 894 | 1029 | emit(db, "listing.changed", { id: now.id }); |
| 895 | 1030 | return ok(launched(db, now.id)!); |
src/comingsoon.test.ts +2 −3
| @@ -138,9 +138,8 @@ test("the migration keeps every listing, photo name and approval", () => { | ||
| 138 | 138 | const dir = tmp("soon-mig"); |
| 139 | 139 | const db = new Database(join(dir, "app.db")); |
| 140 | 140 | db.exec("PRAGMA foreign_keys = ON"); |
| 141 | // A database as the previous migration list made it: replay all but the last, then add rows. | |
| 142 | const last = migrations.length - 1; | |
| 143 | const real = migrations.splice(last); | |
| 141 | // A database as the previous migration list made it: replay all before the status rebuild, then add rows. | |
| 142 | const real = migrations.splice(migrations.findIndex((m) => m.includes("CREATE TABLE listing_old"))); | |
| 144 | 143 | try { |
| 145 | 144 | migrate(db); |
| 146 | 145 | } finally { |
src/contract.test.ts +38
| @@ -20,6 +20,8 @@ import { | ||
| 20 | 20 | getNote, |
| 21 | 21 | insertListing, |
| 22 | 22 | launchListing, |
| 23 | listVersions, | |
| 24 | restoreAsset, | |
| 23 | 25 | updatePhotos, |
| 24 | 26 | listAudit, |
| 25 | 27 | listListings, |
| @@ -274,3 +276,39 @@ test("a property page answers as the listing's read cell says once approved, and | ||
| 274 | 276 | } |
| 275 | 277 | expect(asked).toBeGreaterThan(0); |
| 276 | 278 | }); |
| 279 | ||
| 280 | /** Putting an earlier version back (and listing them) is `listing:update`: the action, the tool and the key let through exactly whom the table's update cell does. */ | |
| 281 | test("restoring an earlier version answers as the listing's update cell says, at the action, the tool and the listing of versions", () => { | |
| 282 | const root = { role: "admin" as const, id: "root", name: "root" }; | |
| 283 | let asked = 0; | |
| 284 | for (const cell of table(contract)) { | |
| 285 | if (cell.resource !== "listing" || cell.verb !== "update") continue; | |
| 286 | const who = whoIs(cell.as); | |
| 287 | const { mine, theirs } = actingAs(contract, cell.as); | |
| 288 | const attempts = (authorId: string) => { | |
| 289 | const id = listingBy(authorId); | |
| 290 | const tool = (name: string, args: object) => | |
| 291 | !JSON.stringify(rpc(db, who, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }).body).includes('"isError":true'); | |
| 292 | const earlier = () => { | |
| 293 | editAsset(db, root, { id, kind: "mls", text: "Changed by root." }); | |
| 294 | const v = listVersions(db, root, { id }); | |
| 295 | return v.ok ? v.value["mls"]![0]!.id : -1; | |
| 296 | }; | |
| 297 | return [ | |
| 298 | restoreAsset(db, who, { id, kind: "mls", version: earlier() }).ok, | |
| 299 | tool("restore_asset", { id, kind: "mls", version: earlier() }), | |
| 300 | listVersions(db, who, { id }).ok, | |
| 301 | tool("list_versions", { id }), | |
| 302 | ]; | |
| 303 | }; | |
| 304 | const onMine = attempts(String(mine["author_id"])); | |
| 305 | const onTheirs = attempts(String(theirs["author_id"])); | |
| 306 | const said = `restore_asset as ${cell.as}`; | |
| 307 | asked++; | |
| 308 | const all = (v: boolean) => [v, v, v, v]; | |
| 309 | if (cell.answer === "yes") expect([said, onMine, onTheirs]).toEqual([said, all(true), all(true)]); | |
| 310 | else if (cell.answer === "no") expect([said, onMine, onTheirs]).toEqual([said, all(false), all(false)]); | |
| 311 | else expect([said, onMine, onTheirs]).toEqual([said, all(true), all(false)]); | |
| 312 | } | |
| 313 | expect(asked).toBeGreaterThan(0); | |
| 314 | }); |
src/db.ts +13
| @@ -144,6 +144,19 @@ export const migrations: string[] = [ | ||
| 144 | 144 | SELECT listing_id, kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via FROM asset_old; |
| 145 | 145 | DROP TABLE asset_old; |
| 146 | 146 | DROP TABLE listing_old;`, |
| 147 | // The words an asset's save replaced (the five most recent per asset are kept, src/actions.ts): who | |
| 148 | // replaced them, when, and why. They go with their listing. | |
| 149 | `CREATE TABLE IF NOT EXISTS asset_version ( | |
| 150 | id INTEGER PRIMARY KEY, | |
| 151 | listing_id INTEGER NOT NULL REFERENCES listing (id) ON DELETE CASCADE, | |
| 152 | kind TEXT NOT NULL, | |
| 153 | body TEXT NOT NULL, | |
| 154 | replaced_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 155 | by_id TEXT NOT NULL, | |
| 156 | by_name TEXT NOT NULL, | |
| 157 | reason TEXT NOT NULL | |
| 158 | ); | |
| 159 | CREATE INDEX IF NOT EXISTS asset_version_asset ON asset_version (listing_id, kind, id)`, | |
| 147 | 160 | ]; |
| 148 | 161 | |
| 149 | 162 | export function openDb(dir = dataDir): Database { |
src/mcp.ts +21
| @@ -21,6 +21,8 @@ import { | ||
| 21 | 21 | launchListing, |
| 22 | 22 | listListings, |
| 23 | 23 | listNotes, |
| 24 | listVersions, | |
| 25 | restoreAsset, | |
| 24 | 26 | retoneListing, |
| 25 | 27 | setStatus, |
| 26 | 28 | updateFacts, |
| @@ -202,6 +204,25 @@ export const tools: readonly Tool[] = [ | ||
| 202 | 204 | text: args.text, |
| 203 | 205 | }), |
| 204 | 206 | }, |
| 207 | { | |
| 208 | name: "list_versions", | |
| 209 | description: | |
| 210 | "A listing's earlier versions of its assets' words: the five most recent per asset, newest first, each with its id, when it was replaced, by whom, why, and the words. Only the listing's agent or an admin can.", | |
| 211 | inputSchema: { type: "object", properties: { id: { type: "integer" } }, required: ["id"] }, | |
| 212 | run: (db, who, args) => listVersions(db, who, { id: Number(args.id) }), | |
| 213 | }, | |
| 214 | { | |
| 215 | name: "restore_asset", | |
| 216 | description: | |
| 217 | "Put an earlier version of one asset's words back (list_versions gives the ids; the newest when `version` is left out). A new save: checked again, audited, approval cleared, the words it replaces kept in turn. Only the listing's agent or an admin can.", | |
| 218 | inputSchema: { | |
| 219 | type: "object", | |
| 220 | properties: { id: { type: "integer" }, kind: { enum: [...KINDS] }, version: { type: "integer" }, seen: { type: "string", description: "The asset's updated_at as you read it; a conflict if the words changed since." } }, | |
| 221 | required: ["id", "kind"], | |
| 222 | }, | |
| 223 | run: (db, who, args) => | |
| 224 | restoreAsset(db, who, { id: Number(args.id), kind: String(args.kind ?? ""), version: args.version, seen: args.seen }), | |
| 225 | }, | |
| 205 | 226 | { |
| 206 | 227 | name: "approve_asset", |
| 207 | 228 | description: |
src/pages/listing.ts +33 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * flyer, which is also a page of its own for printing (`flyerPage`). |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { Version } from "../actions.ts"; | |
| 7 | 8 | import type { Check, Finding } from "../fairhousing.ts"; |
| 8 | 9 | import { |
| 9 | 10 | factItems, |
| @@ -134,7 +135,34 @@ export function approval(l: Launched, a: Asset): string { | ||
| 134 | 135 | <button class="button" data-approve aria-label="${words}: the ${escape(a.label)}" formaction="${to}" name="approved" value="1">${words}</button>`; |
| 135 | 136 | } |
| 136 | 137 | |
| 137 | function editor(l: Launched, a: Asset, editable: boolean): string { | |
| 138 | /** The first line of some words, cut short for a list. */ | |
| 139 | const firstLine = (text: string): string => { | |
| 140 | const line = text.split("\n").find((x) => x.trim() !== "") ?? ""; | |
| 141 | return line.length > 100 ? `${line.slice(0, 99)}…` : line; | |
| 142 | }; | |
| 143 | ||
| 144 | /** The asset's last versions, newest first, each with the form that puts it back (only for whoever may edit: earlier versions hold drafts a reader is not shown). */ | |
| 145 | function versionList(l: Launched, a: Asset, versions: readonly Version[], editable: boolean): string { | |
| 146 | if (!editable || versions.length === 0) return ""; | |
| 147 | return `<details class="versions"> | |
| 148 | <summary>Earlier versions (${versions.length})</summary> | |
| 149 | <ol>${versions | |
| 150 | .map( | |
| 151 | (v) => `<li> | |
| 152 | <small class="muted">${ago(v.at)} · ${escape(v.by_name)} · ${escape(v.why)}</small> | |
| 153 | <p>${escape(firstLine(v.text))}</p> | |
| 154 | <form method="post" action="/listings/${l.id}/assets/${a.kind}/restore"> | |
| 155 | <input type="hidden" name="version" value="${v.id}"> | |
| 156 | <input type="hidden" name="seen" value="${escape(a.updated_at)}"> | |
| 157 | <button class="button button-quiet" aria-label="Restore the ${escape(a.label)} from ${escape(firstLine(v.text))}">Restore</button> | |
| 158 | </form> | |
| 159 | </li>`, | |
| 160 | ) | |
| 161 | .join("")}</ol> | |
| 162 | </details>`; | |
| 163 | } | |
| 164 | ||
| 165 | function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Version[]): string { | |
| 138 | 166 | const rows = Math.min(16, Math.max(4, Math.ceil(a.text.length / 70) + a.text.split("\n").length)); |
| 139 | 167 | return `<section class="asset" id="${a.kind}" aria-labelledby="${a.kind}-title"> |
| 140 | 168 | <div class="asset-edit"> |
| @@ -148,6 +176,7 @@ function editor(l: Launched, a: Asset, editable: boolean): string { | ||
| 148 | 176 | <small class="muted" data-count>${isPost(a.kind) ? escape(lengthLine(a.text)) : `${a.text.length} characters`}</small> |
| 149 | 177 | </div> |
| 150 | 178 | </form> |
| 179 | ${versionList(l, a, versions, editable)} | |
| 151 | 180 | </div> |
| 152 | 181 | <aside class="fh" data-fh="${a.kind}" aria-live="polite">${asideOf(a.kind, a.text, a.check, editable ? suggestionHref(l.id, a.kind) : undefined)}</aside> |
| 153 | 182 | </section>`; |
| @@ -174,6 +203,8 @@ export function listingPage(input: { | ||
| 174 | 203 | listing: Launched; |
| 175 | 204 | origin?: string; |
| 176 | 205 | editable: boolean; |
| 206 | /** Each asset's earlier versions by kind, newest first. */ | |
| 207 | versions?: Readonly<Record<string, readonly Version[]>>; | |
| 177 | 208 | tab?: string | null; |
| 178 | 209 | notice?: string | undefined; |
| 179 | 210 | }): string { |
| @@ -229,7 +260,7 @@ export function listingPage(input: { | ||
| 229 | 260 | ${t.id === "email" ? `<p><a class="button button-quiet" href="/listings/${l.id}/email">Preview email</a></p>` : ""} |
| 230 | 261 | ${l.assets |
| 231 | 262 | .filter((a) => t.kinds.includes(a.kind)) |
| 232 | .map((a) => editor(l, a, editable)) | |
| 263 | .map((a) => editor(l, a, editable, input.versions?.[a.kind] ?? [])) | |
| 233 | 264 | .join("")} |
| 234 | 265 | </div>`, |
| 235 | 266 | ).join("")} |
src/routes.ts +22 −1
| @@ -31,6 +31,8 @@ import { | ||
| 31 | 31 | listAudit, |
| 32 | 32 | listListings, |
| 33 | 33 | listNotes, |
| 34 | listVersions, | |
| 35 | restoreAsset, | |
| 34 | 36 | retoneListing, |
| 35 | 37 | setStatus, |
| 36 | 38 | updateFacts, |
| @@ -308,6 +310,7 @@ const LISTING_CHANGE: Schema = { | ||
| 308 | 310 | remove: { type: "array", items: { type: "string" } }, |
| 309 | 311 | }, |
| 310 | 312 | }, |
| 313 | restore: { type: "integer", description: "Put back an earlier version of the asset `kind` (its id from earlier_versions in the listing page or list_versions): a new save, checked again, approval cleared. With `seen`, a conflict (409) if the words have changed since." }, | |
| 311 | 314 | approved: { type: "boolean" }, |
| 312 | 315 | seen: { |
| 313 | 316 | type: "string", |
| @@ -468,12 +471,15 @@ export const routes: readonly Route[] = [ | ||
| 468 | 471 | const { notice, clear } = readNotice(c.request); |
| 469 | 472 | const found = getListing(c.db, c.who, { id: Number(c.params.id) }); |
| 470 | 473 | if (!found.ok) return page(c.who, found, () => ""); |
| 474 | const earlier = listVersions(c.db, c.who, { id: Number(c.params.id) }); | |
| 475 | const versions = earlier.ok ? earlier.value : {}; | |
| 471 | 476 | return html( |
| 472 | 477 | listingPage({ |
| 473 | 478 | who: c.who, |
| 474 | 479 | listing: found.value, |
| 475 | 480 | origin: publicOrigin, |
| 476 | 481 | editable: canEditListing(c.who, found.value), |
| 482 | versions, | |
| 477 | 483 | tab: c.url.searchParams.get("tab"), |
| 478 | 484 | notice, |
| 479 | 485 | }), |
| @@ -656,6 +662,19 @@ export const routes: readonly Route[] = [ | ||
| 656 | 662 | return back(done.ok ? (approving ? "Approved." : "Approval withdrawn.") : done.message, to); |
| 657 | 663 | }, |
| 658 | 664 | }, |
| 665 | { | |
| 666 | method: "POST", | |
| 667 | path: "/listings/:id/assets/:kind/restore", | |
| 668 | handle: async (c) => { | |
| 669 | const form = await readForm(c.request); | |
| 670 | if (form === undefined) return text("too large", 413); | |
| 671 | const id = Number(c.params.id); | |
| 672 | const kind = c.params.kind ?? ""; | |
| 673 | const done = restoreAsset(c.db, c.who, { id, kind, version: Number(form.get("version") ?? ""), seen: form.get("seen") ?? "" }); | |
| 674 | const to = `/listings/${id}?tab=${tabOf(kind)}#${encodeURIComponent(kind)}`; | |
| 675 | return back(done.ok ? "Put back. Read it through, then approve it again." : done.message, to); | |
| 676 | }, | |
| 677 | }, | |
| 659 | 678 | { |
| 660 | 679 | method: "POST", |
| 661 | 680 | path: "/listings/:id/tone", |
| @@ -869,12 +888,14 @@ export const routes: readonly Route[] = [ | ||
| 869 | 888 | ? setStatus(c.db, c.who, { id, status: body.status, sold_price: body.sold_price }) |
| 870 | 889 | : body.tone !== undefined && body.kind === undefined |
| 871 | 890 | ? retoneListing(c.db, c.who, { id, tone: body.tone }) |
| 891 | : body.restore !== undefined && body.text === undefined | |
| 892 | ? restoreAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), version: body.restore, seen: body.seen }) | |
| 872 | 893 | : body.approved !== undefined && body.text === undefined |
| 873 | 894 | ? approveAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), approved: body.approved, seen: body.seen, door: "api" }) |
| 874 | 895 | : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }), |
| 875 | 896 | ); |
| 876 | 897 | }, |
| 877 | api: { summary: "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING }, | |
| 898 | api: { summary: "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING }, | |
| 878 | 899 | }, |
| 879 | 900 | { |
| 880 | 901 | method: "DELETE", |
src/sample.test.ts +3 −3
| @@ -5,7 +5,7 @@ import { mkdtempSync } from "node:fs"; | ||
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { getListing, launchListing } from "./actions.ts"; |
| 8 | import { migrate, openDb } from "./db.ts"; | |
| 8 | import { migrate, migrations, openDb } from "./db.ts"; | |
| 9 | 9 | import { kit } from "./kit.ts"; |
| 10 | 10 | import { draftBand } from "./pages/listing.ts"; |
| 11 | 11 | import { check } from "./fairhousing.ts"; |
| @@ -79,8 +79,8 @@ test("the migration approves an old sample once, and leaves edited or approved r | ||
| 79 | 79 | db.run("UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL"); |
| 80 | 80 | db.run(`UPDATE asset SET edited = 1 WHERE kind = 'flyer' AND listing_id = ${id}`); |
| 81 | 81 | db.run(`UPDATE asset SET approved_by_id = 'u1', approved_by_name = 'Pat', approved_at = '2026-01-01T00:00:00Z', approved_via = 'page' WHERE kind = 'email' AND listing_id = ${id}`); |
| 82 | // Forget the sample-approval migration and the one after it (the status rebuild, which runs again harmlessly). | |
| 83 | db.run("DELETE FROM schema_version WHERE version >= (SELECT max(version) FROM schema_version) - 1"); | |
| 82 | // Run the sample's migration (and those after it, which are safe to run again) once more. | |
| 83 | db.run(`DELETE FROM schema_version WHERE version >= ${migrations.findIndex((m) => m.startsWith("UPDATE asset SET approved_by_id = 'sample'")) + 1}`); | |
| 84 | 84 | migrate(db); |
| 85 | 85 | const rows = () => |
| 86 | 86 | db.query<{ kind: string; approved_by_name: string | null }, [number]>("SELECT kind, approved_by_name FROM asset WHERE listing_id = ?1").all(id); |
src/server.test.ts +2
| @@ -151,6 +151,8 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 151 | 151 | "launch_listing", |
| 152 | 152 | "edit_asset", |
| 153 | 153 | "use_suggestion", |
| 154 | "list_versions", | |
| 155 | "restore_asset", | |
| 154 | 156 | "approve_asset", |
| 155 | 157 | "retone_listing", |
| 156 | 158 | "set_status", |
src/tty.ts +35 −3
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | * - listings: enter opens one — every asset with its Fair Housing findings; |
| 7 | 7 | * j/k move between the assets, e edits the one under the cursor (the |
| 8 | 8 | * words are checked again as they are saved), t writes them all again in |
| 9 | * the next tone, a approves the asset or withdraws it, esc goes back; x | |
| 9 | * the next tone, a approves the asset or withdraws it, v puts back its | |
| 10 | * earlier words, esc goes back; x | |
| 10 | 11 | * removes a listing; p previews the email as the client will read it; k shows |
| 11 | 12 | * the launch kit, the file the page offers to download. |
| 12 | 13 | * - notes: a adds, d removes. |
| @@ -30,6 +31,8 @@ import { | ||
| 30 | 31 | getListing, |
| 31 | 32 | listListings, |
| 32 | 33 | listNotes, |
| 34 | listVersions, | |
| 35 | restoreAsset, | |
| 33 | 36 | retoneListing, |
| 34 | 37 | setStatus, |
| 35 | 38 | updateFacts, |
| @@ -92,6 +95,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 92 | 95 | let previewing = false; |
| 93 | 96 | /** The launch kit, as the file would read. */ |
| 94 | 97 | let kitting = false; |
| 98 | /** The asset whose earlier versions are listed, 1 to 5 puts one back. */ | |
| 99 | let versioning: Kind | undefined; | |
| 95 | 100 | let cursor = 0; |
| 96 | 101 | /** What the one-line input is for: a new note, or an asset's new words. */ |
| 97 | 102 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | { for: "search" } | undefined; |
| @@ -121,6 +126,14 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 121 | 126 | if (!found.ok) return [found.message]; |
| 122 | 127 | const l = found.value; |
| 123 | 128 | if (kitting) return kit(l).split("\n"); |
| 129 | if (versioning !== undefined) { | |
| 130 | const earlier = listVersions(db, me, { id: l.id }); | |
| 131 | const list = (earlier.ok ? earlier.value[versioning] : undefined) ?? []; | |
| 132 | return [ | |
| 133 | bold(`Earlier versions: ${l.assets.find((a) => a.kind === versioning)?.label ?? versioning}`), "", | |
| 134 | ...(list.length === 0 ? [dim("None yet.")] : list.map((v, i) => `${i + 1}) ${dim(`${v.at.slice(0, 16).replace("T", " ")} · ${scrub(v.by_name)} · ${v.why}`)} ${scrub(v.text.split("\n")[0] ?? "").slice(0, 60)}`)), | |
| 135 | ]; | |
| 136 | } | |
| 124 | 137 | if (previewing) { |
| 125 | 138 | const { subject, paragraphs } = emailParts(l.assets.find((a) => a.kind === "email")?.text ?? "", l.agent_name); |
| 126 | 139 | const approved = l.assets.find((a) => a.kind === "email")?.approval != null; |
| @@ -160,9 +173,10 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 160 | 173 | lines.push(...readLines()); |
| 161 | 174 | const found = open(); |
| 162 | 175 | if (previewing || kitting) help = "esc back to the assets · q quit"; |
| 176 | else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit"; | |
| 163 | 177 | else help = |
| 164 | 178 | found?.ok === true && canEditListing(me, found.value) |
| 165 | ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · t next tone · p preview email · k launch kit · esc back · q quit" | |
| 179 | ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · v list earlier versions and put one back · t next tone · p preview email · k launch kit · esc back · q quit" | |
| 166 | 180 | : "j/↓↑ asset · p preview email · k launch kit · esc back · q quit"; |
| 167 | 181 | } else if (view === "listings") { |
| 168 | 182 | const rows = listings(); |
| @@ -269,6 +283,21 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 269 | 283 | kitting = true; |
| 270 | 284 | return undefined; |
| 271 | 285 | } |
| 286 | if (versioning !== undefined) { | |
| 287 | const n = Number(key); | |
| 288 | if (key === "\x1b") versioning = undefined; | |
| 289 | else if (Number.isInteger(n) && n >= 1 && n <= 5 && found?.ok === true) { | |
| 290 | const earlier = listVersions(db, me, { id: found.value.id }); | |
| 291 | const picked = (earlier.ok ? earlier.value[versioning] : undefined)?.[n - 1]; | |
| 292 | if (picked === undefined) said = "No such earlier version."; | |
| 293 | else { | |
| 294 | const done = restoreAsset(db, me, { id: found.value.id, kind: versioning, version: picked.id }); | |
| 295 | said = done.ok ? green("Put back. Checked again; approval cleared.") : done.message; | |
| 296 | if (done.ok) versioning = undefined; | |
| 297 | } | |
| 298 | } | |
| 299 | return undefined; | |
| 300 | } | |
| 272 | 301 | if (key === "\x1b") { |
| 273 | 302 | if (previewing || kitting) previewing = kitting = false; |
| 274 | 303 | else { |
| @@ -280,7 +309,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 280 | 309 | if (previewing || kitting) return undefined; |
| 281 | 310 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 282 | 311 | if (up) cursor = Math.max(cursor - 1, 0); |
| 283 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s" || key === "r")) { | |
| 312 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s" || key === "r" || key === "v")) { | |
| 284 | 313 | if (!canEditListing(me, found.value)) |
| 285 | 314 | said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it."; |
| 286 | 315 | else if (key === "u") { |
| @@ -308,6 +337,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 308 | 337 | : done.message; |
| 309 | 338 | } |
| 310 | 339 | } |
| 340 | } else if (key === "v") { | |
| 341 | const asset = assets[cursor]; | |
| 342 | if (asset !== undefined) versioning = asset.kind; | |
| 311 | 343 | } else if (key === "s") { |
| 312 | 344 | typing = { for: "status" }; |
| 313 | 345 | typed = statusToLine(found.value); |
src/versions.test.ts added +247
| @@ -0,0 +1,247 @@ | ||
| 1 | /** | |
| 2 | * Earlier versions: every save of an asset keeps the words it replaced (the | |
| 3 | * last five), and Restore puts one back as a new save — through one action, | |
| 4 | * at the page, the MCP and the terminal. | |
| 5 | */ | |
| 6 | import { afterAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { | |
| 11 | approveAsset, | |
| 12 | deleteListing, | |
| 13 | editAsset, | |
| 14 | getListing, | |
| 15 | launchListing, | |
| 16 | listVersions, | |
| 17 | restoreAsset, | |
| 18 | retoneListing, | |
| 19 | updateFacts, | |
| 20 | useSuggestion, | |
| 21 | } from "./actions.ts"; | |
| 22 | import { openDb } from "./db.ts"; | |
| 23 | import { rpc } from "./mcp.ts"; | |
| 24 | import { listingPage } from "./pages/listing.ts"; | |
| 25 | import { SAMPLE } from "./sample.ts"; | |
| 26 | import { serve } from "./server.ts"; | |
| 27 | import { session } from "./tty.ts"; | |
| 28 | import { sign, type Who } from "./who.ts"; | |
| 29 | ||
| 30 | const me: Who = { role: "user", id: "u1", name: "Pat" }; | |
| 31 | const other: Who = { role: "user", id: "u2", name: "Sam" }; | |
| 32 | const anon: Who = { role: "anon" }; | |
| 33 | ||
| 34 | const launch = () => { | |
| 35 | const db = openDb(mkdtempSync(join(tmpdir(), "app-versions-"))); | |
| 36 | const made = launchListing(db, me, { fields: { ...SAMPLE } }); | |
| 37 | if (!made.ok) throw new Error(made.message); | |
| 38 | return { db, id: made.value.id }; | |
| 39 | }; | |
| 40 | const asset = (db: ReturnType<typeof openDb>, id: number, kind = "mls", who: Who = me) => { | |
| 41 | const found = getListing(db, who, { id }); | |
| 42 | if (!found.ok) throw new Error(found.message); | |
| 43 | return found.value.assets.find((a) => a.kind === kind)!; | |
| 44 | }; | |
| 45 | const versions = (db: ReturnType<typeof openDb>, id: number, kind = "mls") => { | |
| 46 | const v = listVersions(db, me, { id }); | |
| 47 | if (!v.ok) throw new Error(v.message); | |
| 48 | return v.value[kind] ?? []; | |
| 49 | }; | |
| 50 | ||
| 51 | test("a sixth save drops the oldest of five kept", () => { | |
| 52 | const { db, id } = launch(); | |
| 53 | const first = asset(db, id).text; | |
| 54 | for (let i = 1; i <= 6; i++) editAsset(db, me, { id, kind: "mls", text: `Words number ${i}.` }); | |
| 55 | const kept = versions(db, id); | |
| 56 | expect(kept.length).toBe(5); | |
| 57 | // Newest first: the words each save replaced; the generated first words are gone. | |
| 58 | expect(kept.map((v) => v.text)).toEqual(["Words number 5.", "Words number 4.", "Words number 3.", "Words number 2.", "Words number 1."]); | |
| 59 | expect(kept.map((v) => v.text)).not.toContain(first); | |
| 60 | expect(kept[0]!.why).toBe("edited"); | |
| 61 | expect(kept[0]!.by_name).toBe("Pat"); | |
| 62 | }); | |
| 63 | ||
| 64 | test("restore brings the words back as a new save, clearing approval, and can itself be undone", () => { | |
| 65 | const { db, id } = launch(); | |
| 66 | const mine = "My own MLS words for the house."; | |
| 67 | editAsset(db, me, { id, kind: "mls", text: mine }); | |
| 68 | const rewritten = retoneListing(db, me, { id, tone: "bold" }); | |
| 69 | expect(rewritten.ok).toBe(true); | |
| 70 | const [old] = versions(db, id); | |
| 71 | expect(old!.text).toBe(mine); | |
| 72 | expect(old!.why).toBe("rewrite in Bold"); | |
| 73 | const bold = asset(db, id); | |
| 74 | expect(approveAsset(db, me, { id, kind: "mls", door: "page", seen: bold.updated_at }).ok).toBe(true); | |
| 75 | expect(asset(db, id).approval).not.toBeNull(); | |
| 76 | const done = restoreAsset(db, me, { id, kind: "mls", version: old!.id }); | |
| 77 | expect(done.ok).toBe(true); | |
| 78 | const back = asset(db, id); | |
| 79 | expect(back.text).toBe(mine); | |
| 80 | expect(back.approval).toBeNull(); | |
| 81 | expect(back.edited).toBe(true); | |
| 82 | // The bold words it replaced are kept in turn. | |
| 83 | expect(versions(db, id)[0]!.text).toBe(bold.text); | |
| 84 | expect(versions(db, id)[0]!.why).toBe("restored"); | |
| 85 | }); | |
| 86 | ||
| 87 | test("facts changed and a suggestion used are kept with their reasons", () => { | |
| 88 | const { db, id } = launch(); | |
| 89 | updateFacts(db, me, { id, fields: { price: SAMPLE.price + 1000 } }); | |
| 90 | expect(versions(db, id)[0]!.why).toBe("facts changed"); | |
| 91 | editAsset(db, me, { id, kind: "mls", text: "A family-friendly street." }); | |
| 92 | const f = asset(db, id).check.findings.find((x) => x.dropIn !== null)!; | |
| 93 | expect(f).toBeDefined(); | |
| 94 | expect(useSuggestion(db, me, { id, kind: "mls", start: f.start, end: f.end, text: f.text }).ok).toBe(true); | |
| 95 | expect(versions(db, id)[0]!.why).toBe("suggestion used"); | |
| 96 | expect(versions(db, id)[0]!.text).toBe("A family-friendly street."); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a reader cannot restore or list; a stale page is a conflict; a gone version is not found", () => { | |
| 100 | const { db, id } = launch(); | |
| 101 | editAsset(db, me, { id, kind: "mls", text: "Changed once." }); | |
| 102 | const [v] = versions(db, id); | |
| 103 | for (const who of [other, anon]) { | |
| 104 | expect(restoreAsset(db, who, { id, kind: "mls", version: v!.id }).ok).toBe(false); | |
| 105 | expect(listVersions(db, who, { id }).ok).toBe(false); | |
| 106 | } | |
| 107 | expect(asset(db, id).text).toBe("Changed once."); | |
| 108 | const found = getListing(db, other, { id }); | |
| 109 | if (!found.ok) throw new Error(found.message); | |
| 110 | const readerPage = listingPage({ who: other, listing: found.value, editable: false, versions: { mls: [v!] } }); | |
| 111 | expect(readerPage).not.toContain("Earlier versions"); | |
| 112 | expect(readerPage).not.toContain("/restore"); | |
| 113 | const stale = restoreAsset(db, me, { id, kind: "mls", version: v!.id, seen: "2000-01-01T00:00:00.000Z" }); | |
| 114 | expect(stale.ok).toBe(false); | |
| 115 | if (!stale.ok) expect(stale.error).toBe("conflict"); | |
| 116 | const gone = restoreAsset(db, me, { id, kind: "mls", version: 999999 }); | |
| 117 | expect(gone.ok).toBe(false); | |
| 118 | if (!gone.ok) expect(gone.error).toBe("not_found"); | |
| 119 | }); | |
| 120 | ||
| 121 | test("the owner's page lists them with Restore; removing the listing removes them", () => { | |
| 122 | const { db, id } = launch(); | |
| 123 | editAsset(db, me, { id, kind: "mls", text: "Changed once." }); | |
| 124 | const found = getListing(db, me, { id }); | |
| 125 | if (!found.ok) throw new Error(found.message); | |
| 126 | const page = listingPage({ who: me, listing: found.value, editable: true, versions: { mls: versions(db, id) } }); | |
| 127 | expect(page).toContain("Earlier versions (1)"); | |
| 128 | expect(page).toContain(`/listings/${id}/assets/mls/restore`); | |
| 129 | expect(deleteListing(db, me, { id }).ok).toBe(true); | |
| 130 | expect(db.query("SELECT count(*) AS n FROM asset_version").get()).toEqual({ n: 0 }); | |
| 131 | }); | |
| 132 | ||
| 133 | test("the MCP lists and restores for the author, and refuses another", () => { | |
| 134 | const { db, id } = launch(); | |
| 135 | const generated = asset(db, id).text; | |
| 136 | editAsset(db, me, { id, kind: "mls", text: "Changed once." }); | |
| 137 | const call = (who: Who, name: string, args: object) => | |
| 138 | rpc(db, who, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }).body as { result: { isError?: boolean; content: { text: string }[] } }; | |
| 139 | const listed = call(me, "list_versions", { id }); | |
| 140 | expect(listed.result.isError).toBeFalsy(); | |
| 141 | expect(listed.result.content[0]!.text).toContain("edited"); | |
| 142 | expect(call(other, "list_versions", { id }).result.isError).toBe(true); | |
| 143 | expect(call(other, "restore_asset", { id, kind: "mls" }).result.isError).toBe(true); | |
| 144 | expect(asset(db, id).text).toBe("Changed once."); | |
| 145 | expect(call(me, "restore_asset", { id, kind: "mls" }).result.isError).toBeFalsy(); | |
| 146 | expect(asset(db, id).text).toBe(generated); | |
| 147 | }); | |
| 148 | ||
| 149 | test("the terminal lists the versions, puts one back with its number, clears approval, and refuses a reader", () => { | |
| 150 | const { db, id } = launch(); | |
| 151 | const generated = asset(db, id).text; | |
| 152 | editAsset(db, me, { id, kind: "mls", text: "Changed once." }); | |
| 153 | editAsset(db, me, { id, kind: "mls", text: "Changed twice." }); | |
| 154 | approveAsset(db, me, { id, kind: "mls", door: "tty", seen: asset(db, id).updated_at }); | |
| 155 | expect(asset(db, id).approval).not.toBeNull(); | |
| 156 | const tty = session(db, me); | |
| 157 | tty.press("\r"); | |
| 158 | tty.press("v"); | |
| 159 | const screen = tty.screen().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); | |
| 160 | expect(screen).toContain("1) "); | |
| 161 | expect(screen).toContain("Changed once."); | |
| 162 | expect(screen).toContain("2) "); | |
| 163 | tty.press("2"); // the older: the generated words | |
| 164 | expect(asset(db, id).text).toBe(generated); | |
| 165 | expect(asset(db, id).approval).toBeNull(); | |
| 166 | for (const who of [other, anon]) { | |
| 167 | const reader = session(db, who); | |
| 168 | reader.press("\r"); | |
| 169 | reader.press("v"); | |
| 170 | expect(reader.screen()).not.toContain("Earlier versions"); | |
| 171 | } | |
| 172 | }); | |
| 173 | ||
| 174 | const KEY = "a-key-only-the-forge-holds"; | |
| 175 | const serverDir = mkdtempSync(join(tmpdir(), "versions-")); | |
| 176 | const server = serve({ port: 0, dataDir: serverDir, identityKey: KEY }); | |
| 177 | afterAll(() => server.stop(true)); | |
| 178 | const base = `http://${server.hostname}:${server.port}`; | |
| 179 | const as = (userId: string) => { | |
| 180 | const identity = `${userId}|${userId}|user|${Date.now() + 600_000}`; | |
| 181 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 182 | }; | |
| 183 | const ownerHeaders = as("ver-owner"); | |
| 184 | ||
| 185 | test("over HTTP: the page form restores for its author, refuses another, and answers a stale page with the conflict", async () => { | |
| 186 | const launched = await fetch(`${base}/api/listings`, { method: "POST", headers: { ...ownerHeaders, "content-type": "application/json" }, body: JSON.stringify(SAMPLE) }); | |
| 187 | const id = ((await launched.json()) as { id: number }).id; | |
| 188 | const edit = (text: string) => | |
| 189 | fetch(`${base}/api/listings/${id}`, { method: "PATCH", headers: { ...ownerHeaders, "content-type": "application/json" }, body: JSON.stringify({ kind: "mls", text }) }); | |
| 190 | expect((await edit("Words one.")).status).toBe(200); | |
| 191 | expect((await edit("Words two.")).status).toBe(200); | |
| 192 | const page = await (await fetch(`${base}/listings/${id}`, { headers: ownerHeaders })).text(); | |
| 193 | expect(page).toContain("Earlier versions (2)"); | |
| 194 | const form = /<form method="post" action="([^"]*\/assets\/mls\/restore)">\s*<input type="hidden" name="version" value="(\d+)">\s*<input type="hidden" name="seen" value="([^"]+)">/.exec(page)!; | |
| 195 | const seen = form[3]!.replaceAll("&", "&"); | |
| 196 | const send = (headers: Record<string, string>, version: string, seenValue: string) => { | |
| 197 | const body = new FormData(); | |
| 198 | body.set("version", version); | |
| 199 | body.set("seen", seenValue); | |
| 200 | return fetch(base + form[1], { method: "POST", headers, body, redirect: "manual" }); | |
| 201 | }; | |
| 202 | // Another person: refused, nothing changes; a reader's page shows no list. | |
| 203 | const theirs = await send(as("ver-other"), form[2]!, seen); | |
| 204 | expect(theirs.status).toBe(303); | |
| 205 | const stillTwo = await (await fetch(`${base}/api/listings/${id}`, { headers: ownerHeaders })).json() as { assets: { kind: string; text: string }[] }; | |
| 206 | expect(stillTwo.assets.find((a) => a.kind === "mls")!.text).toBe("Words two."); | |
| 207 | const readerPage = await (await fetch(`${base}/listings/${id}`, { headers: as("ver-other") })).text(); | |
| 208 | expect(readerPage).not.toContain("Earlier versions"); | |
| 209 | // A stale page: the cookie notice carries the conflict, the words stay. | |
| 210 | const stale = await send(ownerHeaders, form[2]!, "2000-01-01T00:00:00.000Z"); | |
| 211 | expect(stale.status).toBe(303); | |
| 212 | expect(decodeURIComponent(stale.headers.get("set-cookie") ?? "")).toContain("changed since you read them"); | |
| 213 | const unchanged = await (await fetch(`${base}/api/listings/${id}`, { headers: ownerHeaders })).json() as { assets: { kind: string; text: string }[] }; | |
| 214 | expect(unchanged.assets.find((a) => a.kind === "mls")!.text).toBe("Words two."); | |
| 215 | // The real one puts the older words back. | |
| 216 | expect((await send(ownerHeaders, form[2]!, seen)).status).toBe(303); | |
| 217 | const back = await (await fetch(`${base}/api/listings/${id}`, { headers: ownerHeaders })).json() as { assets: { kind: string; text: string }[] }; | |
| 218 | expect(back.assets.find((a) => a.kind === "mls")!.text).toBe("Words one."); | |
| 219 | ||
| 220 | }); | |
| 221 | ||
| 222 | test("over HTTP: PATCH { kind, restore } restores for the author, 403 for another, 409 for a stale seen", async () => { | |
| 223 | const owner = as("ver-api-owner"); | |
| 224 | const json = { "content-type": "application/json" }; | |
| 225 | const launched = await fetch(`${base}/api/listings`, { method: "POST", headers: { ...owner, ...json }, body: JSON.stringify(SAMPLE) }); | |
| 226 | const id = ((await launched.json()) as { id: number }).id; | |
| 227 | const patch = (headers: Record<string, string>, body: object) => | |
| 228 | fetch(`${base}/api/listings/${id}`, { method: "PATCH", headers: { ...headers, ...json }, body: JSON.stringify(body) }); | |
| 229 | const before = ((await (await fetch(`${base}/api/listings/${id}`, { headers: owner })).json()) as { assets: { kind: string; text: string }[] }).assets.find((a) => a.kind === "mls")!.text; | |
| 230 | expect((await patch(owner, { kind: "mls", text: "Typed over." })).status).toBe(200); | |
| 231 | const version = newestVersion(id); | |
| 232 | expect((await patch(as("ver-api-other"), { kind: "mls", restore: version })).status).toBe(403); | |
| 233 | expect((await patch(owner, { kind: "mls", restore: version, seen: "2000-01-01T00:00:00.000Z" })).status).toBe(409); | |
| 234 | const done = await patch(owner, { kind: "mls", restore: version }); | |
| 235 | expect(done.status).toBe(200); | |
| 236 | expect(((await done.json()) as { assets: { kind: string; text: string }[] }).assets.find((a) => a.kind === "mls")!.text).toBe(before); | |
| 237 | }); | |
| 238 | ||
| 239 | /** The id of the newest kept version of an MLS description, read beside the server. */ | |
| 240 | function newestVersion(id: number): number { | |
| 241 | const reading = openDb(serverDir); | |
| 242 | try { | |
| 243 | return reading.query<{ id: number }, [number]>("SELECT max(id) AS id FROM asset_version WHERE listing_id = ?1 AND kind = 'mls'").get(id)!.id; | |
| 244 | } finally { | |
| 245 | reading.close(); | |
| 246 | } | |
| 247 | } |
src/walkthrough.test.ts +19 −4
| @@ -22,7 +22,7 @@ const server = serve({ | ||
| 22 | 22 | const base = `http://${server.hostname}:${server.port}`; |
| 23 | 23 | afterAll(() => server.stop(true)); |
| 24 | 24 | |
| 25 | const identity = `u1|realtor|user|${Date.now() + 600_000}`; | |
| 25 | const identity = `walk-realtor|realtor|user|${Date.now() + 600_000}`; | |
| 26 | 26 | const headers = { |
| 27 | 27 | "x-forge-identity": identity, |
| 28 | 28 | "x-forge-signature": sign(identity, KEY), |
| @@ -150,7 +150,22 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 150 | 150 | expect(textareaOf(afterApprove)).toContain("written by hand"); |
| 151 | 151 | expect(section).toContain("0 of 6 approved"); |
| 152 | 152 | |
| 153 | // 6. Drop the price: Edit facts, the new price on the flyer, approvals cleared. | |
| 153 | // 6. Put back the MLS words: Earlier versions, Restore, checked again, approval clear. | |
| 154 | expect(section).toContain("**Earlier versions (1)**"); | |
| 155 | expect(section).toContain("**Restore**"); | |
| 156 | expect(afterTone).toContain("Earlier versions (1)"); | |
| 157 | expect(afterTone).toContain("rewrite in Bold"); | |
| 158 | expect(afterTone).toMatch(control("Restore")); | |
| 159 | const restoreForm = /<form method="post" action="([^"]*\/assets\/mls\/restore)">([^]*?)<\/form>/.exec(afterTone)!; | |
| 160 | const put = new FormData(); | |
| 161 | put.set("version", /name="version" value="(\d+)"/.exec(restoreForm[2]!)![1]!); | |
| 162 | put.set("seen", /name="seen" value="([^"]+)"/.exec(restoreForm[2]!)![1]!.replaceAll("&", "&")); | |
| 163 | expect((await post(restoreForm[1]!, put)).status).toBe(303); | |
| 164 | const afterRestore = await (await get(mine)).text(); | |
| 165 | expect(textareaOf(afterRestore)).toBe("My own words about 9 Walkthrough Way, written by hand."); | |
| 166 | expect(afterRestore).toContain("0 of 6 approved"); | |
| 167 | ||
| 168 | // 7. Drop the price: Edit facts, the new price on the flyer, approvals cleared. | |
| 154 | 169 | expect(section).toContain("**Edit facts**"); |
| 155 | 170 | expect(afterTone).toMatch(control("Edit facts")); |
| 156 | 171 | expect(sampleHtml).not.toMatch(control("Edit facts")); |
| @@ -160,8 +175,8 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 160 | 175 | expect(facts).toMatch(control("Save and write all again")); |
| 161 | 176 | const approveAgain = new FormData(); |
| 162 | 177 | approveAgain.set("approved", "1"); |
| 163 | approveAgain.set("seen", /name="seen" value="([^"]+)"/.exec(afterTone)![1]!.replaceAll("&", "&")); | |
| 164 | approveAgain.set("text", textareaOf(afterTone)); | |
| 178 | approveAgain.set("seen", /name="seen" value="([^"]+)"/.exec(afterRestore)![1]!.replaceAll("&", "&")); | |
| 179 | approveAgain.set("text", textareaOf(afterRestore)); | |
| 165 | 180 | await post(`${mine}/assets/mls/approve`, approveAgain); |
| 166 | 181 | expect(await (await get(mine)).text()).toContain("1 of 6 approved"); |
| 167 | 182 | const drop = new FormData(); |