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

Merged opened this

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

Merged

mason/listing-launch:mason/27-add-remove-and-reorder-a-launched-listin → main 2 commits fork

  • Merged. mason/27-add-remove-and-reorder-a-launched-listin was merged into main as f4d96c0.

Review

Sign in to review this change.
9 files changed +357 −9
README.md +6 −3
@@ -42,8 +42,11 @@ list cannot drift from the app.
4242
4343 1. **Type the property in.** On the first screen: the address, beds, baths,
4444 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.
4750 Your brand (name, brokerage, license number, phone, email, headshot,
4851 colour) is asked once and carried to your next listing.
4952 2. **Pick a tone** — *classic* (measured), *warm* (inviting) or *bold*
@@ -236,7 +239,7 @@ curl -X DELETE https://<app>/api/listings/1
236239
237240 As an MCP server, for Claude Code or Claude Desktop — `list_listings`,
238241 `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`,
240243 `check_fair_housing`, and the notes' `list_notes`, `add_note`,
241244 `delete_note`:
242245
contract/openapi.json +19 −1
@@ -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 }), 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.",
512512 "properties": {
513513 "kind": {
514514 "enum": [
@@ -621,6 +621,24 @@
621621 }
622622 }
623623 },
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 },
624642 "approved": {
625643 "type": "boolean"
626644 },
src/actions.ts +89
@@ -32,6 +32,7 @@ import {
3232 accept as acceptPhoto,
3333 type Kind as PhotoKind,
3434 PHOTOS_MAX,
35 PHOTOS_TOTAL,
3536 remove as removePhotos,
3637 nameFor as photoName,
3738 unwrite as unwritePhotos,
@@ -814,6 +815,94 @@ function changeFacts(
814815 return ok(launched(db, now.id)!);
815816 }
816817
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
817906 export const deleteListing = (
818907 db: Database,
819908 who: Who,
src/contract.test.ts +36
@@ -19,6 +19,7 @@ import {
1919 getNote,
2020 insertListing,
2121 launchListing,
22 updatePhotos,
2223 listAudit,
2324 listListings,
2425 listNotes,
@@ -212,3 +213,38 @@ test("taking a suggested wording answers as the listing's update cell says, at t
212213 }
213214 expect(asked).toBeGreaterThan(0);
214215 });
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";
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, updateFacts } from "./actions.ts";
11 import { approveAsset, editAsset, getListing, insertListing, launchListing, retoneListing, updateFacts, updatePhotos } from "./actions.ts";
1212 import { openDb } from "./db.ts";
1313 import { rpc } from "./mcp.ts";
1414 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
693693 expect(open).toContain('value="18 Quarry Road, Bend, OR 97701"');
694694 expect(open).toContain(`value="${SAMPLE.price}"`);
695695 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"');
697698
698699 // The page form.
699700 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
737738 expect(ok.result.isError).toBeUndefined();
738739 expect(ok.result.content[0]!.text).toContain("$400,000");
739740 });
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 {
2121 listNotes,
2222 retoneListing,
2323 updateFacts,
24 updatePhotos,
2425 type Result,
2526 } from "./actions.ts";
2627 import { KINDS, TONES } from "./generate.ts";
@@ -230,6 +231,26 @@ export const tools: readonly Tool[] = [
230231 : {},
231232 }),
232233 },
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 },
233254 {
234255 name: "delete_listing",
235256 description:
src/pages/facts.ts +19 −1
@@ -8,7 +8,9 @@ import { escape } from "../http.ts";
88 import type { Listing } from "../listing.ts";
99 import type { Who } from "../who.ts";
1010 import { draftOf, form } from "./home.ts";
11 import { PHOTOS_MAX } from "../photos.ts";
1112 import { layout, toast } from "./layout.ts";
13 import { photoUrl } from "./listing.ts";
1214
1315 export function factsPage(input: { who: Who; listing: Listing; notice?: string | undefined }): string {
1416 const { who, listing: l, notice } = input;
@@ -16,11 +18,27 @@ export function factsPage(input: { who: Who; listing: Listing; notice?: string |
1618 `<section class="compose" aria-labelledby="compose-title">
1719 <p><a href="/listings/${l.id}">← ${escape(l.address)}</a></p>
1820 <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>
2022 ${form(who, draftOf(l, false), { id: l.id })}
23 ${photosForm(l)}
2124 </section>
2225 ${toast(notice)}`,
2326 who,
2427 { path: `/listings/${l.id}/facts`, heading: "Edit facts" },
2528 );
2629 }
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 {
3232 listNotes,
3333 retoneListing,
3434 updateFacts,
35 updatePhotos,
3536 updateNote,
3637 useSuggestion,
3738 type Failure,
@@ -260,7 +261,7 @@ const LISTING_BODY: Schema = {
260261 const LISTING_CHANGE: Schema = {
261262 type: "object",
262263 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.",
264265 properties: {
265266 kind: { enum: [...KINDS] },
266267 text: { type: "string", minLength: 1, maxLength: ASSET_MAX },
@@ -270,6 +271,14 @@ const LISTING_CHANGE: Schema = {
270271 description: "Launch fields to change; the ones left out keep their value.",
271272 properties: FACTS,
272273 },
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 },
273282 approved: { type: "boolean" },
274283 seen: {
275284 type: "string",
@@ -340,6 +349,12 @@ const uploads = async (form: FormData, name: string): Promise<Upload[]> =>
340349 .map(async (f) => ({ bytes: new Uint8Array(await f.arrayBuffer()) })),
341350 );
342351
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
343358 /** The form's text fields, as plain data. */
344359 const textFields = (form: FormData): Record<string, string> =>
345360 Object.fromEntries(
@@ -455,6 +470,29 @@ export const routes: readonly Route[] = [
455470 : back(done.message, `/listings/${id}/facts`);
456471 },
457472 },
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 },
458496 {
459497 method: "GET",
460498 path: "/listings/:id/flyer",
@@ -737,7 +775,9 @@ export const routes: readonly Route[] = [
737775 const body = fields(raw);
738776 const id = Number(c.params.id);
739777 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)
741781 ? updateFacts(c.db, c.who, { id, fields: fields(body.facts) })
742782 : body.tone !== undefined && body.kind === undefined
743783 ? 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 () =>
153153 "approve_asset",
154154 "retone_listing",
155155 "update_facts",
156 "update_photos",
156157 "delete_listing",
157158 "check_fair_housing",
158159 ]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs