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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/26-change-a-launched-listing-s-price-and-fa → main 2 commits fork
11 files
changed +420
−18
README.md +11 −3
| @@ -25,7 +25,13 @@ to make a listing of your own. | ||
| 25 | 25 | or Bold) and press **Rewrite all**. Every asset is written again in that |
| 26 | 26 | tone, your edits are replaced, and the approval is cleared: it says |
| 27 | 27 | "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 | |
| 29 | 35 | the photo, price, facts, description and your name. Print it or save it |
| 30 | 36 | as a PDF. |
| 31 | 37 | |
| @@ -143,7 +149,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 143 | 149 | |
| 144 | 150 | | file | what it is | |
| 145 | 151 | | ---------------------------------- | -------------------------------------------------------------- | |
| 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 | | |
| 147 | 153 | | `src/listing.ts` | what a listing is, and the check at the boundary that one arrived | |
| 148 | 154 | | `src/generate.ts` | `generate(listing)`: the five assets from the facts, in three tones | |
| 149 | 155 | | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite | |
| @@ -223,11 +229,13 @@ curl https://<app>/api/listings | ||
| 223 | 229 | curl https://<app>/api/listings/1 |
| 224 | 230 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"kind":"mls","text":"…"}' |
| 225 | 231 | 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}}' | |
| 226 | 234 | curl -X DELETE https://<app>/api/listings/1 |
| 227 | 235 | |
| 228 | 236 | |
| 229 | 237 | 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`, | |
| 231 | 239 | `delete_listing`, |
| 232 | 240 | `check_fair_housing`, and the notes' `list_notes`, `add_note`, |
| 233 | 241 | `delete_note`: |
contract/openapi.json +92 −3
| @@ -490,7 +490,7 @@ | ||
| 490 | 490 | } |
| 491 | 491 | }, |
| 492 | 492 | "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.", | |
| 494 | 494 | "operationId": "patchApiListingsId", |
| 495 | 495 | "parameters": [ |
| 496 | 496 | { |
| @@ -508,7 +508,7 @@ | ||
| 508 | 508 | "application/json": { |
| 509 | 509 | "schema": { |
| 510 | 510 | "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.", | |
| 512 | 512 | "properties": { |
| 513 | 513 | "kind": { |
| 514 | 514 | "enum": [ |
| @@ -532,6 +532,95 @@ | ||
| 532 | 532 | "bold" |
| 533 | 533 | ] |
| 534 | 534 | }, |
| 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 | }, | |
| 535 | 624 | "approved": { |
| 536 | 625 | "type": "boolean" |
| 537 | 626 | }, |
| @@ -546,7 +635,7 @@ | ||
| 546 | 635 | }, |
| 547 | 636 | "responses": { |
| 548 | 637 | "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.", | |
| 550 | 639 | "content": { |
| 551 | 640 | "application/json": { |
| 552 | 641 | "schema": { |
src/actions.ts +61
| @@ -753,6 +753,67 @@ function retone( | ||
| 753 | 753 | return ok(launched(db, found.value.id)!); |
| 754 | 754 | } |
| 755 | 755 | |
| 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 | ||
| 756 | 817 | export const deleteListing = ( |
| 757 | 818 | db: Database, |
| 758 | 819 | who: Who, |
src/listings.test.ts +119 −1
| @@ -8,10 +8,11 @@ import { afterAll, expect, test } from "bun:test"; | ||
| 8 | 8 | import { existsSync, mkdtempSync, readdirSync } from "node:fs"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { openDb } from "./db.ts"; |
| 13 | 13 | import { rpc } from "./mcp.ts"; |
| 14 | 14 | import { validate } from "./listing.ts"; |
| 15 | import { money } from "./generate.ts"; | |
| 15 | 16 | import { sniff } from "./photos.ts"; |
| 16 | 17 | import { SAMPLE, seed } from "./sample.ts"; |
| 17 | 18 | import { serve } from "./server.ts"; |
| @@ -619,3 +620,120 @@ test("one tap takes the suggested wording: exactly those words change, and it sa | ||
| 619 | 620 | expect(audit).toContain("forbidden"); |
| 620 | 621 | expect(audit).toContain("conflict"); |
| 621 | 622 | }); |
| 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 { | ||
| 20 | 20 | listListings, |
| 21 | 21 | listNotes, |
| 22 | 22 | retoneListing, |
| 23 | updateFacts, | |
| 23 | 24 | type Result, |
| 24 | 25 | } from "./actions.ts"; |
| 25 | 26 | import { KINDS, TONES } from "./generate.ts"; |
| @@ -204,6 +205,31 @@ export const tools: readonly Tool[] = [ | ||
| 204 | 205 | run: (db, who, args) => |
| 205 | 206 | retoneListing(db, who, { id: Number(args.id), tone: args.tone }), |
| 206 | 207 | }, |
| 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 | }, | |
| 207 | 233 | { |
| 208 | 234 | name: "delete_listing", |
| 209 | 235 | 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( | ||
| 91 | 91 | <input class="input" name="${name}" value="${v(draft, name)}" placeholder="${ph(placeholder)}" ${attrs}></label>`; |
| 92 | 92 | } |
| 93 | 93 | |
| 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 { | |
| 95 | 100 | const tone = draft.tone ?? "classic"; |
| 96 | 101 | const palette = draft.palette ?? "navy"; |
| 97 | 102 | 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" | |
| 99 | 108 | ? `<a class="button" href="/__forge/signin?to=/">Sign in to launch</a>` |
| 100 | 109 | : `<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"`}> | |
| 102 | 115 | <fieldset> |
| 103 | 116 | <legend>The property</legend> |
| 104 | 117 | ${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 { | ||
| 112 | 125 | </div> |
| 113 | 126 | <label class="field"><span>Features <small class="muted">${FEATURES_MIN} to ${FEATURES_MAX}, one per line</small></span> |
| 114 | 127 | <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 : ""} | |
| 117 | 129 | </fieldset> |
| 118 | 130 | <fieldset> |
| 119 | 131 | <legend>Tone</legend> |
| @@ -130,8 +142,7 @@ function form(who: Who, draft: Draft): string { | ||
| 130 | 142 | ${field(draft, "license", "License number", 'required maxlength="40"', SAMPLE.license)} |
| 131 | 143 | ${field(draft, "agent_phone", "Phone", 'required type="tel" maxlength="30" autocomplete="tel"', SAMPLE.agent_phone)} |
| 132 | 144 | ${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 : ""} | |
| 135 | 146 | </div> |
| 136 | 147 | <div class="swatches" role="radiogroup" aria-label="Brand colour">${PALETTES.map( |
| 137 | 148 | (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: { | ||
| 169 | 169 | ${approvalPill(approvedCount(l), l.assets.length)} |
| 170 | 170 | ${n === 0 ? `<span class="fh-pill fh-ok">Fair Housing: clear</span>` : `<span class="fh-pill fh-warn">Fair Housing: ${n} to review</span>`} |
| 171 | 171 | ${tone} |
| 172 | ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""} | |
| 172 | 173 | <a class="button" href="/listings/${l.id}/flyer">Print flyer</a> |
| 173 | 174 | <a class="button button-quiet" href="/?from=${l.id}">Start a new one from this</a> |
| 174 | 175 | </div> |
src/routes.ts +37 −3
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | listListings, |
| 32 | 32 | listNotes, |
| 33 | 33 | retoneListing, |
| 34 | updateFacts, | |
| 34 | 35 | updateNote, |
| 35 | 36 | useSuggestion, |
| 36 | 37 | type Failure, |
| @@ -58,6 +59,7 @@ import { events } from "./live.ts"; | ||
| 58 | 59 | import { rpc } from "./mcp.ts"; |
| 59 | 60 | import { draftOf, home, type Draft } from "./pages/home.ts"; |
| 60 | 61 | import { layout } from "./pages/layout.ts"; |
| 62 | import { factsPage } from "./pages/facts.ts"; | |
| 61 | 63 | import { emailPage } from "./pages/email.ts"; |
| 62 | 64 | import { approval, fairHousing, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; |
| 63 | 65 | import { notesPage } from "./pages/notes.ts"; |
| @@ -258,11 +260,16 @@ const LISTING_BODY: Schema = { | ||
| 258 | 260 | const LISTING_CHANGE: Schema = { |
| 259 | 261 | type: "object", |
| 260 | 262 | 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.", | |
| 262 | 264 | properties: { |
| 263 | 265 | kind: { enum: [...KINDS] }, |
| 264 | 266 | text: { type: "string", minLength: 1, maxLength: ASSET_MAX }, |
| 265 | 267 | 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 | }, | |
| 266 | 273 | approved: { type: "boolean" }, |
| 267 | 274 | seen: { |
| 268 | 275 | type: "string", |
| @@ -423,6 +430,31 @@ export const routes: readonly Route[] = [ | ||
| 423 | 430 | ); |
| 424 | 431 | }, |
| 425 | 432 | }, |
| 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 | }, | |
| 426 | 458 | { |
| 427 | 459 | method: "GET", |
| 428 | 460 | path: "/listings/:id/flyer", |
| @@ -705,14 +737,16 @@ export const routes: readonly Route[] = [ | ||
| 705 | 737 | const body = fields(raw); |
| 706 | 738 | const id = Number(c.params.id); |
| 707 | 739 | 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 | |
| 709 | 743 | ? retoneListing(c.db, c.who, { id, tone: body.tone }) |
| 710 | 744 | : body.approved !== undefined && body.text === undefined |
| 711 | 745 | ? approveAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), approved: body.approved, seen: body.seen, door: "api" }) |
| 712 | 746 | : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }), |
| 713 | 747 | ); |
| 714 | 748 | }, |
| 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 }, | |
| 716 | 750 | }, |
| 717 | 751 | { |
| 718 | 752 | 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 () => | ||
| 152 | 152 | "use_suggestion", |
| 153 | 153 | "approve_asset", |
| 154 | 154 | "retone_listing", |
| 155 | "update_facts", | |
| 155 | 156 | "delete_listing", |
| 156 | 157 | "check_fair_housing", |
| 157 | 158 | ]); |
src/walkthrough.test.ts +28 −1
| @@ -145,7 +145,34 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 145 | 145 | expect(textareaOf(afterApprove)).toContain("written by hand"); |
| 146 | 146 | expect(section).toContain("0 of 6 approved"); |
| 147 | 147 | |
| 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("&", "&")); | |
| 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. | |
| 149 | 176 | expect(afterTone).toMatch(control("Print flyer")); |
| 150 | 177 | const flyer = await get(`${mine}/flyer`); |
| 151 | 178 | expect(flyer.status).toBe(200); |
new above
offline