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

Merged opened this

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

Merged

mason/listing-launch:mason/39-put-back-an-asset-s-earlier-words-each-a → main 5 commits fork

  • Merged. mason/39-put-back-an-asset-s-earlier-words-each-a was merged into main as 4782e1c.

Review

Sign in to review this change.
16 files changed +607 −33
README.md +17 −6
@@ -28,20 +28,31 @@ to make a listing of your own.
2828 or Bold) and press **Rewrite all**. Every asset is written again in that
2929 tone, your edits are replaced, and the approval is cleared: it says
3030 "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
3243 launch form opens filled in with this listing's address, beds, baths,
3344 square feet, lot, year, price, features and brand. Lower the price and
3445 press **Save and write all again**: the new price is on the flyer, every
3546 asset is written again from the facts (your edits are replaced), and the
3647 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"
3849 pick a date, a start and an end time (Alder Lane's sample already has one),
3950 then press **Save and write all again**. The flyer gets a band under the
4051 price, the email a line above its body, and the MLS description and every
4152 post a closing line, such as "Open Sunday, March 7, 1–3 pm", all in the
4253 listing's tone. Clearing the three boxes takes it off again. The times are
4354 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
4556 the tone, add the price it sold at if you want it said, and press **Set
4657 status**. Two more assets appear, a status post under Social and a status
4758 email under Email ("Just sold — 9 Walkthrough Way"), written in the
@@ -62,15 +73,15 @@ to make a listing of your own.
6273 **Find one.** Above the list, **All · Active · Under contract · Sold ·
6374 Coming soon · Needs approval** are links with their counts, and the
6475 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
6677 the photo, price, facts, description and your name. Print it or save it
6778 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
6980 the listing: one text file named from the address, with the price and
7081 facts, then every asset under its name, each marked "Approved by … on …"
7182 or "DRAFT — not approved", with its Fair Housing findings beneath it. Hand
7283 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
7485 page link (`/homes/:id`) has a Copy button; the flyer prints it and the
7586 email's "See the home" button points to it. Open it on a phone: photos,
7687 price, status, open house, your contact details, and the MLS description
contract/openapi.json +6 −2
@@ -579,7 +579,7 @@
579579 }
580580 },
581581 "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.",
583583 "operationId": "patchApiListingsId",
584584 "parameters": [
585585 {
@@ -775,6 +775,10 @@
775775 }
776776 }
777777 },
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 },
778782 "approved": {
779783 "type": "boolean"
780784 },
@@ -789,7 +793,7 @@
789793 },
790794 "responses": {
791795 "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.",
793797 "content": {
794798 "application/json": {
795799 "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
1616 const base = `http://${server.hostname}:${server.port}`;
1717 afterAll(() => server.stop(true));
1818
19 const identity = `u1|realtor|admin|${Date.now() + 600_000}`;
19 const identity = `a11y-admin|realtor|admin|${Date.now() + 600_000}`;
2020 const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
2121 const page = async (path: string, signedIn = true): Promise<string> => {
2222 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 {
352352 * default, or only `only`. A kind the listing has no words for now (a status
353353 * post once it is active again) is removed.
354354 */
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 {
356361 const words = generate({ facts: listing, agent: agentOf(listing) });
357362 const put = db.query(
358363 `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
364369 for (const kind of only) {
365370 const body = words[kind];
366371 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 }
368376 }
369377 }
370378
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
371402 /** Insert a listing and its assets as one, for whoever is named. */
372403 export function insertListing(
373404 db: Database,
@@ -635,6 +666,7 @@ function edit(
635666 db: Database,
636667 who: Who,
637668 input: { id: number; kind: string; text: unknown },
669 why = "edited",
638670 ): Result<Launched> {
639671 const found = listingFor(db, who, "update", input.id, {
640672 signIn: "Sign in to edit this listing.",
@@ -646,6 +678,7 @@ function edit(
646678 const text = assetText(input.text);
647679 if (!text.ok) return fail("invalid", text.message);
648680 db.transaction(() => {
681 keepWords(db, found.value.id, input.kind, text.value, who, why);
649682 db.query(
650683 "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",
651684 ).run(text.value, found.value.id, input.kind);
@@ -693,11 +726,113 @@ function suggest(
693726 return fail("conflict", "These words changed since you read them. Read them again before taking the suggestion.");
694727 if (finding.dropIn === null)
695728 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.",
700784 });
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");
701836 }
702837
703838 /** 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(
823958 found.value.id,
824959 );
825960 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)}` });
827962 })();
828963 emit(db, "listing.changed", { id: found.value.id });
829964 return ok(launched(db, found.value.id)!);
@@ -889,7 +1024,7 @@ function changeFacts(
8891024 const was = v.price < now.price ? (now.was_price ?? now.price) : now.was_price !== null && v.price < now.was_price ? now.was_price : null;
8901025 db.query("UPDATE listing SET was_price = ?1 WHERE id = ?2").run(was, now.id);
8911026 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" });
8931028 })();
8941029 emit(db, "listing.changed", { id: now.id });
8951030 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", () => {
138138 const dir = tmp("soon-mig");
139139 const db = new Database(join(dir, "app.db"));
140140 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")));
144143 try {
145144 migrate(db);
146145 } finally {
src/contract.test.ts +38
@@ -20,6 +20,8 @@ import {
2020 getNote,
2121 insertListing,
2222 launchListing,
23 listVersions,
24 restoreAsset,
2325 updatePhotos,
2426 listAudit,
2527 listListings,
@@ -274,3 +276,39 @@ test("a property page answers as the listing's read cell says once approved, and
274276 }
275277 expect(asked).toBeGreaterThan(0);
276278 });
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[] = [
144144 SELECT listing_id, kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via FROM asset_old;
145145 DROP TABLE asset_old;
146146 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)`,
147160 ];
148161
149162 export function openDb(dir = dataDir): Database {
src/mcp.ts +21
@@ -21,6 +21,8 @@ import {
2121 launchListing,
2222 listListings,
2323 listNotes,
24 listVersions,
25 restoreAsset,
2426 retoneListing,
2527 setStatus,
2628 updateFacts,
@@ -202,6 +204,25 @@ export const tools: readonly Tool[] = [
202204 text: args.text,
203205 }),
204206 },
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 },
205226 {
206227 name: "approve_asset",
207228 description:
src/pages/listing.ts +33 −2
@@ -4,6 +4,7 @@
44 * flyer, which is also a page of its own for printing (`flyerPage`).
55 */
66
7 import type { Version } from "../actions.ts";
78 import type { Check, Finding } from "../fairhousing.ts";
89 import {
910 factItems,
@@ -134,7 +135,34 @@ export function approval(l: Launched, a: Asset): string {
134135 <button class="button" data-approve aria-label="${words}: the ${escape(a.label)}" formaction="${to}" name="approved" value="1">${words}</button>`;
135136 }
136137
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 {
138166 const rows = Math.min(16, Math.max(4, Math.ceil(a.text.length / 70) + a.text.split("\n").length));
139167 return `<section class="asset" id="${a.kind}" aria-labelledby="${a.kind}-title">
140168 <div class="asset-edit">
@@ -148,6 +176,7 @@ function editor(l: Launched, a: Asset, editable: boolean): string {
148176 <small class="muted" data-count>${isPost(a.kind) ? escape(lengthLine(a.text)) : `${a.text.length} characters`}</small>
149177 </div>
150178 </form>
179 ${versionList(l, a, versions, editable)}
151180 </div>
152181 <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>
153182 </section>`;
@@ -174,6 +203,8 @@ export function listingPage(input: {
174203 listing: Launched;
175204 origin?: string;
176205 editable: boolean;
206 /** Each asset's earlier versions by kind, newest first. */
207 versions?: Readonly<Record<string, readonly Version[]>>;
177208 tab?: string | null;
178209 notice?: string | undefined;
179210 }): string {
@@ -229,7 +260,7 @@ export function listingPage(input: {
229260 ${t.id === "email" ? `<p><a class="button button-quiet" href="/listings/${l.id}/email">Preview email</a></p>` : ""}
230261 ${l.assets
231262 .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] ?? []))
233264 .join("")}
234265 </div>`,
235266 ).join("")}
src/routes.ts +22 −1
@@ -31,6 +31,8 @@ import {
3131 listAudit,
3232 listListings,
3333 listNotes,
34 listVersions,
35 restoreAsset,
3436 retoneListing,
3537 setStatus,
3638 updateFacts,
@@ -308,6 +310,7 @@ const LISTING_CHANGE: Schema = {
308310 remove: { type: "array", items: { type: "string" } },
309311 },
310312 },
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." },
311314 approved: { type: "boolean" },
312315 seen: {
313316 type: "string",
@@ -468,12 +471,15 @@ export const routes: readonly Route[] = [
468471 const { notice, clear } = readNotice(c.request);
469472 const found = getListing(c.db, c.who, { id: Number(c.params.id) });
470473 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 : {};
471476 return html(
472477 listingPage({
473478 who: c.who,
474479 listing: found.value,
475480 origin: publicOrigin,
476481 editable: canEditListing(c.who, found.value),
482 versions,
477483 tab: c.url.searchParams.get("tab"),
478484 notice,
479485 }),
@@ -656,6 +662,19 @@ export const routes: readonly Route[] = [
656662 return back(done.ok ? (approving ? "Approved." : "Approval withdrawn.") : done.message, to);
657663 },
658664 },
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 },
659678 {
660679 method: "POST",
661680 path: "/listings/:id/tone",
@@ -869,12 +888,14 @@ export const routes: readonly Route[] = [
869888 ? setStatus(c.db, c.who, { id, status: body.status, sold_price: body.sold_price })
870889 : body.tone !== undefined && body.kind === undefined
871890 ? 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 })
872893 : body.approved !== undefined && body.text === undefined
873894 ? approveAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), approved: body.approved, seen: body.seen, door: "api" })
874895 : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }),
875896 );
876897 },
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 },
878899 },
879900 {
880901 method: "DELETE",
src/sample.test.ts +3 −3
@@ -5,7 +5,7 @@ import { mkdtempSync } from "node:fs";
55 import { tmpdir } from "node:os";
66 import { join } from "node:path";
77 import { getListing, launchListing } from "./actions.ts";
8 import { migrate, openDb } from "./db.ts";
8 import { migrate, migrations, openDb } from "./db.ts";
99 import { kit } from "./kit.ts";
1010 import { draftBand } from "./pages/listing.ts";
1111 import { check } from "./fairhousing.ts";
@@ -79,8 +79,8 @@ test("the migration approves an old sample once, and leaves edited or approved r
7979 db.run("UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL");
8080 db.run(`UPDATE asset SET edited = 1 WHERE kind = 'flyer' AND listing_id = ${id}`);
8181 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}`);
8484 migrate(db);
8585 const rows = () =>
8686 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 () =>
151151 "launch_listing",
152152 "edit_asset",
153153 "use_suggestion",
154 "list_versions",
155 "restore_asset",
154156 "approve_asset",
155157 "retone_listing",
156158 "set_status",
src/tty.ts +35 −3
@@ -6,7 +6,8 @@
66 * - listings: enter opens one — every asset with its Fair Housing findings;
77 * j/k move between the assets, e edits the one under the cursor (the
88 * 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
1011 * removes a listing; p previews the email as the client will read it; k shows
1112 * the launch kit, the file the page offers to download.
1213 * - notes: a adds, d removes.
@@ -30,6 +31,8 @@ import {
3031 getListing,
3132 listListings,
3233 listNotes,
34 listVersions,
35 restoreAsset,
3336 retoneListing,
3437 setStatus,
3538 updateFacts,
@@ -92,6 +95,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
9295 let previewing = false;
9396 /** The launch kit, as the file would read. */
9497 let kitting = false;
98 /** The asset whose earlier versions are listed, 1 to 5 puts one back. */
99 let versioning: Kind | undefined;
95100 let cursor = 0;
96101 /** What the one-line input is for: a new note, or an asset's new words. */
97102 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 }
121126 if (!found.ok) return [found.message];
122127 const l = found.value;
123128 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 }
124137 if (previewing) {
125138 const { subject, paragraphs } = emailParts(l.assets.find((a) => a.kind === "email")?.text ?? "", l.agent_name);
126139 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 }
160173 lines.push(...readLines());
161174 const found = open();
162175 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";
163177 else help =
164178 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"
166180 : "j/↓↑ asset · p preview email · k launch kit · esc back · q quit";
167181 } else if (view === "listings") {
168182 const rows = listings();
@@ -269,6 +283,21 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
269283 kitting = true;
270284 return undefined;
271285 }
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 }
272301 if (key === "\x1b") {
273302 if (previewing || kitting) previewing = kitting = false;
274303 else {
@@ -280,7 +309,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
280309 if (previewing || kitting) return undefined;
281310 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
282311 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")) {
284313 if (!canEditListing(me, found.value))
285314 said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it.";
286315 else if (key === "u") {
@@ -308,6 +337,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
308337 : done.message;
309338 }
310339 }
340 } else if (key === "v") {
341 const asset = assets[cursor];
342 if (asset !== undefined) versioning = asset.kind;
311343 } else if (key === "s") {
312344 typing = { for: "status" };
313345 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("&amp;", "&");
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({
2222 const base = `http://${server.hostname}:${server.port}`;
2323 afterAll(() => server.stop(true));
2424
25 const identity = `u1|realtor|user|${Date.now() + 600_000}`;
25 const identity = `walk-realtor|realtor|user|${Date.now() + 600_000}`;
2626 const headers = {
2727 "x-forge-identity": identity,
2828 "x-forge-signature": sign(identity, KEY),
@@ -150,7 +150,22 @@ test("every step of the section works, with the labels it names", async () => {
150150 expect(textareaOf(afterApprove)).toContain("written by hand");
151151 expect(section).toContain("0 of 6 approved");
152152
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("&amp;", "&"));
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.
154169 expect(section).toContain("**Edit facts**");
155170 expect(afterTone).toMatch(control("Edit facts"));
156171 expect(sampleHtml).not.toMatch(control("Edit facts"));
@@ -160,8 +175,8 @@ test("every step of the section works, with the labels it names", async () => {
160175 expect(facts).toMatch(control("Save and write all again"));
161176 const approveAgain = new FormData();
162177 approveAgain.set("approved", "1");
163 approveAgain.set("seen", /name="seen" value="([^"]+)"/.exec(afterTone)![1]!.replaceAll("&amp;", "&"));
164 approveAgain.set("text", textareaOf(afterTone));
178 approveAgain.set("seen", /name="seen" value="([^"]+)"/.exec(afterRestore)![1]!.replaceAll("&amp;", "&"));
179 approveAgain.set("text", textareaOf(afterRestore));
165180 await post(`${mine}/assets/mls/approve`, approveAgain);
166181 expect(await (await get(mine)).text()).toContain("1 of 6 approved");
167182 const drop = new FormData();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs