Public: anyone can read this; the crew can change it.
Add, remove and reorder a launched listing's photos; the first one is the hero on the flyer and the email #32
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/27-add-remove-and-reorder-a-launched-listin → main 2 commits fork
9 files
changed +357
−9
README.md +6 −3
| @@ -42,8 +42,11 @@ list cannot drift from the app. | ||
| 42 | 42 | |
| 43 | 43 | 1. **Type the property in.** On the first screen: the address, beds, baths, |
| 44 | 44 | square feet, lot, year built, price, five to ten feature lines (one per |
| 45 | line, in your words), and up to eight photos (24 MB in all) — the first | |
| 46 | is the hero. | |
| 45 | line, in your words), and up to eight photos (24 MB at a time) — the first | |
| 46 | is the hero. Photos can be changed later, on **Edit facts**: add, remove, | |
| 47 | or make another the hero (24 MB of new files per save; the flyer and the email need approving again). | |
| 48 | Over the JSON API, `PATCH` `{ photos: { order, remove } }` reorders and | |
| 49 | removes; uploads are multipart on the page only. | |
| 47 | 50 | Your brand (name, brokerage, license number, phone, email, headshot, |
| 48 | 51 | colour) is asked once and carried to your next listing. |
| 49 | 52 | 2. **Pick a tone** — *classic* (measured), *warm* (inviting) or *bold* |
| @@ -236,7 +239,7 @@ curl -X DELETE https://<app>/api/listings/1 | ||
| 236 | 239 | |
| 237 | 240 | As an MCP server, for Claude Code or Claude Desktop — `list_listings`, |
| 238 | 241 | `get_listing`, `launch_listing`, `edit_asset`, `retone_listing`, `update_facts`, |
| 239 | `delete_listing`, | |
| 242 | `update_photos` (order and removals; uploads are on the page only), `delete_listing`, | |
| 240 | 243 | `check_fair_housing`, and the notes' `list_notes`, `add_note`, |
| 241 | 244 | `delete_note`: |
| 242 | 245 |
contract/openapi.json +19 −1
| @@ -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 }), 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.", | |
| 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 photos changed ({ photos: { order, remove } }), 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": [ |
| @@ -621,6 +621,24 @@ | ||
| 621 | 621 | } |
| 622 | 622 | } |
| 623 | 623 | }, |
| 624 | "photos": { | |
| 625 | "type": "object", | |
| 626 | "description": "Photos to change: `order` names the photos to put first (the first is the hero), `remove` names the ones to drop. Uploads are multipart on the page only. Clears the flyer's and the email's approval.", | |
| 627 | "properties": { | |
| 628 | "order": { | |
| 629 | "type": "array", | |
| 630 | "items": { | |
| 631 | "type": "string" | |
| 632 | } | |
| 633 | }, | |
| 634 | "remove": { | |
| 635 | "type": "array", | |
| 636 | "items": { | |
| 637 | "type": "string" | |
| 638 | } | |
| 639 | } | |
| 640 | } | |
| 641 | }, | |
| 624 | 642 | "approved": { |
| 625 | 643 | "type": "boolean" |
| 626 | 644 | }, |
src/actions.ts +89
| @@ -32,6 +32,7 @@ import { | ||
| 32 | 32 | accept as acceptPhoto, |
| 33 | 33 | type Kind as PhotoKind, |
| 34 | 34 | PHOTOS_MAX, |
| 35 | PHOTOS_TOTAL, | |
| 35 | 36 | remove as removePhotos, |
| 36 | 37 | nameFor as photoName, |
| 37 | 38 | unwrite as unwritePhotos, |
| @@ -814,6 +815,94 @@ function changeFacts( | ||
| 814 | 815 | return ok(launched(db, now.id)!); |
| 815 | 816 | } |
| 816 | 817 | |
| 818 | /** The assets that show the photos: what they show changed, so their approval goes. */ | |
| 819 | const PHOTO_ASSETS = ["flyer", "email"] as const; | |
| 820 | ||
| 821 | /** | |
| 822 | * Add, remove and reorder a launched listing's photos; the first is the hero. | |
| 823 | * `order` names the photos that exist now, first to last (any it leaves out | |
| 824 | * follow in their old order); `remove` names the ones to drop; `add` appends | |
| 825 | * new files. New files are written with the row and removed ones unwritten | |
| 826 | * after it commits, so a failure leaves the photos as they were. The flyer's | |
| 827 | * and the email's approval is cleared; the other assets keep theirs. | |
| 828 | */ | |
| 829 | export const updatePhotos = ( | |
| 830 | db: Database, | |
| 831 | who: Who, | |
| 832 | input: { | |
| 833 | id: number; | |
| 834 | add?: readonly Upload[]; | |
| 835 | remove?: readonly unknown[]; | |
| 836 | order?: readonly unknown[]; | |
| 837 | }, | |
| 838 | ): Result<Launched> => | |
| 839 | audited(db, who, "listing", "update", input.id, changePhotos(db, who, input)); | |
| 840 | ||
| 841 | function changePhotos( | |
| 842 | db: Database, | |
| 843 | who: Who, | |
| 844 | input: { | |
| 845 | id: number; | |
| 846 | add?: readonly Upload[]; | |
| 847 | remove?: readonly unknown[]; | |
| 848 | order?: readonly unknown[]; | |
| 849 | }, | |
| 850 | ): Result<Launched> { | |
| 851 | const found = listingFor(db, who, "update", input.id, { | |
| 852 | signIn: "Sign in to edit this listing.", | |
| 853 | no: "Only the listing's agent or an admin can edit it.", | |
| 854 | }); | |
| 855 | if (!found.ok) return found; | |
| 856 | const now = found.value; | |
| 857 | const have = new Set(now.photos); | |
| 858 | const names = (list: readonly unknown[] | undefined): string[] | undefined => { | |
| 859 | const out = (list ?? []).filter((n): n is string => typeof n === "string"); | |
| 860 | return out.length === (list ?? []).length && out.every((n) => have.has(n)) | |
| 861 | ? out | |
| 862 | : undefined; | |
| 863 | }; | |
| 864 | const remove = names(input.remove); | |
| 865 | const order = names(input.order); | |
| 866 | if (remove === undefined || order === undefined) | |
| 867 | return fail("invalid", "That photo is not on this listing."); | |
| 868 | const uploads = (input.add ?? []).filter((p) => p.bytes.byteLength > 0); | |
| 869 | const dropped = new Set(remove); | |
| 870 | const first = [...new Set(order)].filter((n) => !dropped.has(n)); | |
| 871 | const kept = [...first, ...now.photos.filter((n) => !dropped.has(n) && !first.includes(n))]; | |
| 872 | if (kept.length + uploads.length > PHOTOS_MAX) | |
| 873 | return fail("invalid", `Up to ${PHOTOS_MAX} photos.`); | |
| 874 | if (uploads.reduce((sum, u) => sum + u.bytes.byteLength, 0) > PHOTOS_TOTAL) | |
| 875 | return fail("invalid", "Up to 24 MB of photos in all."); | |
| 876 | const files: { name: string; bytes: Uint8Array }[] = []; | |
| 877 | for (const upload of uploads) { | |
| 878 | const accepted = acceptPhoto(upload.bytes); | |
| 879 | if (!accepted.ok) return fail("invalid", accepted.message); | |
| 880 | files.push({ name: photoName(accepted.kind), bytes: upload.bytes }); | |
| 881 | } | |
| 882 | const photos = [...kept, ...files.map((f) => f.name)]; | |
| 883 | try { | |
| 884 | db.transaction(() => { | |
| 885 | db.query("UPDATE listing SET photos = ?1 WHERE id = ?2").run( | |
| 886 | JSON.stringify(photos), | |
| 887 | now.id, | |
| 888 | ); | |
| 889 | touch(db, now.id); | |
| 890 | for (const kind of PHOTO_ASSETS) | |
| 891 | db.query( | |
| 892 | `UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL | |
| 893 | WHERE listing_id = ?1 AND kind = ?2`, | |
| 894 | ).run(now.id, kind); | |
| 895 | for (const f of files) writePhoto(db, f.name, f.bytes); | |
| 896 | })(); | |
| 897 | } catch (error) { | |
| 898 | unwritePhotos(db, files.map((f) => f.name)); | |
| 899 | throw error; | |
| 900 | } | |
| 901 | removePhotos(db, remove); | |
| 902 | emit(db, "listing.changed", { id: now.id }); | |
| 903 | return ok(launched(db, now.id)!); | |
| 904 | } | |
| 905 | ||
| 817 | 906 | export const deleteListing = ( |
| 818 | 907 | db: Database, |
| 819 | 908 | who: Who, |
src/contract.test.ts +36
| @@ -19,6 +19,7 @@ import { | ||
| 19 | 19 | getNote, |
| 20 | 20 | insertListing, |
| 21 | 21 | launchListing, |
| 22 | updatePhotos, | |
| 22 | 23 | listAudit, |
| 23 | 24 | listListings, |
| 24 | 25 | listNotes, |
| @@ -212,3 +213,38 @@ test("taking a suggested wording answers as the listing's update cell says, at t | ||
| 212 | 213 | } |
| 213 | 214 | expect(asked).toBeGreaterThan(0); |
| 214 | 215 | }); |
| 216 | ||
| 217 | /** Changing photos is `listing:update`: the action and the MCP tool let through exactly whom the table's update cell does. */ | |
| 218 | test("changing a listing's photos answers as the listing's update cell says, at the action and the tool", () => { | |
| 219 | let asked = 0; | |
| 220 | for (const cell of table(contract)) { | |
| 221 | if (cell.resource !== "listing" || cell.verb !== "update") continue; | |
| 222 | const who = whoIs(cell.as); | |
| 223 | const { mine, theirs } = actingAs(contract, cell.as); | |
| 224 | const attempts = (authorId: string) => { | |
| 225 | const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]); | |
| 226 | const id = listingBy(authorId); | |
| 227 | const root = { role: "admin" as const, id: "root", name: "root" }; | |
| 228 | const added = updatePhotos(db, root, { id, add: [{ bytes: png }, { bytes: png }] }); | |
| 229 | const names = added.ok ? added.value.photos : []; | |
| 230 | const direct = updatePhotos(db, who, { id, order: [names[1]!] }).ok; | |
| 231 | const called = rpc(db, who, { | |
| 232 | jsonrpc: "2.0", | |
| 233 | id: 1, | |
| 234 | method: "tools/call", | |
| 235 | params: { name: "update_photos", arguments: { id, order: [names[0]!] } }, | |
| 236 | }); | |
| 237 | return [direct, !JSON.stringify(called.body).includes('"isError":true')]; | |
| 238 | }; | |
| 239 | const onMine = attempts(String(mine["author_id"])); | |
| 240 | const onTheirs = attempts(String(theirs["author_id"])); | |
| 241 | const said = `update_photos as ${cell.as}`; | |
| 242 | asked++; | |
| 243 | if (cell.answer === "yes") | |
| 244 | expect([said, onMine, onTheirs]).toEqual([said, [true, true], [true, true]]); | |
| 245 | else if (cell.answer === "no") | |
| 246 | expect([said, onMine, onTheirs]).toEqual([said, [false, false], [false, false]]); | |
| 247 | else expect([said, onMine, onTheirs]).toEqual([said, [true, true], [false, false]]); | |
| 248 | } | |
| 249 | expect(asked).toBeGreaterThan(0); | |
| 250 | }); |
src/listings.test.ts +124 −2
| @@ -8,7 +8,7 @@ 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, updateFacts } from "./actions.ts"; | |
| 11 | import { approveAsset, editAsset, getListing, insertListing, launchListing, retoneListing, updateFacts, updatePhotos } from "./actions.ts"; | |
| 12 | 12 | import { openDb } from "./db.ts"; |
| 13 | 13 | import { rpc } from "./mcp.ts"; |
| 14 | 14 | import { validate } from "./listing.ts"; |
| @@ -693,7 +693,8 @@ test("Edit facts is on the page for the agent only, and the form, the API and MC | ||
| 693 | 693 | expect(open).toContain('value="18 Quarry Road, Bend, OR 97701"'); |
| 694 | 694 | expect(open).toContain(`value="${SAMPLE.price}"`); |
| 695 | 695 | expect(open).toContain("your edits are replaced"); |
| 696 | expect(open).not.toContain('type="file"'); | |
| 696 | // The facts form takes no files; the photos have their own form below it. | |
| 697 | expect(open.split('action="/listings/' + id + '/photos"')[0]).not.toContain('type="file"'); | |
| 697 | 698 | |
| 698 | 699 | // The page form. |
| 699 | 700 | const post = (who: Record<string, string>, form: FormData) => |
| @@ -737,3 +738,124 @@ test("Edit facts is on the page for the agent only, and the form, the API and MC | ||
| 737 | 738 | expect(ok.result.isError).toBeUndefined(); |
| 738 | 739 | expect(ok.result.content[0]!.text).toContain("$400,000"); |
| 739 | 740 | }); |
| 741 | ||
| 742 | test("photos change after launch: add, make the hero, remove; the flyer and email lose approval, the rest keep it", () => { | |
| 743 | const data = mkdtempSync(join(tmpdir(), "app-photos-")); | |
| 744 | const db = openDb(data); | |
| 745 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 746 | const other: Who = { role: "user", id: "u2", name: "other" }; | |
| 747 | const made = launchListing(db, me, { fields: { ...SAMPLE }, photos: [{ bytes: PNG }] }); | |
| 748 | if (!made.ok) throw new Error(made.message); | |
| 749 | const id = made.value.id; | |
| 750 | const files = () => readdirSync(join(data, "photos")).sort(); | |
| 751 | const first = made.value.photos[0]!; | |
| 752 | for (const kind of ["mls", "flyer", "email"]) { | |
| 753 | const l = getListing(db, me, { id }); | |
| 754 | if (!l.ok) throw new Error(l.message); | |
| 755 | const seen = l.value.assets.find((a) => a.kind === kind)!.updated_at; | |
| 756 | const a = approveAsset(db, me, { id, kind, door: "page", seen }); | |
| 757 | if (!a.ok) throw new Error(a.message); | |
| 758 | } | |
| 759 | ||
| 760 | const added = updatePhotos(db, me, { id, add: [{ bytes: JPEG }] }); | |
| 761 | if (!added.ok) throw new Error(added.message); | |
| 762 | const second = added.value.photos[1]!; | |
| 763 | expect(added.value.photos).toEqual([first, second]); | |
| 764 | expect(second).toMatch(/\.jpg$/); | |
| 765 | expect(files()).toEqual([first, second].sort()); | |
| 766 | const approved = (l: typeof added.value) => l.assets.filter((a) => a.approval !== null).map((a) => a.kind); | |
| 767 | expect(approved(added.value)).toEqual(["mls"]); | |
| 768 | ||
| 769 | const hero = updatePhotos(db, me, { id, order: [second] }); | |
| 770 | if (!hero.ok) throw new Error(hero.message); | |
| 771 | expect(hero.value.photos).toEqual([second, first]); | |
| 772 | ||
| 773 | // The audit and the live event. | |
| 774 | expect(db.query("SELECT count(*) AS n FROM audit WHERE resource = 'listing' AND verb = 'update' AND outcome = 'ok'").get()).toEqual({ n: 2 }); | |
| 775 | expect(db.query("SELECT count(*) AS n FROM event WHERE type = 'listing.changed'").get()).toEqual({ n: 5 }); | |
| 776 | ||
| 777 | // A name that is not this listing's, a stranger, a ninth photo, a bad file: nothing changes. | |
| 778 | const before = files(); | |
| 779 | expect(updatePhotos(db, me, { id, remove: ["../../app.db"] })).toMatchObject({ ok: false, error: "invalid" }); | |
| 780 | expect(updatePhotos(db, me, { id, order: ["0123456789abcdef0123456789abcdef.png"] })).toMatchObject({ ok: false, error: "invalid" }); | |
| 781 | expect(updatePhotos(db, other, { id, remove: [first] })).toMatchObject({ ok: false, error: "forbidden" }); | |
| 782 | expect(updatePhotos(db, { role: "anon" }, { id, remove: [first] })).toMatchObject({ ok: false, error: "signin_required" }); | |
| 783 | expect(updatePhotos(db, me, { id, add: [{ bytes: new TextEncoder().encode("<svg>") }] })).toMatchObject({ ok: false, error: "invalid" }); | |
| 784 | expect(updatePhotos(db, me, { id, add: Array.from({ length: 7 }, () => ({ bytes: PNG })) })).toMatchObject({ ok: false, error: "invalid" }); | |
| 785 | expect(files()).toEqual(before); | |
| 786 | const same = getListing(db, me, { id }); | |
| 787 | expect(same.ok && same.value.photos).toEqual([second, first]); | |
| 788 | ||
| 789 | // Eight fit; a ninth does not. | |
| 790 | const full = updatePhotos(db, me, { id, add: Array.from({ length: 6 }, () => ({ bytes: PNG })) }); | |
| 791 | expect(full.ok && full.value.photos.length).toBe(8); | |
| 792 | expect(updatePhotos(db, me, { id, add: [{ bytes: PNG }] })).toMatchObject({ ok: false, error: "invalid" }); | |
| 793 | expect(files().length).toBe(8); | |
| 794 | ||
| 795 | // Removing unwrites the file, but never one another row still names. | |
| 796 | const shared = insertListing(db, me, validateSample(), [first], null); | |
| 797 | const gone = updatePhotos(db, me, { id, remove: [first, second] }); | |
| 798 | if (!gone.ok) throw new Error(gone.message); | |
| 799 | expect(gone.value.photos.length).toBe(6); | |
| 800 | expect(files()).toContain(first); | |
| 801 | expect(files()).not.toContain(second); | |
| 802 | expect(shared.photos).toEqual([first]); | |
| 803 | }); | |
| 804 | ||
| 805 | const validateSample = () => { | |
| 806 | const v = validate({ ...SAMPLE }); | |
| 807 | if (!v.ok) throw new Error(v.message); | |
| 808 | return v.value; | |
| 809 | }; | |
| 810 | ||
| 811 | test("photos over the page and the API: same-origin, multipart upload, the flyer and email show the hero", async () => { | |
| 812 | const { to } = await launch(alice, [PNG]); | |
| 813 | const id = Number(to.split("/").pop()); | |
| 814 | const post = (who: Record<string, string>, form: FormData, extra: Record<string, string> = {}) => | |
| 815 | fetch(`${base}/listings/${id}/photos`, { method: "POST", headers: { ...who, ...extra }, body: form, redirect: "manual" }); | |
| 816 | const add = new FormData(); | |
| 817 | add.append("photos", new Blob([JPEG]), "new.jpg"); | |
| 818 | expect((await post(alice, add, { origin: "https://evil.example" })).status).toBe(403); | |
| 819 | expect((await post(bob, add)).headers.get("location")).toBe(`/listings/${id}/facts`); | |
| 820 | expect((await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).match(/\/photos\//g)!.length).toBe(3); | |
| 821 | expect((await post(alice, add)).status).toBe(303); | |
| 822 | ||
| 823 | const api = async (who: Record<string, string>, photos: unknown) => | |
| 824 | fetch(`${base}/api/listings/${id}`, { method: "PATCH", headers: { ...who, "content-type": "application/json" }, body: JSON.stringify({ photos }) }); | |
| 825 | const listing = (await (await fetch(`${base}/api/listings/${id}`, { headers: alice })).json()) as { photos: string[] }; | |
| 826 | const [old, fresh] = listing.photos; | |
| 827 | expect(fresh).toMatch(/\.jpg$/); | |
| 828 | expect((await api(bob, { order: [fresh] })).status).toBe(403); | |
| 829 | const hero = await api(alice, { order: [fresh] }); | |
| 830 | expect(hero.status).toBe(200); | |
| 831 | for (const path of ["flyer", "email"]) { | |
| 832 | const html = await (await fetch(`${base}/listings/${id}/${path}`, { headers: alice })).text(); | |
| 833 | expect(html).toContain(`/listings/${id}/photos/${fresh}"`); | |
| 834 | } | |
| 835 | expect((await api(alice, { remove: ["nope.png"] })).status).toBe(422); | |
| 836 | const removed = await api(alice, { remove: [old] }); | |
| 837 | expect(removed.status).toBe(200); | |
| 838 | expect(existsSync(join(dir, "photos", old!))).toBe(false); | |
| 839 | expect((await fetch(`${base}/listings/${id}/facts`, { headers: alice })).status).toBe(200); | |
| 840 | }); | |
| 841 | ||
| 842 | test("update_photos over MCP reorders and removes, and refuses a stranger and a name that is not the listing's", () => { | |
| 843 | const data = mkdtempSync(join(tmpdir(), "app-photos-mcp-")); | |
| 844 | const db = openDb(data); | |
| 845 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 846 | const made = launchListing(db, me, { fields: { ...SAMPLE }, photos: [{ bytes: PNG }, { bytes: JPEG }] }); | |
| 847 | if (!made.ok) throw new Error(made.message); | |
| 848 | const [a, b] = made.value.photos as [string, string]; | |
| 849 | const call = (who: Who, args: unknown) => | |
| 850 | rpc(db, who, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "update_photos", arguments: args } }).body as { result: { isError?: boolean; content: { text: string }[] } }; | |
| 851 | const id = made.value.id; | |
| 852 | expect(call({ role: "user", id: "u2", name: "x" }, { id, order: [b] }).result.isError).toBe(true); | |
| 853 | expect(call(me, { id, remove: ["../../app.db"] }).result.isError).toBe(true); | |
| 854 | expect(getListing(db, me, { id })).toMatchObject({ value: { photos: [a, b] } }); | |
| 855 | const hero = call(me, { id, order: [b] }); | |
| 856 | expect(hero.result.isError).toBeUndefined(); | |
| 857 | expect(getListing(db, me, { id })).toMatchObject({ value: { photos: [b, a] } }); | |
| 858 | expect(call(me, { id, remove: [b] }).result.isError).toBeUndefined(); | |
| 859 | expect(getListing(db, me, { id })).toMatchObject({ value: { photos: [a] } }); | |
| 860 | expect(existsSync(join(data, "photos", b))).toBe(false); | |
| 861 | }); |
src/mcp.ts +21
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | listNotes, |
| 22 | 22 | retoneListing, |
| 23 | 23 | updateFacts, |
| 24 | updatePhotos, | |
| 24 | 25 | type Result, |
| 25 | 26 | } from "./actions.ts"; |
| 26 | 27 | import { KINDS, TONES } from "./generate.ts"; |
| @@ -230,6 +231,26 @@ export const tools: readonly Tool[] = [ | ||
| 230 | 231 | : {}, |
| 231 | 232 | }), |
| 232 | 233 | }, |
| 234 | { | |
| 235 | name: "update_photos", | |
| 236 | description: | |
| 237 | "Reorder or remove a launched listing's photos: `order` names the photos to put first (the first is the hero on the flyer and the email), `remove` names the ones to drop; the names are the ones get_listing shows. Uploading photos is on the page only. Clears the flyer's and the email's approval; the other assets keep theirs. Only the listing's agent or an admin can.", | |
| 238 | inputSchema: { | |
| 239 | type: "object", | |
| 240 | properties: { | |
| 241 | id: { type: "integer" }, | |
| 242 | order: { type: "array", items: { type: "string" } }, | |
| 243 | remove: { type: "array", items: { type: "string" } }, | |
| 244 | }, | |
| 245 | required: ["id"], | |
| 246 | }, | |
| 247 | run: (db, who, args) => | |
| 248 | updatePhotos(db, who, { | |
| 249 | id: Number(args.id), | |
| 250 | order: Array.isArray(args.order) ? args.order : [], | |
| 251 | remove: Array.isArray(args.remove) ? args.remove : [], | |
| 252 | }), | |
| 253 | }, | |
| 233 | 254 | { |
| 234 | 255 | name: "delete_listing", |
| 235 | 256 | description: |
src/pages/facts.ts +19 −1
| @@ -8,7 +8,9 @@ import { escape } from "../http.ts"; | ||
| 8 | 8 | import type { Listing } from "../listing.ts"; |
| 9 | 9 | import type { Who } from "../who.ts"; |
| 10 | 10 | import { draftOf, form } from "./home.ts"; |
| 11 | import { PHOTOS_MAX } from "../photos.ts"; | |
| 11 | 12 | import { layout, toast } from "./layout.ts"; |
| 13 | import { photoUrl } from "./listing.ts"; | |
| 12 | 14 | |
| 13 | 15 | export function factsPage(input: { who: Who; listing: Listing; notice?: string | undefined }): string { |
| 14 | 16 | const { who, listing: l, notice } = input; |
| @@ -16,11 +18,27 @@ export function factsPage(input: { who: Who; listing: Listing; notice?: string | | ||
| 16 | 18 | `<section class="compose" aria-labelledby="compose-title"> |
| 17 | 19 | <p><a href="/listings/${l.id}">← ${escape(l.address)}</a></p> |
| 18 | 20 | <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> | |
| 21 | <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 are changed below, on their own.</p> | |
| 20 | 22 | ${form(who, draftOf(l, false), { id: l.id })} |
| 23 | ${photosForm(l)} | |
| 21 | 24 | </section> |
| 22 | 25 | ${toast(notice)}`, |
| 23 | 26 | who, |
| 24 | 27 | { path: `/listings/${l.id}/facts`, heading: "Edit facts" }, |
| 25 | 28 | ); |
| 26 | 29 | } |
| 30 | ||
| 31 | /** The photos as they are: make one the hero, drop some, add more. Its own POST, multipart. */ | |
| 32 | function photosForm(l: Listing): string { | |
| 33 | const room = PHOTOS_MAX - l.photos.length; | |
| 34 | return `<form class="card form" method="post" action="/listings/${l.id}/photos" enctype="multipart/form-data" aria-labelledby="photos-title"> | |
| 35 | <h3 id="photos-title">Photos</h3> | |
| 36 | <p class="muted">The first is the hero on the flyer and the email. Saving clears the approval of those two; the other assets keep theirs.</p> | |
| 37 | ${l.photos.length === 0 ? `<p class="muted">No photos yet.</p>` : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="Photo ${i + 1} of ${escape(l.address)}" width="240" height="160" loading="lazy"> | |
| 38 | ${i === 0 ? `<b>Hero</b>` : `<button class="button button-quiet" name="hero" value="${escape(p)}">Make hero</button>`} | |
| 39 | <label class="choice"><input type="checkbox" name="remove" value="${escape(p)}"> <span>Remove</span></label></li>`).join("")}</ul>`} | |
| 40 | ${room <= 0 ? `<p class="muted">Eight photos is the most; remove one to add another.</p>` : `<label class="field"><span>Add photos <small class="muted">up to ${room} more, JPEG, PNG or WebP, 24 MB at a time</small></span> | |
| 41 | <input class="input file" type="file" name="photos" multiple accept="image/jpeg,image/png,image/webp"></label>`} | |
| 42 | <button class="button">Save photos</button> | |
| 43 | </form>`; | |
| 44 | } |
src/routes.ts +42 −2
| @@ -32,6 +32,7 @@ import { | ||
| 32 | 32 | listNotes, |
| 33 | 33 | retoneListing, |
| 34 | 34 | updateFacts, |
| 35 | updatePhotos, | |
| 35 | 36 | updateNote, |
| 36 | 37 | useSuggestion, |
| 37 | 38 | type Failure, |
| @@ -260,7 +261,7 @@ const LISTING_BODY: Schema = { | ||
| 260 | 261 | const LISTING_CHANGE: Schema = { |
| 261 | 262 | type: "object", |
| 262 | 263 | description: |
| 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.", | |
| 264 | "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 photos changed ({ photos: { order, remove } }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.", | |
| 264 | 265 | properties: { |
| 265 | 266 | kind: { enum: [...KINDS] }, |
| 266 | 267 | text: { type: "string", minLength: 1, maxLength: ASSET_MAX }, |
| @@ -270,6 +271,14 @@ const LISTING_CHANGE: Schema = { | ||
| 270 | 271 | description: "Launch fields to change; the ones left out keep their value.", |
| 271 | 272 | properties: FACTS, |
| 272 | 273 | }, |
| 274 | photos: { | |
| 275 | type: "object", | |
| 276 | description: "Photos to change: `order` names the photos to put first (the first is the hero), `remove` names the ones to drop. Uploads are multipart on the page only. Clears the flyer's and the email's approval.", | |
| 277 | properties: { | |
| 278 | order: { type: "array", items: { type: "string" } }, | |
| 279 | remove: { type: "array", items: { type: "string" } }, | |
| 280 | }, | |
| 281 | }, | |
| 273 | 282 | approved: { type: "boolean" }, |
| 274 | 283 | seen: { |
| 275 | 284 | type: "string", |
| @@ -340,6 +349,12 @@ const uploads = async (form: FormData, name: string): Promise<Upload[]> => | ||
| 340 | 349 | .map(async (f) => ({ bytes: new Uint8Array(await f.arrayBuffer()) })), |
| 341 | 350 | ); |
| 342 | 351 | |
| 352 | /** A JSON photo change: the order and the removals, whatever else was sent left out. */ | |
| 353 | const photoChange = (raw: Record<string, unknown>): { order: unknown[]; remove: unknown[] } => ({ | |
| 354 | order: Array.isArray(raw.order) ? raw.order : [], | |
| 355 | remove: Array.isArray(raw.remove) ? raw.remove : [], | |
| 356 | }); | |
| 357 | ||
| 343 | 358 | /** The form's text fields, as plain data. */ |
| 344 | 359 | const textFields = (form: FormData): Record<string, string> => |
| 345 | 360 | Object.fromEntries( |
| @@ -455,6 +470,29 @@ export const routes: readonly Route[] = [ | ||
| 455 | 470 | : back(done.message, `/listings/${id}/facts`); |
| 456 | 471 | }, |
| 457 | 472 | }, |
| 473 | { | |
| 474 | method: "POST", | |
| 475 | path: "/listings/:id/photos", | |
| 476 | handle: async (c) => { | |
| 477 | const id = Number(c.params.id); | |
| 478 | const to = `/listings/${id}/facts`; | |
| 479 | // Whoever may not edit is answered before a byte of the body is read. | |
| 480 | const may = getListing(c.db, c.who, { id }); | |
| 481 | if (!may.ok || !canEditListing(c.who, may.value)) return back("Only the listing's agent or an admin can edit it.", to); | |
| 482 | const form = await readForm(c.request, LAUNCH_MAX, 0); | |
| 483 | if (form === undefined) return back("Too large: up to 24 MB of photos in all.", to); | |
| 484 | const hero = form.get("hero"); | |
| 485 | const done = updatePhotos(c.db, c.who, { | |
| 486 | id, | |
| 487 | add: await uploads(form, "photos"), | |
| 488 | remove: form.getAll("remove").map(String), | |
| 489 | order: typeof hero === "string" && hero !== "" ? [hero] : [], | |
| 490 | }); | |
| 491 | return done.ok | |
| 492 | ? back("Photos saved. The flyer and the email need approving again.", to) | |
| 493 | : back(done.message, to); | |
| 494 | }, | |
| 495 | }, | |
| 458 | 496 | { |
| 459 | 497 | method: "GET", |
| 460 | 498 | path: "/listings/:id/flyer", |
| @@ -737,7 +775,9 @@ export const routes: readonly Route[] = [ | ||
| 737 | 775 | const body = fields(raw); |
| 738 | 776 | const id = Number(c.params.id); |
| 739 | 777 | return answer( |
| 740 | typeof body.facts === "object" && body.facts !== null && !Array.isArray(body.facts) | |
| 778 | typeof body.photos === "object" && body.photos !== null && !Array.isArray(body.photos) | |
| 779 | ? updatePhotos(c.db, c.who, { id, ...photoChange(fields(body.photos)) }) | |
| 780 | : typeof body.facts === "object" && body.facts !== null && !Array.isArray(body.facts) | |
| 741 | 781 | ? updateFacts(c.db, c.who, { id, fields: fields(body.facts) }) |
| 742 | 782 | : body.tone !== undefined && body.kind === undefined |
| 743 | 783 | ? retoneListing(c.db, c.who, { id, tone: body.tone }) |
src/server.test.ts +1
| @@ -153,6 +153,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 153 | 153 | "approve_asset", |
| 154 | 154 | "retone_listing", |
| 155 | 155 | "update_facts", |
| 156 | "update_photos", | |
| 156 | 157 | "delete_listing", |
| 157 | 158 | "check_fair_housing", |
| 158 | 159 | ]); |
new above
offline