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

Change a launched listing's price and facts: every asset is written again and its approval cleared #28

Merged opened this

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

Merged

mason/listing-launch:mason/26-change-a-launched-listing-s-price-and-fa → main 2 commits fork

  • Merged. mason/26-change-a-launched-listing-s-price-and-fa was merged into main as b763bcb.

Review

Sign in to review this change.
11 files changed +420 −18
README.md +11 −3
@@ -25,7 +25,13 @@ to make a listing of your own.
2525 or Bold) and press **Rewrite all**. Every asset is written again in that
2626 tone, your edits are replaced, and the approval is cleared: it says
2727 "0 of 6 approved" again, because you have not read the new words yet.
28 6. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
28 6. **Drop the price.** Press **Edit facts** at the top of your listing. The
29 launch form opens filled in with this listing's address, beds, baths,
30 square feet, lot, year, price, features and brand. Lower the price and
31 press **Save and write all again**: the new price is on the flyer, every
32 asset is written again from the facts (your edits are replaced), and the
33 approval is cleared, so it says "0 of 6 approved". The photos stay.
34 7. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
2935 the photo, price, facts, description and your name. Print it or save it
3036 as a PDF.
3137
@@ -143,7 +149,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
143149
144150 | file | what it is |
145151 | ---------------------------------- | -------------------------------------------------------------- |
146 | `src/actions.ts` | listings (launch, edit an asset, retone, remove), notes, the audit trail |
152 | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail |
147153 | `src/listing.ts` | what a listing is, and the check at the boundary that one arrived |
148154 | `src/generate.ts` | `generate(listing)`: the five assets from the facts, in three tones |
149155 | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite |
@@ -223,11 +229,13 @@ curl https://<app>/api/listings
223229 curl https://<app>/api/listings/1
224230 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"kind":"mls","text":"…"}'
225231 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"tone":"bold"}'
232 # change facts (any launch field); every asset is written again, approvals cleared
233 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"price":649000}}'
226234 curl -X DELETE https://<app>/api/listings/1
227235 ```
228236
229237 As an MCP server, for Claude Code or Claude Desktop — `list_listings`,
230 `get_listing`, `launch_listing`, `edit_asset`, `retone_listing`,
238 `get_listing`, `launch_listing`, `edit_asset`, `retone_listing`, `update_facts`,
231239 `delete_listing`,
232240 `check_fair_housing`, and the notes' `list_notes`, `add_note`,
233241 `delete_note`:
contract/openapi.json +92 −3
@@ -490,7 +490,7 @@
490490 }
491491 },
492492 "patch": {
493 "summary": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), or write every asset again in a tone.",
493 "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, or change the facts ({ facts }), which writes every asset again and clears every approval.",
494494 "operationId": "patchApiListingsId",
495495 "parameters": [
496496 {
@@ -508,7 +508,7 @@
508508 "application/json": {
509509 "schema": {
510510 "type": "object",
511 "description": "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), or every asset written again in a tone ({ tone }), which replaces edits.",
511 "description": "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), every asset written again in a tone ({ tone }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.",
512512 "properties": {
513513 "kind": {
514514 "enum": [
@@ -532,6 +532,95 @@
532532 "bold"
533533 ]
534534 },
535 "facts": {
536 "type": "object",
537 "description": "Launch fields to change; the ones left out keep their value.",
538 "properties": {
539 "address": {
540 "type": "string",
541 "minLength": 5,
542 "maxLength": 120
543 },
544 "beds": {
545 "type": "integer",
546 "minimum": 0,
547 "maximum": 50
548 },
549 "baths": {
550 "type": "number",
551 "minimum": 0,
552 "maximum": 50,
553 "multipleOf": 0.25
554 },
555 "sqft": {
556 "type": "integer",
557 "minimum": 100,
558 "maximum": 100000
559 },
560 "lot": {
561 "type": "string",
562 "minLength": 1,
563 "maxLength": 40
564 },
565 "year": {
566 "type": "integer",
567 "minimum": 1700
568 },
569 "price": {
570 "type": "integer",
571 "minimum": 1000
572 },
573 "features": {
574 "type": "array",
575 "items": {
576 "type": "string",
577 "maxLength": 140
578 },
579 "minItems": 5,
580 "maxItems": 10
581 },
582 "tone": {
583 "enum": [
584 "classic",
585 "warm",
586 "bold"
587 ]
588 },
589 "palette": {
590 "enum": [
591 "navy",
592 "forest",
593 "burgundy",
594 "charcoal",
595 "teal"
596 ]
597 },
598 "agent_name": {
599 "type": "string",
600 "minLength": 2,
601 "maxLength": 80
602 },
603 "brokerage": {
604 "type": "string",
605 "minLength": 2,
606 "maxLength": 100
607 },
608 "license": {
609 "type": "string",
610 "minLength": 2,
611 "maxLength": 40
612 },
613 "agent_phone": {
614 "type": "string",
615 "maxLength": 30
616 },
617 "agent_email": {
618 "type": "string",
619 "format": "email",
620 "maxLength": 120
621 }
622 }
623 },
535624 "approved": {
536625 "type": "boolean"
537626 },
@@ -546,7 +635,7 @@
546635 },
547636 "responses": {
548637 "200": {
549 "description": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), or write every asset again in a tone.",
638 "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, or change the facts ({ facts }), which writes every asset again and clears every approval.",
550639 "content": {
551640 "application/json": {
552641 "schema": {
src/actions.ts +61
@@ -753,6 +753,67 @@ function retone(
753753 return ok(launched(db, found.value.id)!);
754754 }
755755
756 /**
757 * Change a launched listing's facts (and brand) and write every asset again
758 * in its tone, as one transaction: hand edits are replaced and every approval
759 * cleared, as with a retone. Fields left out keep their current value; the
760 * photos and the headshot are not touched.
761 */
762 export const updateFacts = (
763 db: Database,
764 who: Who,
765 input: { id: number; fields: Readonly<Record<string, unknown>> },
766 ): Result<Launched> =>
767 audited(db, who, "listing", "update", input.id, changeFacts(db, who, input));
768
769 function changeFacts(
770 db: Database,
771 who: Who,
772 input: { id: number; fields: Readonly<Record<string, unknown>> },
773 ): Result<Launched> {
774 const found = listingFor(db, who, "update", input.id, {
775 signIn: "Sign in to edit this listing.",
776 no: "Only the listing's agent or an admin can edit it.",
777 });
778 if (!found.ok) return found;
779 const now = found.value;
780 const valid = validate({
781 address: now.address,
782 beds: now.beds,
783 baths: now.baths,
784 sqft: now.sqft,
785 lot: now.lot,
786 year: now.year,
787 price: now.price,
788 features: now.features,
789 tone: now.tone,
790 palette: now.palette,
791 agent_name: now.agent_name,
792 brokerage: now.brokerage,
793 license: now.license,
794 agent_phone: now.agent_phone,
795 agent_email: now.agent_email,
796 ...input.fields,
797 });
798 if (!valid.ok) return fail("invalid", valid.message);
799 const v = valid.value;
800 db.transaction(() => {
801 db.query(
802 `UPDATE listing SET address = ?1, beds = ?2, baths = ?3, sqft = ?4, lot = ?5, year = ?6, price = ?7,
803 features = ?8, tone = ?9, palette = ?10, agent_name = ?11, brokerage = ?12, license = ?13,
804 agent_phone = ?14, agent_email = ?15 WHERE id = ?16`,
805 ).run(
806 v.address, v.beds, v.baths, v.sqft, v.lot, v.year, v.price,
807 JSON.stringify(v.features), v.tone, v.palette, v.agent_name, v.brokerage,
808 v.license, v.agent_phone, v.agent_email, now.id,
809 );
810 touch(db, now.id);
811 writeAssets(db, { ...now, ...v });
812 })();
813 emit(db, "listing.changed", { id: now.id });
814 return ok(launched(db, now.id)!);
815 }
816
756817 export const deleteListing = (
757818 db: Database,
758819 who: Who,
src/listings.test.ts +119 −1
@@ -8,10 +8,11 @@ import { afterAll, expect, test } from "bun:test";
88 import { existsSync, mkdtempSync, readdirSync } from "node:fs";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { approveAsset, editAsset, getListing, insertListing, launchListing, retoneListing } from "./actions.ts";
11 import { approveAsset, editAsset, getListing, insertListing, launchListing, retoneListing, updateFacts } from "./actions.ts";
1212 import { openDb } from "./db.ts";
1313 import { rpc } from "./mcp.ts";
1414 import { validate } from "./listing.ts";
15 import { money } from "./generate.ts";
1516 import { sniff } from "./photos.ts";
1617 import { SAMPLE, seed } from "./sample.ts";
1718 import { serve } from "./server.ts";
@@ -619,3 +620,120 @@ test("one tap takes the suggested wording: exactly those words change, and it sa
619620 expect(audit).toContain("forbidden");
620621 expect(audit).toContain("conflict");
621622 });
623
624 test("changing the facts writes every asset again, clears every approval, and is the agent's alone", async () => {
625 const db = openDb(mkdtempSync(join(tmpdir(), "app-facts-")));
626 const me: Who = { role: "user", id: "u1", name: "me" };
627 const other: Who = { role: "user", id: "u2", name: "other" };
628 const made = launchListing(db, me, { fields: { ...SAMPLE } });
629 if (!made.ok) throw new Error(made.message);
630 const id = made.value.id;
631 const text = (l: { assets: readonly { kind: string; text: string }[] }, kind: string) =>
632 l.assets.find((a) => a.kind === kind)!.text;
633 const old = money(SAMPLE.price);
634 const cut = money(SAMPLE.price - 25_000);
635 for (const kind of ["mls", "social_short", "email"]) expect(text(made.value, kind)).toContain(old);
636 const seen = (kind: string) => {
637 const l = getListing(db, me, { id });
638 return l.ok ? l.value.assets.find((a) => a.kind === kind)!.updated_at : "";
639 };
640 editAsset(db, me, { id, kind: "social_short", text: "My own words." });
641 approveAsset(db, me, { id, kind: "mls", door: "page", seen: seen("mls") });
642 expect(getListing(db, me, { id })).toMatchObject({ ok: true });
643
644 const done = updateFacts(db, me, { id, fields: { price: SAMPLE.price - 25_000 } });
645 if (!done.ok) throw new Error(done.message);
646 expect(done.value.price).toBe(SAMPLE.price - 25_000);
647 for (const kind of ["mls", "social_short", "email"]) {
648 expect(text(done.value, kind)).toContain(cut);
649 expect(text(done.value, kind)).not.toContain(old);
650 }
651 expect(done.value.assets.filter((a) => a.approval !== null).length).toBe(0);
652 expect(done.value.assets.some((a) => a.edited)).toBe(false);
653 expect(text(done.value, "social_short")).not.toBe("My own words.");
654 // Everything else, the photos included, stays.
655 expect(done.value.address).toBe(SAMPLE.address);
656 expect(done.value.photos).toEqual(made.value.photos);
657
658 // A bad price is the form's message, and nothing changes.
659 const before = JSON.stringify(getListing(db, me, { id }));
660 const bad = updateFacts(db, me, { id, fields: { price: "cheap" } });
661 expect(bad).toMatchObject({ ok: false, error: "invalid", message: "Price is whole dollars, from $1,000." });
662 expect(JSON.stringify(getListing(db, me, { id }))).toBe(before);
663
664 // Another user and a guest are refused, as for a tone change; so is the sample.
665 expect(updateFacts(db, other, { id, fields: { price: 500_000 } })).toMatchObject({ ok: false, error: "forbidden" });
666 expect(updateFacts(db, { role: "anon" }, { id, fields: { price: 500_000 } })).toMatchObject({ ok: false, error: "signin_required" });
667 expect(getListing(db, me, { id })).toMatchObject({ ok: true, value: { price: SAMPLE.price - 25_000 } });
668 seed(db);
669 const sample = db.query<{ id: number }, []>("SELECT id FROM listing WHERE author_id = 'sample' ORDER BY id LIMIT 1").get()!.id;
670 expect(updateFacts(db, me, { id: sample, fields: { price: 500_000 } })).toMatchObject({ ok: false, error: "forbidden" });
671
672 const trail = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE resource = 'listing' AND verb = 'update' ORDER BY id DESC LIMIT 5").all().map((r) => r.outcome);
673 expect(trail).toContain("invalid");
674 expect(trail).toContain("forbidden");
675 expect(trail).toContain("ok");
676 });
677
678 test("Edit facts is on the page for the agent only, and the form, the API and MCP all reach the action", async () => {
679 // Their own people: the write limit is per person, and the other tests spend alice's and bob's.
680 const alice = as("facts-agent");
681 const bob = as("facts-other");
682 const made = await launch(alice);
683 const id = Number(made.to.split("/").pop());
684 const mine = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text();
685 expect(mine).toMatch(/<a[^>]*href="\/listings\/\d+\/facts"[^>]*>Edit facts</);
686 const theirs = await (await fetch(`${base}/listings/${id}`, { headers: bob })).text();
687 expect(theirs).not.toContain("Edit facts");
688 expect((await fetch(`${base}/listings/${id}/facts`, { headers: bob })).status).toBe(403);
689 expect((await fetch(`${base}/listings/${id}/facts`)).status).toBe(401);
690
691 // The form is the launch form, filled in with this listing's facts; photos are not part of it.
692 const open = await (await fetch(`${base}/listings/${id}/facts`, { headers: alice })).text();
693 expect(open).toContain('value="18 Quarry Road, Bend, OR 97701"');
694 expect(open).toContain(`value="${SAMPLE.price}"`);
695 expect(open).toContain("your edits are replaced");
696 expect(open).not.toContain('type="file"');
697
698 // The page form.
699 const post = (who: Record<string, string>, form: FormData) =>
700 fetch(`${base}/listings/${id}/facts`, { method: "POST", headers: who, body: form, redirect: "manual" });
701 const form = new FormData();
702 for (const [k, v] of Object.entries(SAMPLE)) form.set(k, Array.isArray(v) ? v.join("\n") : String(v));
703 form.set("address", "18 Quarry Road, Bend, OR 97701");
704 form.set("price", "611000");
705 expect((await post(bob, form)).status).toBe(303);
706 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).not.toContain("$611,000");
707 expect((await post(alice, form)).headers.get("location")).toBe(`/listings/${id}`);
708 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("$611,000");
709 expect(await (await fetch(`${base}/listings/${id}/flyer`, { headers: alice })).text()).toContain("$611,000");
710 expect(await (await fetch(`${base}/listings/${id}/email`, { headers: alice })).text()).toContain("$611,000");
711 form.set("price", "free");
712 expect((await post(alice, form)).headers.get("location")).toBe(`/listings/${id}/facts`);
713 const evil = await fetch(`${base}/listings/${id}/facts`, { method: "POST", headers: { ...alice, origin: "https://evil.example" }, body: form, redirect: "manual" });
714 expect(evil.status).toBe(403);
715
716 // The API.
717 const patch = (who: Record<string, string>, body: unknown) =>
718 fetch(`${base}/api/listings/${id}`, { method: "PATCH", headers: { ...who, "content-type": "application/json" }, body: JSON.stringify(body) });
719 expect((await patch(bob, { facts: { price: 1 } })).status).toBe(403);
720 expect((await patch(alice, { facts: { price: 5 } })).status).toBe(422);
721 const changed = await patch(alice, { facts: { price: 600000 } });
722 expect(changed.status).toBe(200);
723 const body = (await changed.json()) as { price: number; assets: { text: string; approval: unknown }[] };
724 expect(body.price).toBe(600000);
725 expect(body.assets.every((a) => a.approval === null)).toBe(true);
726 expect(body.assets.some((a) => a.text.includes("$600,000"))).toBe(true);
727
728 // MCP.
729 const db = openDb(mkdtempSync(join(tmpdir(), "app-facts-mcp-")));
730 const me: Who = { role: "user", id: "u1", name: "me" };
731 const l = launchListing(db, me, { fields: { ...SAMPLE } });
732 if (!l.ok) throw new Error(l.message);
733 const call = (who: Who, args: unknown) =>
734 rpc(db, who, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "update_facts", arguments: args } }).body as { result: { isError?: boolean; content: { text: string }[] } };
735 expect(call({ role: "anon" }, { id: l.value.id, facts: { price: 400000 } }).result.isError).toBe(true);
736 const ok = call(me, { id: l.value.id, facts: { price: 400000 } });
737 expect(ok.result.isError).toBeUndefined();
738 expect(ok.result.content[0]!.text).toContain("$400,000");
739 });
src/mcp.ts +26
@@ -20,6 +20,7 @@ import {
2020 listListings,
2121 listNotes,
2222 retoneListing,
23 updateFacts,
2324 type Result,
2425 } from "./actions.ts";
2526 import { KINDS, TONES } from "./generate.ts";
@@ -204,6 +205,31 @@ export const tools: readonly Tool[] = [
204205 run: (db, who, args) =>
205206 retoneListing(db, who, { id: Number(args.id), tone: args.tone }),
206207 },
208 {
209 name: "update_facts",
210 description:
211 "Change a listing's facts (price, square feet, features, brand...): pass only the fields that change. Every asset is written again in its tone, replacing edits and clearing every approval. Only the listing's agent or an admin can.",
212 inputSchema: {
213 type: "object",
214 properties: {
215 id: { type: "integer" },
216 facts: {
217 type: "object",
218 description:
219 "Any of address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email.",
220 },
221 },
222 required: ["id", "facts"],
223 },
224 run: (db, who, args) =>
225 updateFacts(db, who, {
226 id: Number(args.id),
227 fields:
228 typeof args.facts === "object" && args.facts !== null && !Array.isArray(args.facts)
229 ? (args.facts as Record<string, unknown>)
230 : {},
231 }),
232 },
207233 {
208234 name: "delete_listing",
209235 description:
src/pages/facts.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * `/listings/:id/facts`: the launch form again, filled in with a launched
3 * listing's facts, to change them. Saving writes every asset again.
4 */
5
6 import { money } from "../generate.ts";
7 import { escape } from "../http.ts";
8 import type { Listing } from "../listing.ts";
9 import type { Who } from "../who.ts";
10 import { draftOf, form } from "./home.ts";
11 import { layout, toast } from "./layout.ts";
12
13 export function factsPage(input: { who: Who; listing: Listing; notice?: string | undefined }): string {
14 const { who, listing: l, notice } = input;
15 return layout(
16 `<section class="compose" aria-labelledby="compose-title">
17 <p><a href="/listings/${l.id}">← ${escape(l.address)}</a></p>
18 <h2 id="compose-title">Edit facts</h2>
19 <p class="muted lede">${escape(l.address)} is ${money(l.price)} now. Saving writes all six assets again from these facts, so your hand edits are replaced and every approval is cleared. Photos stay as they are.</p>
20 ${form(who, draftOf(l, false), { id: l.id })}
21 </section>
22 ${toast(notice)}`,
23 who,
24 { path: `/listings/${l.id}/facts`, heading: "Edit facts" },
25 );
26 }
src/pages/home.ts +18 −7
@@ -91,14 +91,27 @@ function field(
9191 <input class="input" name="${name}" value="${v(draft, name)}" placeholder="${ph(placeholder)}" ${attrs}></label>`;
9292 }
9393
94 function form(who: Who, draft: Draft): string {
94 /** Set to change a launched listing's facts instead of launching a new one: no photos, a different action. */
95 export interface Editing {
96 readonly id: number;
97 }
98
99 export function form(who: Who, draft: Draft, editing?: Editing): string {
95100 const tone = draft.tone ?? "classic";
96101 const palette = draft.palette ?? "navy";
97102 const launch =
98 who.role === "anon"
103 editing !== undefined
104 ? `<button class="button button-launch">Save and write all again</button>
105 <a class="button button-quiet" href="/listings/${editing.id}">Cancel</a>
106 <span class="muted">Every asset is written again from these facts: your edits are replaced and every approval is cleared.</span>`
107 : who.role === "anon"
99108 ? `<a class="button" href="/__forge/signin?to=/">Sign in to launch</a>`
100109 : `<button class="button button-launch">Launch</button>`;
101 return `<form class="card form" method="post" action="/listings" enctype="multipart/form-data">
110 const photos = `<label class="field"><span>Photos <small class="muted">up to ${PHOTOS_MAX}, JPEG, PNG or WebP, 24 MB in all; the first is the hero</small></span>
111 <input class="input file" type="file" name="photos" multiple accept="image/jpeg,image/png,image/webp"></label>`;
112 const headshot = `<label class="field"><span>Headshot <small class="muted">optional; your last one carries over</small></span>
113 <input class="input file" type="file" name="headshot" accept="image/jpeg,image/png,image/webp"></label>`;
114 return `<form class="card form" method="post" ${editing === undefined ? 'action="/listings" enctype="multipart/form-data"' : `action="/listings/${editing.id}/facts"`}>
102115 <fieldset>
103116 <legend>The property</legend>
104117 ${field(draft, "address", "Address", 'required minlength="5" maxlength="120" autocomplete="street-address"', SAMPLE.address)}
@@ -112,8 +125,7 @@ function form(who: Who, draft: Draft): string {
112125 </div>
113126 <label class="field"><span>Features <small class="muted">${FEATURES_MIN} to ${FEATURES_MAX}, one per line</small></span>
114127 <textarea class="input textarea" name="features" rows="7" required placeholder="${ph(SAMPLE.features.slice(0, 5).join("\n"))}">${v(draft, "features")}</textarea></label>
115 <label class="field"><span>Photos <small class="muted">up to ${PHOTOS_MAX}, JPEG, PNG or WebP, 24 MB in all; the first is the hero</small></span>
116 <input class="input file" type="file" name="photos" multiple accept="image/jpeg,image/png,image/webp"></label>
128 ${editing === undefined ? photos : ""}
117129 </fieldset>
118130 <fieldset>
119131 <legend>Tone</legend>
@@ -130,8 +142,7 @@ function form(who: Who, draft: Draft): string {
130142 ${field(draft, "license", "License number", 'required maxlength="40"', SAMPLE.license)}
131143 ${field(draft, "agent_phone", "Phone", 'required type="tel" maxlength="30" autocomplete="tel"', SAMPLE.agent_phone)}
132144 ${field(draft, "agent_email", "Email", 'required type="email" maxlength="120" autocomplete="email"', SAMPLE.agent_email)}
133 <label class="field"><span>Headshot <small class="muted">optional; your last one carries over</small></span>
134 <input class="input file" type="file" name="headshot" accept="image/jpeg,image/png,image/webp"></label>
145 ${editing === undefined ? headshot : ""}
135146 </div>
136147 <div class="swatches" role="radiogroup" aria-label="Brand colour">${PALETTES.map(
137148 (p) => `<label class="swatch palette-${p}"><input type="radio" name="palette" value="${p}"${p === palette ? " checked" : ""}><span class="brand-fill" aria-hidden="true"></span>${p}</label>`,
src/pages/listing.ts +1
@@ -169,6 +169,7 @@ export function listingPage(input: {
169169 ${approvalPill(approvedCount(l), l.assets.length)}
170170 ${n === 0 ? `<span class="fh-pill fh-ok">Fair Housing: clear</span>` : `<span class="fh-pill fh-warn">Fair Housing: ${n} to review</span>`}
171171 ${tone}
172 ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""}
172173 <a class="button" href="/listings/${l.id}/flyer">Print flyer</a>
173174 <a class="button button-quiet" href="/?from=${l.id}">Start a new one from this</a>
174175 </div>
src/routes.ts +37 −3
@@ -31,6 +31,7 @@ import {
3131 listListings,
3232 listNotes,
3333 retoneListing,
34 updateFacts,
3435 updateNote,
3536 useSuggestion,
3637 type Failure,
@@ -58,6 +59,7 @@ import { events } from "./live.ts";
5859 import { rpc } from "./mcp.ts";
5960 import { draftOf, home, type Draft } from "./pages/home.ts";
6061 import { layout } from "./pages/layout.ts";
62 import { factsPage } from "./pages/facts.ts";
6163 import { emailPage } from "./pages/email.ts";
6264 import { approval, fairHousing, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
6365 import { notesPage } from "./pages/notes.ts";
@@ -258,11 +260,16 @@ const LISTING_BODY: Schema = {
258260 const LISTING_CHANGE: Schema = {
259261 type: "object",
260262 description:
261 "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), or every asset written again in a tone ({ tone }), which replaces edits.",
263 "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), every asset written again in a tone ({ tone }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.",
262264 properties: {
263265 kind: { enum: [...KINDS] },
264266 text: { type: "string", minLength: 1, maxLength: ASSET_MAX },
265267 tone: { enum: [...TONES] },
268 facts: {
269 type: "object",
270 description: "Launch fields to change; the ones left out keep their value.",
271 properties: FACTS,
272 },
266273 approved: { type: "boolean" },
267274 seen: {
268275 type: "string",
@@ -423,6 +430,31 @@ export const routes: readonly Route[] = [
423430 );
424431 },
425432 },
433 {
434 method: "GET",
435 path: "/listings/:id/facts",
436 handle: (c) => {
437 const { notice, clear } = readNotice(c.request);
438 const found = getListing(c.db, c.who, { id: Number(c.params.id) });
439 if (!found.ok) return page(c.who, found, () => "");
440 if (!canEditListing(c.who, found.value))
441 return page(c.who, { ok: false, error: c.who.role === "anon" ? "signin_required" : "forbidden", message: "Only the listing's agent or an admin can edit it." }, () => "");
442 return html(factsPage({ who: c.who, listing: found.value, notice }), 200, clear);
443 },
444 },
445 {
446 method: "POST",
447 path: "/listings/:id/facts",
448 handle: async (c) => {
449 const form = await readForm(c.request);
450 if (form === undefined) return text("too large", 413);
451 const id = Number(c.params.id);
452 const done = updateFacts(c.db, c.who, { id, fields: textFields(form) });
453 return done.ok
454 ? back("Saved. Every asset is written again: read it through.", `/listings/${id}`)
455 : back(done.message, `/listings/${id}/facts`);
456 },
457 },
426458 {
427459 method: "GET",
428460 path: "/listings/:id/flyer",
@@ -705,14 +737,16 @@ export const routes: readonly Route[] = [
705737 const body = fields(raw);
706738 const id = Number(c.params.id);
707739 return answer(
708 body.tone !== undefined && body.kind === undefined
740 typeof body.facts === "object" && body.facts !== null && !Array.isArray(body.facts)
741 ? updateFacts(c.db, c.who, { id, fields: fields(body.facts) })
742 : body.tone !== undefined && body.kind === undefined
709743 ? retoneListing(c.db, c.who, { id, tone: body.tone })
710744 : body.approved !== undefined && body.text === undefined
711745 ? approveAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), approved: body.approved, seen: body.seen, door: "api" })
712746 : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }),
713747 );
714748 },
715 api: { summary: "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), or write every asset again in a tone.", body: LISTING_CHANGE, answer: LISTING },
749 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, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING },
716750 },
717751 {
718752 method: "DELETE",
src/server.test.ts +1
@@ -152,6 +152,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
152152 "use_suggestion",
153153 "approve_asset",
154154 "retone_listing",
155 "update_facts",
155156 "delete_listing",
156157 "check_fair_housing",
157158 ]);
src/walkthrough.test.ts +28 −1
@@ -145,7 +145,34 @@ test("every step of the section works, with the labels it names", async () => {
145145 expect(textareaOf(afterApprove)).toContain("written by hand");
146146 expect(section).toContain("0 of 6 approved");
147147
148 // 6. Print the flyer.
148 // 6. Drop the price: Edit facts, the new price on the flyer, approvals cleared.
149 expect(section).toContain("**Edit facts**");
150 expect(afterTone).toMatch(control("Edit facts"));
151 expect(sampleHtml).not.toMatch(control("Edit facts"));
152 const facts = await (await get(`${mine}/facts`)).text();
153 expect(facts).toContain('value="9 Walkthrough Way, Portland, OR 97214"');
154 expect(facts).toContain(`value="${SAMPLE.price}"`);
155 expect(facts).toMatch(control("Save and write all again"));
156 const approveAgain = new FormData();
157 approveAgain.set("approved", "1");
158 approveAgain.set("seen", /name="seen" value="([^"]+)"/.exec(afterTone)![1]!.replaceAll("&amp;", "&"));
159 approveAgain.set("text", textareaOf(afterTone));
160 await post(`${mine}/assets/mls/approve`, approveAgain);
161 expect(await (await get(mine)).text()).toContain("1 of 6 approved");
162 const drop = new FormData();
163 for (const k of ["beds", "baths", "sqft", "lot", "year", "tone", "palette",
164 "agent_name", "brokerage", "license", "agent_phone", "agent_email"] as const)
165 drop.set(k, String(SAMPLE[k]));
166 drop.set("address", "9 Walkthrough Way, Portland, OR 97214");
167 drop.set("features", SAMPLE.features.join("\n"));
168 drop.set("price", "649000");
169 expect((await post(`${mine}/facts`, drop)).status).toBe(303);
170 const afterDrop = await (await get(mine)).text();
171 expect(afterDrop).toContain("$649,000");
172 expect(afterDrop).toContain("0 of 6 approved");
173 expect(await (await get(`${mine}/flyer`)).text()).toContain("$649,000");
174
175 // 7. Print the flyer.
149176 expect(afterTone).toMatch(control("Print flyer"));
150177 const flyer = await get(`${mine}/flyer`);
151178 expect(flyer.status).toBe(200);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs