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

Give each listing a property page to link from the posts and the email, showing only approved words #50

Merged opened this

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

Merged

mason/listing-launch:mason/47-give-each-listing-a-property-page-to-lin → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/47-give-each-listing-a-property-page-to-lin was merged into main as c4ed84a.

Review

Sign in to review this change.
20 files changed +430 −20
README.md +9
@@ -62,6 +62,15 @@ to make a listing of your own.
6262 facts, then every asset under its name, each marked "Approved by … on …"
6363 or "DRAFT — not approved", with its Fair Housing findings beneath it. Hand
6464 it to an assistant or a coordinator.
65 11. **Show buyers the home.** Under the facts on the listing, the property
66 page link (`/homes/:id`) has a Copy button; the flyer prints it and the
67 email's "See the home" button points to it. Open it on a phone: photos,
68 price, status, open house, your contact details, and the MLS description
69 only once you approved it. Until then everyone else gets a 404 and you see
70 "Not public yet: approve the MLS description". The link's origin is
71 `FORGE_APP_URL`, never whatever address the visitor typed; the terminal and the
72 launch kit print the same URL. The first sample's MLS text is approved so
73 the preview shows a page.
6574
6675 `src/walkthrough.test.ts` walks these steps on every `bun test`, so this
6776 list cannot drift from the app.
docs/design/areas/property.md added +8
@@ -0,0 +1,8 @@
1 # Property page
2
3 New component classes, all built from existing tokens:
4
5 - `home` (`home-hero`, `home-head`, `home-price`, `home-facts`, `home-open`, `home-words`, `home-agent`, `home-face`, `home-name`, `home-notice`) — the buyer's page at `/homes/:id`.
6 - `property-link` — the link and Copy button on the listing page.
7 - `flyer-link` — the property page address printed on the flyer.
8 - `email-cta`, `email-button` — the "See the home" button in the email.
src/actions.ts +13
@@ -19,6 +19,7 @@ import {
1919 fromRow,
2020 isKind,
2121 isTone,
22 publicDescription,
2223 touchesOpenHouses,
2324 validate,
2425 validateStatus,
@@ -500,6 +501,18 @@ export const getListing = (
500501 no: "You may not see this listing.",
501502 });
502503
504 /**
505 * A listing as its property page shows it: whoever may read it, once its MLS
506 * description is approved; before that, only whoever may change it. Everyone
507 * else is told there is no such home, as if there were none.
508 */
509 export const getHome = (db: Database, who: Who, input: { id: number }): Result<Launched> => {
510 const found = getListing(db, who, input);
511 if (found.ok && publicDescription(found.value) === null && !canEditListing(who, found.value))
512 return fail("not_found", "There is no such home.");
513 return found;
514 };
515
503516 /** The listing's launch kit as text: whoever may read the listing may read it. */
504517 export const launchKit = (db: Database, who: Who, input: { id: number }): Result<string> => {
505518 const found = getListing(db, who, input);
src/contract.test.ts +26
@@ -15,6 +15,7 @@ import {
1515 deleteNote,
1616 editAsset,
1717 getAudit,
18 getHome,
1819 getListing,
1920 getNote,
2021 insertListing,
@@ -248,3 +249,28 @@ test("changing a listing's photos answers as the listing's update cell says, at
248249 }
249250 expect(asked).toBeGreaterThan(0);
250251 });
252
253 /** The property page is `listing:read` once approved; before, only whoever may update the row sees it. */
254 test("a property page answers as the listing's read cell says once approved, and as its update cell before", () => {
255 const admin: Who = { role: "admin", id: "root", name: "root" };
256 let asked = 0;
257 for (const read of table(contract)) {
258 if (read.resource !== "listing" || read.verb !== "read") continue;
259 const who = whoIs(read.as);
260 const { mine, theirs } = actingAs(contract, read.as);
261 const update = table(contract).find((c) => c.resource === "listing" && c.verb === "update" && c.as === read.as)!;
262 for (const [authorId, owns] of [[String(mine["author_id"]), true], [String(theirs["author_id"]), false]] as const) {
263 const id = listingBy(authorId);
264 const before = getHome(db, who, { id }).ok;
265 const mayUpdate = update.answer === "yes" || (update.answer === "own" && owns);
266 expect([read.as, authorId, "before", before]).toEqual([read.as, authorId, "before", mayUpdate]);
267 const seen = getListing(db, admin, { id });
268 const updated = seen.ok ? seen.value.assets.find((a) => a.kind === "mls")!.updated_at : "";
269 approveAsset(db, admin, { id, kind: "mls", seen: updated, door: "page" });
270 const mayRead = read.answer === "yes" || (read.answer === "own" && owns);
271 expect([read.as, authorId, "after", getHome(db, who, { id }).ok]).toEqual([read.as, authorId, "after", mayRead]);
272 asked++;
273 }
274 }
275 expect(asked).toBeGreaterThan(0);
276 });
src/hostile.test.ts +9 −1
@@ -61,6 +61,14 @@ test("the hostile listing launches and its assets are edited", async () => {
6161 edit.set("text", PAYLOADS.asset);
6262 expect((await call(`/listings/${id}/assets/${kind}`, { method: "POST", body: edit })).status).toBe(303);
6363 }
64 // The property page shows the MLS words only once approved.
65 const page = await (await call(`/listings/${id}`)).text();
66 const seen = /name="seen" value="([^"]+)"/.exec(page)![1]!;
67 const approve = await call(`/listings/${id}/assets/mls/approve`, {
68 method: "POST",
69 body: new URLSearchParams({ approved: "1", seen }),
70 });
71 expect(approve.status).toBe(303);
6472 });
6573
6674 const floor = (r: Response) => {
@@ -82,7 +90,7 @@ const expectInert = (html: string) => {
8290 expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/);
8391 };
8492
85 for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/email"]) {
93 for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/email", "/homes/:id"]) {
8694 test(`${path} escapes every typed word`, async () => {
8795 const r = await call(path.replace(":id", String(id)));
8896 expect(r.status).toBe(200);
src/kit.ts +4 −2
@@ -6,7 +6,8 @@
66 */
77
88 import { factItems, money, openHouseLine } from "./generate.ts";
9 import type { Asset, Launched } from "./listing.ts";
9 import { publicOrigin } from "./env.ts";
10 import { propertyLine, type Asset, type Launched } from "./listing.ts";
1011
1112 // Control characters (keeping newline where a block may have lines) and the bidirectional overrides.
1213 const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g;
@@ -42,7 +43,7 @@ const asset = (a: Asset): string =>
4243 ].join("\n");
4344
4445 /** The kit's text. */
45 export function kit(l: Launched): string {
46 export function kit(l: Launched, origin = publicOrigin): string {
4647 const open = openHouseLine(l);
4748 const signed = l.assets.filter((a) => a.approval !== null).length;
4849 return `${[
@@ -51,6 +52,7 @@ export function kit(l: Launched): string {
5152 factItems(l).map((i) => `${oneLine(String(i.value))} ${oneLine(i.label)}`).join(" · "),
5253 ...(open === null ? [] : [oneLine(open)]),
5354 `Agent: ${oneLine(l.agent_name)} · ${oneLine(l.brokerage)} · License ${oneLine(l.license)} · ${oneLine(l.agent_phone)} · ${oneLine(l.agent_email)}`,
55 propertyLine(l, origin),
5456 `${signed} of ${l.assets.length} assets approved. Send only what is marked approved.`,
5557 "",
5658 ...l.assets.flatMap((a) => [asset(a), ""]),
src/listing.ts +14
@@ -368,3 +368,17 @@ export function assetText(v: unknown): Checked<string> {
368368 /** "Ready to send" once every asset is signed off, else how many are. */
369369 export const approvalLabel = (approved: number, total: number): string =>
370370 approved === total ? "Ready to send" : `${approved} of ${total} approved`;
371
372 /** Where buyers are sent: a listing's property page, absolute when an origin is given. */
373 export const propertyPath = (listing: Pick<Listing, "id">, origin = ""): string =>
374 `${origin}/homes/${listing.id}`;
375
376 /** One plain-text line for the terminal and the kit: the property page's URL, and whether buyers can open it yet. */
377 export const propertyLine = (l: Launched, origin: string): string =>
378 `Property page: ${propertyPath(l, origin)}${l.assets.find((a) => a.kind === "mls")?.approval == null ? " (not public yet: approve the MLS description)" : ""}`;
379
380 /** The MLS description as buyers may read it: its words once approved, else null. */
381 export const publicDescription = (l: Launched): string | null => {
382 const mls = l.assets.find((a) => a.kind === "mls");
383 return mls === undefined || mls.approval === null ? null : mls.text;
384 };
src/listings.test.ts +3 −1
@@ -505,7 +505,9 @@ test("the email blast is a page of its own, in the agent's brand", async () => {
505505 expect(page).toContain("data-subject>A home you will want to see: 18 Quarry Road<");
506506 expect(page.split(`>${SAMPLE.agent_name}<`).length - 1).toBe(1);
507507 expect(page).not.toContain("The facts:");
508 expect(page).not.toMatch(/(src|href)="https?:/);
508 // Only the property page link is absolute: it must work from a mail tool.
509 expect(page.replace(/href="https?:\/\/[^"/]+\/homes\/\d+"/, "")).not.toMatch(/(src|href)="https?:/);
510 expect(page).toMatch(/class="email-button brand-fill" href="http:\/\/[^"/]+\/homes\/\d+"/);
509511 // The Email tab links to it.
510512 expect(await (await fetch(base + to + "?tab=email")).text()).toContain(`href="${to}/email"`);
511513 });
src/pages/email.ts +5 −4
@@ -7,7 +7,7 @@
77
88 import { factItems, money } from "../generate.ts";
99 import { escape } from "../http.ts";
10 import type { Launched } from "../listing.ts";
10 import { propertyPath, type Launched } from "../listing.ts";
1111 import type { Who } from "../who.ts";
1212 import { layout } from "./layout.ts";
1313 import { draftBand, photoUrl } from "./listing.ts";
@@ -31,7 +31,7 @@ export function emailParts(
3131 }
3232
3333 /** The message itself, one article: what is copied is what is shown. */
34 export function email(l: Launched): string {
34 export function email(l: Launched, origin = ""): string {
3535 const { subject, paragraphs } = emailParts(
3636 l.assets.find((a) => a.kind === "email")?.text ?? "",
3737 l.agent_name,
@@ -46,6 +46,7 @@ export function email(l: Launched): string {
4646 <p class="email-price brand">${money(l.price)}</p>
4747 <p class="email-facts brand-fill">${factItems(l).map((i) => `${escape(i.value)} ${i.label}`).join(" · ")}</p>
4848 ${paragraphs.map((p) => `<p class="email-body">${escape(p).replace(/\n/g, "<br>")}</p>`).join("")}
49 <p class="email-cta"><a class="email-button brand-fill" href="${escape(propertyPath(l, origin))}">See the home</a></p>
4950 <div class="email-agent">
5051 ${l.headshot === null ? "" : `<img class="email-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`}
5152 <div>
@@ -59,7 +60,7 @@ export function email(l: Launched): string {
5960 </article>`;
6061 }
6162
62 export function emailPage(input: { who: Who; listing: Launched }): string {
63 export function emailPage(input: { who: Who; listing: Launched; origin?: string }): string {
6364 const { who, listing: l } = input;
6465 return layout(
6566 `<div class="print-bar row">
@@ -68,7 +69,7 @@ export function emailPage(input: { who: Who; listing: Launched }): string {
6869 <button type="button" class="button button-quiet" data-copy-subject hidden>Copy subject</button>
6970 <span class="muted" data-copy-note role="status">Paste it into your mail tool; the photos load from this page.</span>
7071 </div>
71 ${email(l)}`,
72 ${email(l, input.origin)}`,
7273 who,
7374 { path: `/listings/${l.id}/email`, heading: `Email: ${l.address}` },
7475 );
src/pages/listing.ts +15 −6
@@ -16,7 +16,7 @@ import {
1616 TONES,
1717 } from "../generate.ts";
1818 import { asset as assetHref, escape } from "../http.ts";
19 import { approvalLabel, ASSET_MAX, type Asset, type Launched, type Listing } from "../listing.ts";
19 import { approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts";
2020 import { isPost, lengthLine, meantForX, overXNote } from "../postlength.ts";
2121 import { isSample } from "../photos.ts";
2222 import type { Who } from "../who.ts";
@@ -97,7 +97,7 @@ export const statusPill = (l: Pick<Listing, "status" | "sold_price">): string =>
9797 ? ""
9898 : `<span class="pill status-pill status-${l.status}">${escape(statusLine(l))}</span>`;
9999
100 const statusLine = (l: Pick<Listing, "status" | "sold_price">): string =>
100 export const statusLine = (l: Pick<Listing, "status" | "sold_price">): string =>
101101 l.status === "sold" && l.sold_price !== null ? `Sold at ${money(l.sold_price)}` : STATUS_LABELS[l.status];
102102
103103 /** The agent's way to say where the listing stands: a choice, the price it sold at, one button. */
@@ -166,14 +166,21 @@ const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] =
166166 export const tabOf = (name: string | null): string =>
167167 TABS.find((t) => t.id === name || t.kinds.includes(name ?? ""))?.id ?? "mls";
168168
169 /** The property page link with a Copy button (the script shows it): what buyers get from posts and email. */
170 const propertyLink = (l: Launched, origin: string): string =>
171 `<p class="row property-link"><span>Property page</span> <a href="${propertyPath(l)}" data-property-url>${escape(propertyPath(l, origin))}</a>
172 <button type="button" class="button button-quiet" data-copy-link="${escape(propertyPath(l, origin))}" hidden aria-label="Copy the property page link">Copy</button></p>`;
173
169174 export function listingPage(input: {
170175 who: Who;
171176 listing: Launched;
177 origin?: string;
172178 editable: boolean;
173179 tab?: string | null;
174180 notice?: string | undefined;
175181 }): string {
176182 const { who, listing: l, editable, notice } = input;
183 const origin = input.origin ?? "";
177184 const open = tabOf(input.tab ?? null);
178185 const n = flags(l);
179186 const tabFlags = (kinds: readonly string[]) =>
@@ -212,6 +219,7 @@ export function listingPage(input: {
212219 </div>
213220 </header>
214221 ${editable ? "" : `<p class="card read-only">You are reading ${escape(l.author_name === "Sample listing" ? "the sample listing" : `${l.author_name}'s listing`)}. <a href="/?from=${l.id}">Start a new one from it</a> to make the words your own.</p>`}
222 ${propertyLink(l, origin)}
215223 ${l.photos.length === 0 ? "" : `<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="${i < 3 ? "eager" : "lazy"}"></li>`).join("")}</ul>`}
216224 <div class="tabs" data-tabs>
217225 <nav class="tablist" role="tablist" aria-label="Assets">${TABS.map(
@@ -219,7 +227,7 @@ export function listingPage(input: {
219227 ).join("")}</nav>
220228 ${TABS.map(
221229 (t) => `<div class="tabpanel" role="tabpanel" id="panel-${t.id}" aria-labelledby="tab-${t.id}"${t.id === open ? "" : " hidden"}>
222 ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l)}</a></div>` : ""}
230 ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l, origin)}</a></div>` : ""}
223231 ${t.id === "email" ? `<p><a class="button button-quiet" href="/listings/${l.id}/email">Preview email</a></p>` : ""}
224232 ${l.assets
225233 .filter((a) => t.kinds.includes(a.kind))
@@ -243,7 +251,7 @@ export function listingPage(input: {
243251 }
244252
245253 /** The flyer: the showpiece, a letter-size page in the agent's colours. */
246 export function flyer(l: Launched): string {
254 export function flyer(l: Launched, origin = ""): string {
247255 const copy = flyerParts(l.assets.find((a) => a.kind === "flyer")?.text ?? "", openHouseLine(l));
248256 const [hero, ...more] = l.photos;
249257 return `<article class="flyer palette-${l.palette}">
@@ -276,12 +284,13 @@ export function flyer(l: Launched): string {
276284 <p>${escape(l.agent_phone)} · ${escape(l.agent_email)}</p>
277285 <p class="flyer-license">License ${escape(l.license)}</p>
278286 </div>
287 <p class="flyer-link">See every photo: ${escape(propertyPath(l, origin))}</p>
279288 <p class="flyer-fine">Equal Housing Opportunity. Information provided by the listing agent; deemed reliable, not guaranteed.</p>
280289 </footer>
281290 </article>`;
282291 }
283292
284 export function flyerPage(input: { who: Who; listing: Launched }): string {
293 export function flyerPage(input: { who: Who; listing: Launched; origin?: string }): string {
285294 const { who, listing: l } = input;
286295 return layout(
287296 `<div class="print-bar row">
@@ -289,7 +298,7 @@ export function flyerPage(input: { who: Who; listing: Launched }): string {
289298 <button type="button" class="button" data-print hidden>Print</button>
290299 <span class="muted">Letter size; set margins to none for edge-to-edge.</span>
291300 </div>
292 ${flyer(l)}`,
301 ${flyer(l, input.origin)}`,
293302 who,
294303 { path: `/listings/${l.id}/flyer`, heading: `Flyer: ${l.address}`, kind: "print" },
295304 );
src/pages/property.ts added +49
@@ -0,0 +1,49 @@
1 /**
2 * The property page for buyers, at `/homes/:id`: the photos, the facts, the
3 * agent, and the MLS description only once someone approved it. It carries no
4 * control, no approval state and no draft text; the author alone, before
5 * approval, sees a notice that it is not public yet.
6 */
7
8 import { factItems, money, whenOf } from "../generate.ts";
9 import { escape } from "../http.ts";
10 import { propertyPath, publicDescription, type Launched } from "../listing.ts";
11 import type { Who } from "../who.ts";
12 import { layout } from "./layout.ts";
13 import { photoUrl, statusLine } from "./listing.ts";
14
15 export function propertyPage(input: { who: Who; listing: Launched }): string {
16 const { who, listing: l } = input;
17 const words = publicDescription(l);
18 const [hero, ...more] = l.photos;
19 const status = l.status === "active" ? "Active" : statusLine(l);
20 return layout(
21 `<article class="home palette-${escape(l.palette)}">
22 ${words === null ? `<p class="card home-notice" role="note">Not public yet: approve the MLS description</p>` : ""}
23 ${hero === undefined ? "" : `<img class="home-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`}
24 <header class="home-head">
25 <h2>${escape(l.address)}</h2>
26 <p class="home-price brand">${money(l.price)}</p>
27 <p><span class="pill status-pill status-${l.status}">${escape(status)}</span></p>
28 </header>
29 <ul class="home-facts brand-fill">
30 ${factItems(l).map((i) => `<li><b>${escape(i.value)}</b> ${i.label}</li>`).join("\n ")}
31 </ul>
32 ${l.open_houses.length === 0 || l.status !== "active" ? "" : `<p class="home-open">Open house: ${l.open_houses.map((o) => escape(whenOf(o))).join(" and ")}</p>`}
33 ${words === null ? "" : `<p class="home-words">${escape(words).replace(/\n/g, "<br>")}</p>`}
34 ${more.length === 0 ? "" : `<ul class="strip">${more.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="Photo ${i + 2} of ${escape(l.address)}" width="240" height="160" loading="lazy"></li>`).join("")}</ul>`}
35 <footer class="home-agent card">
36 ${l.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`}
37 <div>
38 <p class="home-name brand">${escape(l.agent_name)}</p>
39 <p>${escape(l.brokerage)} · License ${escape(l.license)}</p>
40 <p><a href="tel:${escape(l.agent_phone.replace(/[^0-9+]/g, ""))}">${escape(l.agent_phone)}</a></p>
41 <p><a href="mailto:${escape(l.agent_email)}">${escape(l.agent_email)}</a></p>
42 </div>
43 </footer>
44 <p class="muted"><small>Equal Housing Opportunity.</small></p>
45 </article>`,
46 who,
47 { path: propertyPath(l), heading: l.address },
48 );
49 }
src/property.test.ts added +126
@@ -0,0 +1,126 @@
1 /** The property page: approved words only, hidden until then, linked from the flyer and the email. */
2 import { afterAll, expect, test } from "bun:test";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { publicOrigin } from "./env.ts";
7 import { serve } from "./server.ts";
8 import { sign } from "./who.ts";
9
10 const KEY = "a-key-only-the-forge-holds";
11 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "property-")), identityKey: KEY });
12 const base = `http://${server.hostname}:${server.port}`;
13 afterAll(() => server.stop(true));
14
15 const as = (id: string) => {
16 const identity = `${id}|${id}|user|${Date.now() + 600_000}`;
17 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
18 };
19 const call = (path: string, who?: string, init: RequestInit = {}) =>
20 fetch(base + path, { redirect: "manual", ...init, headers: who === undefined ? {} : as(who) });
21
22 const form = new FormData();
23 for (const [k, v] of Object.entries({
24 address: "7 Property Pl, Portland, OR 97201", beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000,
25 tone: "classic", palette: "teal", agent_name: "Pat Agent", brokerage: "Harbor Realty", license: "OR-1",
26 agent_phone: "+1 (503) 555-0100", agent_email: "[email protected]", lot: "0.2 acre", features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors",
27 }))
28 form.set(k, String(v));
29
30 let id = 0;
31 test("a launched listing", async () => {
32 const r = await call("/listings", "pat", { method: "POST", body: form });
33 expect(r.status).toBe(303);
34 id = Number(/\/listings\/(\d+)/.exec(r.headers.get("location")!)![1]);
35 });
36
37 const DRAFT = "DRAFT-ONLY-WORDS";
38 test("an edited draft never reaches buyers, and anon gets 404 before approval", async () => {
39 const edit = new FormData();
40 edit.set("text", `${DRAFT} for sale.`);
41 await call(`/listings/${id}/assets/mls`, "pat", { method: "POST", body: edit });
42 const anon = await call(`/homes/${id}`);
43 expect(anon.status).toBe(404);
44 expect(await anon.text()).not.toContain(DRAFT);
45 expect((await call(`/homes/${id}`, "other")).status).toBe(404);
46 });
47
48 test("the author sees the notice, no draft text and no controls", async () => {
49 const r = await call(`/homes/${id}`, "pat");
50 expect(r.status).toBe(200);
51 const html = await r.text();
52 expect(html).toContain("Not public yet: approve the MLS description");
53 expect(html).not.toContain(DRAFT);
54 expect(html).toContain("7 Property Pl");
55 });
56
57 test("once approved, anyone reads the words, the facts and the agent; nothing to edit", async () => {
58 const page = await (await call(`/listings/${id}`, "pat")).text();
59 const seen = /name="seen" value="([^"]+)"/.exec(page)![1]!;
60 // The first approve control on the page is the MLS asset's.
61 const approve = new URLSearchParams({ approved: "1", seen });
62 const done = await call(`/listings/${id}/assets/mls/approve`, "pat", {
63 method: "POST",
64 body: approve,
65 headers: { ...as("pat"), "content-type": "application/x-www-form-urlencoded" },
66 });
67 expect(done.status).toBe(303);
68 const r = await call(`/homes/${id}`);
69 expect(r.status).toBe(200);
70 const html = await r.text();
71 expect(html).toContain(DRAFT);
72 for (const s of ["$500,000", "Active", "Pat Agent", "+1 (503) 555-0100", "[email protected]", "palette-teal"])
73 expect(html).toContain(s);
74 expect(html).not.toContain("Not public yet");
75 expect(html).not.toMatch(/<textarea|<form|<button|fh-pill|Draft —/);
76 });
77
78 test("withdrawing the approval hides the page again", async () => {
79 const done = await call(`/listings/${id}/assets/mls/approve`, "pat", {
80 method: "POST",
81 body: new URLSearchParams({ approved: "0" }),
82 headers: { ...as("pat"), "content-type": "application/x-www-form-urlencoded" },
83 });
84 expect(done.status).toBe(303);
85 expect((await call(`/homes/${id}`)).status).toBe(404);
86 // restore for the next tests
87 const page = await (await call(`/listings/${id}`, "pat")).text();
88 const seen = /name="seen" value="([^"]+)"/.exec(page)![1]!;
89 await call(`/listings/${id}/assets/mls/approve`, "pat", {
90 method: "POST",
91 body: new URLSearchParams({ approved: "1", seen }),
92 headers: { ...as("pat"), "content-type": "application/x-www-form-urlencoded" },
93 });
94 });
95
96 test("a sold listing keeps its page, showing Sold", async () => {
97 await call(`/listings/${id}/status`, "pat", {
98 method: "POST",
99 body: new URLSearchParams({ status: "sold", sold_price: "510000" }),
100 headers: { ...as("pat"), "content-type": "application/x-www-form-urlencoded" },
101 });
102 const html = await (await call(`/homes/${id}`)).text();
103 expect(html).toContain("Sold at $510,000");
104 });
105
106 test("the listing page, the flyer and the email link to it by the configured public origin", async () => {
107 const url = `${publicOrigin}/homes/${id}`;
108 const listing = await (await call(`/listings/${id}`, "pat")).text();
109 expect(listing).toContain(`data-copy-link="${url}"`);
110 expect(listing).toContain(url);
111 expect(await (await call(`/listings/${id}/flyer`)).text()).toContain(url);
112 expect(await (await call(`/listings/${id}/email`)).text()).toContain(`href="${url}"`);
113 });
114
115 test("a spoofed Host never reaches the flyer or the email link", async () => {
116 const host = { host: "evil.example" };
117 for (const path of [`/listings/${id}`, `/listings/${id}/flyer`, `/listings/${id}/email`]) {
118 const html = await (await fetch(base + path, { headers: { ...as("pat"), ...host } })).text();
119 expect(html).toContain(`${publicOrigin}/homes/${id}`);
120 expect(html).not.toContain("evil.example");
121 }
122 });
123
124 test("an unknown home is 404", async () => {
125 expect((await call("/homes/99999")).status).toBe(404);
126 });
src/routes.ts +13 −2
@@ -23,6 +23,7 @@ import {
2323 deleteNote,
2424 editAsset,
2525 getAudit,
26 getHome,
2627 getListing,
2728 getNote,
2829 launchListing,
@@ -64,6 +65,8 @@ import { draftOf, home, type Draft } from "./pages/home.ts";
6465 import { layout } from "./pages/layout.ts";
6566 import { factsPage } from "./pages/facts.ts";
6667 import { emailPage } from "./pages/email.ts";
68 import { publicOrigin } from "./env.ts";
69 import { propertyPage } from "./pages/property.ts";
6770 import { isPost, lengthLine } from "./postlength.ts";
6871 import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
6972 import { notesPage } from "./pages/notes.ts";
@@ -465,6 +468,7 @@ export const routes: readonly Route[] = [
465468 listingPage({
466469 who: c.who,
outdated

c.url.origin comes from the client-supplied Host. Use a configured public origin for links that leave the app (email, flyer).

467470 listing: found.value,
471 origin: publicOrigin,
468472 editable: canEditListing(c.who, found.value),
469473 tab: c.url.searchParams.get("tab"),
470474 notice,
@@ -522,12 +526,19 @@ export const routes: readonly Route[] = [
522526 : back(done.message, to);
523527 },
524528 },
529 {
530 method: "GET",
531 path: "/homes/:id",
532 handle: (c) => {
outdated

Add the contract.test.ts row for this door.

533 return page(c.who, getHome(c.db, c.who, { id: Number(c.params.id) }), (listing) => propertyPage({ who: c.who, listing }));
534 },
535 },
525536 {
526537 method: "GET",
527538 path: "/listings/:id/flyer",
528539 handle: (c) =>
529540 page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) =>
530 flyerPage({ who: c.who, listing }),
541 flyerPage({ who: c.who, listing, origin: publicOrigin }),
531542 ),
532543 },
533544 {
@@ -551,7 +562,7 @@ export const routes: readonly Route[] = [
551562 path: "/listings/:id/email",
552563 handle: (c) =>
553564 page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) =>
554 emailPage({ who: c.who, listing }),
565 emailPage({ who: c.who, listing, origin: publicOrigin }),
555566 ),
556567 },
557568 {
src/sample.ts +6 −1
@@ -135,7 +135,12 @@ export function seed(db: Database): void {
135135 for (const s of SAMPLES) {
136136 const done = db.query("INSERT OR IGNORE INTO seeded (name) VALUES (?1)").run(s.name);
137137 if (done.changes === 0) continue;
138 insertListing(db, { id: "sample", name: "Sample listing" }, s.listing, s.photos, s.headshot);
138 const made = insertListing(db, { id: "sample", name: "Sample listing" }, s.listing, s.photos, s.headshot);
139 // The first sample's MLS text is approved, so the preview's property page shows words, not a 404.
140 if (s.name === "sample")
141 db.query(
142 "UPDATE asset SET approved_by_id = 'sample', approved_by_name = 'Sample listing', approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), approved_via = 'page' WHERE listing_id = ?1 AND kind = 'mls'",
143 ).run(made.id);
139144 }
140145 // A sample planted before statuses existed is marked sold once too; after that it is the owner's to change.
141146 for (const s of SAMPLES) {
src/speed.test.ts +23
@@ -39,6 +39,29 @@ for (const path of PAGES) {
3939 });
4040 }
4141
42 test("a property page answers in under 50 ms at the 95th percentile", async () => {
43 // A sample is not approved, so its page is hidden: approve the MLS description as its author would.
44 const identity = `speed-agent|Speed Agent|user|${Date.now() + 600_000}`;
45 const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
46 const form = new FormData();
47 for (const [k, v] of Object.entries({ ...SAMPLE, features: SAMPLE.features.join("\n") })) form.set(k, String(v));
48 const made = await fetch(base + "/listings", { method: "POST", body: form, headers, redirect: "manual" });
49 const to = made.headers.get("location")!;
50 const id = /\/listings\/(\d+)/.exec(to)![1];
51 const seen = /name="seen" value="([^"]+)"/.exec(await (await fetch(base + to, { headers })).text())![1]!;
52 await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: new URLSearchParams({ approved: "1", seen }), headers, redirect: "manual" });
53 const path = `/homes/${id}`;
54 expect((await fetch(base + path)).status).toBe(200);
55 const times: number[] = [];
56 for (let i = 0; i < 50; i++) {
57 const start = performance.now();
58 await (await fetch(base + path)).text();
59 times.push(performance.now() - start);
60 }
61 times.sort((a, b) => a - b);
62 expect(times[47]).toBeLessThan(50);
63 });
64
4265 test("static files are cached for a year, by their address", async () => {
4366 const page = await (await fetch(base + "/")).text();
4467 const hrefs = [...page.matchAll(/(?:href|src)="(\/(?:static|tokens)[^"]+)"/g)].map(
src/tty.test.ts +13
@@ -8,6 +8,7 @@ import { mkdtempSync } from "node:fs";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { editAsset, getListing, insertListing } from "./actions.ts";
11 import { publicOrigin } from "./env.ts";
1112 import { openDb } from "./db.ts";
1213 import { SAMPLE } from "./sample.ts";
1314 import { fromLine, session, toLine } from "./tty.ts";
@@ -223,3 +224,15 @@ test("the terminal shows a social post's length, and a short post over X needs a
223224 const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%'").all();
224225 expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x"]);
225226 });
227
228 test("the listing view shows the property page URL, and that it is not public until the MLS text is approved", () => {
229 const { db, id } = fresh();
230 const tty = session(db, me);
231 tty.press("\r");
232 const url = `${publicOrigin}/homes/${id}`;
233 expect(plain(tty.screen())).toContain(`Property page: ${url} (not public yet: approve the MLS description)`);
234 tty.press("a"); // approve the MLS description under the cursor
235 const after = plain(tty.screen());
236 expect(after).toContain(`Property page: ${url}`);
237 expect(after).not.toContain("not public yet");
238 });
src/tty.ts +3 −3
@@ -34,9 +34,9 @@ import {
3434 updateFacts,
3535 useSuggestion,
3636 } from "./actions.ts";
37 import { ssh, title, where } from "./env.ts";
37 import { publicOrigin, ssh, title, where } from "./env.ts";
3838 import { kit } from "./kit.ts";
39 import { approvalLabel } from "./listing.ts";
39 import { approvalLabel, propertyLine } from "./listing.ts";
4040 import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts";
4141 import { emailParts } from "./pages/email.ts";
4242 import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts";
@@ -133,7 +133,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
133133 }
134134 const signed = l.assets.filter((a) => a.approval !== null).length;
135135 const whenLine = openHouseLine(l);
136 const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}${l.status === "active" ? "" : ` · ${statusToLine(l)}`}`), ...(whenLine === null ? [] : [whenLine]), ""];
136 const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}${l.status === "active" ? "" : ` · ${statusToLine(l)}`}`), ...(whenLine === null ? [] : [whenLine]), dim(propertyLine(l, publicOrigin)), ""];
137137 // Scroll so the asset under the cursor starts the page.
138138 const body = l.assets.slice(cursor).flatMap((a, i) => [
139139 `${i === 0 ? "▸" : " "} ${i === 0 ? bold(a.label) : a.label} ${a.check.clear ? green("Fair Housing: clear") : amber(`Fair Housing: ${a.check.findings.length} to review`)}${a.edited ? dim(" edited") : ""}${a.approval === null ? "" : green(` approved by ${a.approval.by_name}`)}`,
src/walkthrough.test.ts +4
@@ -241,4 +241,8 @@ test("every step of the section works, with the labels it names", async () => {
241241 const flyer = await get(`${mine}/flyer`);
242242 expect(flyer.status).toBe(200);
243243 expect(await flyer.text()).toContain("9 Walkthrough Way");
244
245 // 10. The property page: linked from the listing, hidden from buyers until the MLS description is approved.
246 expect(await (await get(mine)).text()).toContain("data-copy-link=");
247 expect((await fetch(`${base}${mine.replace("/listings/", "/homes/")}`)).status).toBe(404);
244248 });
static/app.css +76
@@ -972,3 +972,79 @@ main:focus {
972972 .status-form {
973973 flex-wrap: wrap;
974974 }
975
976 /* The property page buyers read, and the links that lead to it. */
977 .property-link {
978 flex-wrap: wrap;
979 margin-bottom: var(--space-4);
980 overflow-wrap: anywhere;
981 }
982 .flyer-link {
983 flex-basis: 100%;
984 font-size: var(--text-sm);
985 overflow-wrap: anywhere;
986 }
987 .email-cta {
988 margin: var(--space-4) 0;
989 }
990 .email-button {
991 display: inline-block;
992 padding: var(--space-3) var(--space-5);
993 border-radius: var(--radius-md);
994 font-weight: 600;
995 text-decoration: none;
996 }
997 .home {
998 max-width: 48rem;
999 margin: 0 auto;
1000 }
1001 .home-notice {
1002 font-weight: 600;
1003 }
1004 .home-hero {
1005 display: block;
1006 width: 100%;
1007 height: auto;
1008 aspect-ratio: 3 / 2;
1009 object-fit: cover;
1010 border-radius: var(--radius-md);
1011 margin-bottom: var(--space-4);
1012 }
1013 .home-price {
1014 font-family: var(--font-display);
1015 font-size: var(--text-xl);
1016 font-weight: 600;
1017 margin: 0 0 var(--space-2);
1018 }
1019 .home-facts {
1020 display: flex;
1021 flex-wrap: wrap;
1022 gap: var(--space-2) var(--space-4);
1023 list-style: none;
1024 margin: var(--space-4) 0;
1025 padding: var(--space-3) var(--space-4);
1026 border-radius: var(--radius-md);
1027 }
1028 .home-open {
1029 font-weight: 600;
1030 }
1031 .home-words {
1032 line-height: var(--leading-body);
1033 margin: var(--space-4) 0;
1034 }
1035 .home-agent {
1036 display: flex;
1037 flex-wrap: wrap;
1038 gap: var(--space-4);
1039 align-items: center;
1040 }
1041 .home-agent p {
1042 margin: 0;
1043 }
1044 .home-name {
1045 font-weight: 600;
1046 }
1047 .home-face {
1048 border-radius: 50%;
1049 object-fit: cover;
1050 }
static/listing.js +11
@@ -54,6 +54,17 @@ for (const button of document.querySelectorAll("[data-copy]")) {
5454 });
5555 }
5656
57 for (const button of document.querySelectorAll("[data-copy-link]")) {
58 if (!navigator.clipboard) continue;
59 button.hidden = false;
60 button.addEventListener("click", async () => {
61 await navigator.clipboard.writeText(button.dataset.copyLink);
62 button.textContent = "Copied";
63 announce("Copied the property page link");
64 setTimeout(() => (button.textContent = "Copy"), 1500);
65 });
66 }
67
5768 for (const area of document.querySelectorAll("textarea[data-check]")) {
5869 const kind = area.dataset.check;
5970 const aside = document.querySelector(`[data-fh="${kind}"]`);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs