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

Each week, get a seller update email to approve: what went out for their listing, what is scheduled next, and the property page link #86

Merged opened this

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

Merged

mason/listing-launch:mason/75-each-week-get-a-seller-update-email-to-a → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/75-each-week-get-a-seller-update-email-to-a was merged into main as c7e3bc9.

Review

Sign in to review this change.
22 files changed +413 −68
AGENTS.md +3 −1
@@ -109,7 +109,9 @@ To add a concept:
109109 the words (`generate(listing)`: plain data in, six texts out — a model goes
110110 behind that one function), `src/fairhousing.ts` the check (`check(text)`:
111111 rules with context, never stored, run on every read), `src/photos.ts` the
112 files (sniffed by their bytes, named by the app, beside the database). The
112 files (sniffed by their bytes, named by the app, beside the database),
113 `src/update.ts` the weekly seller update (`sellerUpdate(listing, now)`: the listing
114 and the clock in, plain text out; stored as the asset `email_update`). The
113115 actions in `src/actions.ts` put them together; none of the four knows who
114116 is asking. A new Fair Housing rule is a row in `RULES` and a case in
115117 `src/fairhousing.test.ts`; the generator test holds every template clear of
README.md +17 −6
@@ -23,11 +23,11 @@ to make a listing of your own.
2323 is only to read; the new listing is yours to change.
2424 4. **Approve one.** On your listing, read the MLS description, then press
2525 **Approve** under it (**Approve with 1 flag** when the check still has
26 something to say). The top of the page now says "1 of 6 approved".
26 something to say). The top of the page now says "1 of 7 approved".
2727 5. **Change the tone.** Pick another tone beside the address (Classic, Warm
2828 or Bold) and press **Rewrite all**. Every asset is written again in that
2929 tone, your edits are replaced, and the approval is cleared: it says
30 "0 of 6 approved" again, because you have not read the new words yet.
30 "0 of 7 approved" again, because you have not read the new words yet.
3131 6. **Put back your MLS words.** Under the MLS box, press **Earlier versions
3232 (1)**: it lists the words the rewrite replaced, when, by whom and why
3333 ("rewrite in Bold"), and the first line of each. Press **Restore** by
@@ -44,7 +44,7 @@ to make a listing of your own.
4444 square feet, lot, year, price, features and brand. Lower the price and
4545 press **Save and write all again**: the new price is on the flyer, every
4646 asset is written again from the facts (your edits are replaced), and the
47 approval is cleared, so it says "0 of 6 approved". The photos stay.
47 approval is cleared, so it says "0 of 7 approved". The photos stay.
4848 8. **Add an open house.** Press **Edit facts** again. Under "Open house"
4949 pick a date, a start and an end time (Alder Lane's sample already has one),
5050 then press **Save and write all again**. The flyer gets a band under the
@@ -83,7 +83,7 @@ to make a listing of your own.
8383 status**. Two more assets appear, a status post under Social and a status
8484 email under Email ("Just sold — 9 Walkthrough Way"), written in the
8585 listing's tone and checked like the rest; each needs your approval, so it
86 says "0 of 8 approved". The other assets and your approvals stay. The
86 says "0 of 9 approved". The other assets and your approvals stay. The
8787 flyer wears a "Sold" band, and the list on the first screen shows the
8888 status beside the approval count. *88 Meridian Street* is already sold, so
8989 you can see it. **Under contract** does the same with its own words, and
@@ -142,6 +142,17 @@ to make a listing of your own.
142142 time, no time zones), launch day being the day the listing was created.
143143 Nothing is sent; the app only drafts. In the terminal, `w` on an open
144144 listing shows the same plan as text.
145 15. **Draft the seller update.** Open the **Seller update** tab on a listing: a
146 weekly email to the seller, written in the agent's name from what the app
147 knows — the address and status, every asset approved in the last seven
148 days (who and when), what the launch week has in the next seven days, the
149 open house dates and the property page link. Edit it, read the Fair Housing
150 check, then **Approve** it like any asset; until then it wears "Draft — not
151 approved". It follows approvals while it is an unedited draft, and editing
152 the facts or the status clears its approval. An assistant cannot approve it.
153 In the terminal it is the last asset of the listing: `j` to it, `a` to approve
154 or withdraw. The seller report leaves it out; it is a note, not something
155 that goes out.
145156
146157 `src/walkthrough.test.ts` walks these steps on every `bun test`, so this
147158 list cannot drift from the app.
@@ -218,7 +229,7 @@ list cannot drift from the app.
218229 asset under the cursor.
219230 Each asset also has an **Approve** button ("Approve with 2 flags" when
220231 the check still has findings): it records who signed off and when, the
221 header counts "4 of 6 approved" and turns to "Ready to send" at 6 of 6,
232 header counts "4 of 7 approved" and turns to "Ready to send" at 7 of 7,
222233 and editing an asset or **Rewrite all** clears the approval. The same
223234 action is `a` in the terminal; `PATCH /api/listings/:id` with
224235 `{ kind, approved: false }` and the `approve_asset` MCP tool can withdraw,
@@ -227,7 +238,7 @@ list cannot drift from the app.
227238 if they changed, it answers 409 and approves nothing. Approval is a
228239 record, not a gate: Copy and Print flyer do not look at it.
229240 Each listing on the home list (and in the terminal's list) reads "Ready to send"
230 or "3 of 6 approved" beside its Fair Housing pill, live through `/events`;
241 or "3 of 7 approved" beside its Fair Housing pill, live through `/events`;
231242 `GET /api/listings` and the MCP list carry `approvals` (`kind`, `approved`) per
232243 listing. The flyer and the email preview wear a "Draft — not approved" band
233244 (on paper too; the terminal's preview says it in text) until their asset is approved.
contract/openapi.json +6 −3
@@ -652,7 +652,8 @@
652652 "email_price",
653653 "email_followup",
654654 "social_reminder",
655 "email_reminder"
655 "email_reminder",
656 "email_update"
656657 ]
657658 },
658659 "text": {
@@ -1347,7 +1348,8 @@
13471348 "email_price",
13481349 "email_followup",
13491350 "social_reminder",
1350 "email_reminder"
1351 "email_reminder",
1352 "email_update"
13511353 ]
13521354 },
13531355 "label": {
@@ -1695,7 +1697,8 @@
16951697 "email_price",
16961698 "email_followup",
16971699 "social_reminder",
1698 "email_reminder"
1700 "email_reminder",
1701 "email_update"
16991702 ]
17001703 },
17011704 "approved": {
src/actions.ts +53 −1
@@ -49,6 +49,7 @@ import {
4949 write as writePhoto,
5050 } from "./photos.ts";
5151 import { headshotFrom } from "./agent.ts";
52 import { sellerUpdate } from "./update.ts";
5253 import { validVisitor, VISITORS_MAX, type Visitor } from "./visitors.ts";
5354 import type { Who } from "./who.ts";
5455
@@ -380,6 +381,8 @@ function writeAssets(
380381 );
381382 const drop = db.query("DELETE FROM asset WHERE listing_id = ?1 AND kind = ?2");
382383 for (const kind of only) {
384 // The seller update is drafted from the other assets' approvals, so it is written after them (below).
385 if (kind === "email_update") continue;
383386 const body = words[kind];
384387 if (body === undefined) drop.run(listing.id, kind);
385388 else {
@@ -387,6 +390,45 @@ function writeAssets(
387390 put.run(listing.id, kind, body);
388391 }
389392 }
393 if (only.includes("email_update")) {
394 const body = sellerUpdate(launched(db, listing.id)!, new Date());
395 if (by !== undefined) keepWords(db, listing.id, "email_update", body, by.who, by.why);
396 put.run(listing.id, "email_update", body);
397 }
398 }
399
400 /**
401 * The seller update follows the approvals it reports: when another asset is
402 * approved or withdrawn, a draft nobody edited or approved is written again.
403 */
404 export function redraftUpdate(db: Database, id: number): void {
405 const update = db
406 .query<{ body: string }, [number]>(
407 "SELECT body FROM asset WHERE listing_id = ?1 AND kind = 'email_update' AND edited = 0 AND approved_at IS NULL",
408 )
409 .get(id);
410 if (update === null) return;
411 const body = sellerUpdate(launched(db, id)!, new Date());
412 if (body !== update.body)
413 db.query(
414 "UPDATE asset SET body = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?1 AND kind = 'email_update'",
415 ).run(id, body);
416 }
417
418 /** A listing launched before the seller update existed gets its draft, once; the ids drafted. */
419 export function draftMissingUpdates(db: Database): number[] {
420 const ids = db
421 .query<{ id: number }, []>(
422 "SELECT id FROM listing WHERE id NOT IN (SELECT listing_id FROM asset WHERE kind = 'email_update')",
423 )
424 .all()
425 .map((r) => r.id);
426 for (const id of ids)
427 db.query("INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, 'email_update', ?2, 0)").run(
428 id,
429 sellerUpdate(launched(db, id)!, new Date()),
430 );
431 return ids;
390432 }
391433
392434 /** How many earlier versions of one asset's words are kept. */
@@ -480,7 +522,14 @@ export function markStatus(
480522 (remind && (REMINDER_KINDS as readonly string[]).includes(k)) ||
481523 db.query("SELECT 1 FROM asset WHERE listing_id = ?1 AND kind = ?2 AND edited = 0").get(id, k) !== null,
482524 );
483 writeAssets(db, fromRow(row), kinds, undefined, isBackOnMarket(before.status, stand.status));
525 // The seller update is written again with the status unless a person edited it.
526 const updateDrafted = db.query("SELECT 1 FROM asset WHERE listing_id = ?1 AND kind = 'email_update' AND edited = 0").get(id) !== null;
527 const written = updateDrafted && !kinds.includes("email_update") ? [...kinds, "email_update" as const] : kinds;
528 writeAssets(db, fromRow(row), written, undefined, isBackOnMarket(before.status, stand.status));
529 // A new status is new words for the seller: an update a person edited keeps them, but loses its approval.
530 db.query(
531 "UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL WHERE listing_id = ?1 AND kind = 'email_update' AND edited = 1",
532 ).run(id);
484533 // Words a person edited keep everything they wrote but the open house, which an off-market listing no longer has.
485534 if (isOffMarket(stand.status) && !isOffMarket(before.status)) {
486535 const facts = { ...fromRow(row), status: "active" as const };
@@ -726,6 +775,7 @@ function edit(
726775 db.query(
727776 "UPDATE asset SET body = ?1, edited = 1, approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?2 AND kind = ?3",
728777 ).run(text.value, found.value.id, input.kind);
778 if (input.kind !== "email_update") redraftUpdate(db, found.value.id);
729779 touch(db, found.value.id);
730780 })();
731781 emit(db, "listing.changed", { id: found.value.id });
@@ -965,6 +1015,7 @@ function approve(
9651015 db.query(
9661016 "UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL WHERE listing_id = ?1 AND kind = ?2",
9671017 ).run(found.value.id, input.kind);
1018 if (input.kind !== "email_update") redraftUpdate(db, found.value.id);
9681019 emit(db, "listing.changed", { id: found.value.id });
9691020 return ok(launched(db, found.value.id)!);
9701021 }
@@ -1208,6 +1259,7 @@ function changePhotos(
12081259 `UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL
12091260 WHERE listing_id = ?1 AND kind = ?2`,
12101261 ).run(now.id, kind);
1262 if (reshown) redraftUpdate(db, now.id);
12111263 for (const f of files) writePhoto(db, f.name, f.bytes);
12121264 })();
12131265 } catch (error) {
src/backonmarket.test.ts +1 −1
@@ -86,7 +86,7 @@ test("sold → active, and coming soon → active, take the status words away",
8686 const { db, id } = launch();
8787 setStatus(db, me, { id, status: from });
8888 setStatus(db, me, { id, status: "active" });
89 expect(get(db, id).assets).toHaveLength(6);
89 expect(get(db, id).assets).toHaveLength(7);
9090 }
9191 });
9292
src/comingsoon.test.ts +4 −4
@@ -46,7 +46,7 @@ test("coming soon is offered first, and the teaser says the facts only, in each
4646 const kinds = soon.value.assets.map((a) => a.kind);
4747 expect(kinds).toContain("social_status");
4848 expect(kinds).toContain("email_status");
49 expect(soon.value.assets).toHaveLength(8);
49 expect(soon.value.assets).toHaveLength(9);
5050 for (const a of soon.value.assets.filter((a) => a.kind.endsWith("_status"))) {
5151 expect(a.text).toMatch(/coming soon/i);
5252 expect(a.text).toContain("412 Alder Lane");
@@ -66,14 +66,14 @@ test("each is approved on its own, core approvals stay, and Active or another st
6666 const soon = setStatus(db, me, { id, status: "coming_soon" });
6767 expect(soon.ok && soon.value.assets.filter((a) => a.approval !== null).map((a) => a.kind)).toEqual(["mls"]);
6868 const summary = listListings(db, me);
69 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(8);
69 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(9);
7070
7171 const sold = setStatus(db, me, { id, status: "sold" });
7272 expect(sold.ok && sold.value.assets.find((a) => a.kind === "social_status")!.text).toContain("Just sold");
7373 const again = setStatus(db, me, { id, status: "coming_soon" });
7474 expect(again.ok && again.value.assets.find((a) => a.kind === "social_status")!.text).toContain("Coming soon");
7575 const back = setStatus(db, me, { id, status: "active" });
76 expect(back.ok && back.value.assets).toHaveLength(6);
76 expect(back.ok && back.value.assets).toHaveLength(7);
7777 expect(back.ok && back.value.assets.filter((a) => a.approval !== null)).toHaveLength(1);
7878 expect(setStatus(db, me, { id, status: "coming-soon" })).toMatchObject({ ok: false, error: "invalid" });
7979 });
@@ -111,7 +111,7 @@ test("the web and API doors accept it and refuse the unknown with a 422", async
111111 const patch = (body: unknown) => fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify(body) });
112112 const ok = await patch({ status: "coming_soon" });
113113 expect(ok.status).toBe(200);
114 expect(((await ok.json()) as { assets: unknown[] }).assets).toHaveLength(8);
114 expect(((await ok.json()) as { assets: unknown[] }).assets).toHaveLength(9);
115115 expect((await patch({ status: "pending" })).status).toBe(422);
116116
117117 const post = (status: string) => {
src/generate.ts +2
@@ -30,6 +30,7 @@ export const KINDS = [
3030 "email_followup",
3131 "social_reminder",
3232 "email_reminder",
33 "email_update",
3334 ] as const;
3435 export type Kind = (typeof KINDS)[number];
3536
@@ -48,6 +49,7 @@ export const LABELS: Readonly<Record<Kind, string>> = {
4849 email_followup: "Open house follow-up email",
4950 social_reminder: "Open tomorrow post",
5051 email_reminder: "Open tomorrow email",
52 email_update: "Seller update",
5153 };
5254
5355 /** The assets that exist only while a listing is coming soon, under contract, sold, or back on the market. */
src/hostile.test.ts +1 −1
@@ -59,7 +59,7 @@ test("the hostile listing launches and its assets are edited", async () => {
5959 const launched = await call("/listings", { method: "POST", body: form });
6060 expect(launched.status).toBe(303);
6161 id = Number(/\/listings\/(\d+)/.exec(launched.headers.get("location")!)![1]);
62 for (const kind of ["mls", "social", "flyer", "email"]) {
62 for (const kind of ["mls", "social", "flyer", "email", "email_update"]) {
6363 const edit = new FormData();
6464 edit.set("text", PAYLOADS.asset);
6565 expect((await call(`/listings/${id}/assets/${kind}`, { method: "POST", body: edit })).status).toBe(303);
src/listings.test.ts +13 −8
@@ -438,7 +438,7 @@ test("the page says how many are approved, names the flags, and the form and API
438438 const { to } = await launch(alice);
439439 const id = Number(to.split("/").at(-1));
440440 let page = await (await fetch(`${base}/listings/${id}`)).text();
441 expect(page).toContain("0 of 6 approved");
441 expect(page).toContain("0 of 7 approved");
442442 expect(page).not.toContain("Approve</button>"); // anonymous readers have no button
443443 // Two flagged phrases make the button say so.
444444 const edit = new FormData();
@@ -456,12 +456,12 @@ test("the page says how many are approved, names the flags, and the form and API
456456 form.set("text", "unsaved typing");
457457 const stale = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" });
458458 expect(stale.status).toBe(303);
459 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("0 of 6 approved");
459 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("0 of 7 approved");
460460 form.delete("text");
461461 const posted = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" });
462462 expect(posted.status).toBe(303);
463463 page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text();
464 expect(page).toContain("1 of 6 approved");
464 expect(page).toContain("1 of 7 approved");
465465 expect(page).toContain("Approved by carol");
466466 expect(page).toContain("via the page");
467467
@@ -483,9 +483,12 @@ test("the page says how many are approved, names the flags, and the form and API
483483 expect(scripted.status).toBeGreaterThanOrEqual(400);
484484 expect(JSON.stringify(await scripted.json())).toContain("person_required");
485485 expect(await formApprove("email", "2000-01-01T00:00:00.000Z")).toBe(303);
486 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("1 of 6 approved");
486 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("1 of 7 approved");
487487 for (const kind of ["social_short", "social_medium", "social_carousel", "flyer", "email"])
488488 expect(await formApprove(kind, at(kind))).toBe(303);
489 // The seller update reports those approvals, so its words (and `updated_at`) moved with them: read it again.
490 const moved = (await (await fetch(`${base}/api/listings/${id}`, { headers: alice })).json()) as { assets: { kind: string; updated_at: string }[] };
491 expect(await formApprove("email_update", moved.assets.find((a) => a.kind === "email_update")!.updated_at)).toBe(303);
489492 page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text();
490493 expect(page).toContain("Ready to send");
491494 const back = await api(alice, { kind: "mls", approved: false });
@@ -529,7 +532,7 @@ test("the home list says what is approved, and flyer and email wear a draft band
529532 const id = Number(to.split("/").at(-1));
530533 const card = (html: string) => html.slice(html.indexOf(`href="/listings/${id}"`)).split("</li>")[0]!;
531534 const home = async () => card(await (await fetch(`${base}/`)).text());
532 expect(await home()).toContain("0 of 6 approved");
535 expect(await home()).toContain("0 of 7 approved");
533536 for (const path of ["flyer", "email"])
534537 expect(await (await fetch(`${base}${to}/${path}`)).text()).toContain("Draft — not approved");
535538 expect(await (await fetch(`${base}${to}`)).text()).toContain("Draft — not approved");
@@ -544,13 +547,15 @@ test("the home list says what is approved, and flyer and email wear a draft band
544547 for (const kind of ["flyer", "email"]) expect((await approve(kind)).status).toBe(303);
545548 for (const path of ["flyer", "email"])
546549 expect(await (await fetch(`${base}${to}/${path}`)).text()).not.toContain("Draft — not approved");
547 expect(await home()).toContain("2 of 6 approved");
550 expect(await home()).toContain("2 of 7 approved");
548551
549552 const summary = ((await (await fetch(`${base}/api/listings`)).json()) as { id: number; approvals: { kind: string; approved: boolean }[] }[]).find((l) => l.id === id)!;
550 expect(summary.approvals).toHaveLength(6);
553 expect(summary.approvals).toHaveLength(7);
551554 expect(summary.approvals.filter((a) => a.approved).map((a) => a.kind).sort()).toEqual(["email", "flyer"]);
552555
553 for (const a of listed.assets) if (a.kind !== "flyer" && a.kind !== "email") expect((await approve(a.kind)).status).toBe(303);
556 for (const a of listed.assets) if (a.kind !== "flyer" && a.kind !== "email" && a.kind !== "email_update") expect((await approve(a.kind)).status).toBe(303);
557 listed.assets = ((await (await fetch(`${base}/api/listings/${id}`)).json()) as typeof listed).assets; // the seller update moved with them
558 expect((await approve("email_update")).status).toBe(303);
554559 expect(await home()).toContain("Ready to send");
555560 });
556561
src/offmarket.test.ts +1 −1
@@ -150,7 +150,7 @@ test("existing rows keep their statuses and assets across the migration", () =>
150150 const again = openDb(dir);
151151 const l = get(again, made.value.id);
152152 expect(l).toMatchObject({ status: "sold", sold_price: 700000 });
153 expect(l.assets.length).toBe(8);
153 expect(l.assets.length).toBe(9);
154154 });
155155
156156 test("a person's edited flyer, email and post lose their open house line but keep their other words", () => {
src/openhouse.test.ts +1 −1
@@ -175,7 +175,7 @@ test("PATCH /api/listings/:id sets and clears it, and the API describes it", asy
175175 const made = (await launch.json()) as { id: number; open_houses: unknown[]; assets: { text: string }[] };
176176 expect(made.open_houses).toEqual([sunday]);
177177 // The thanks speaks of the visit that happened, not of the time it was held: it names the property instead.
178 const [thanks, rest] = [made.assets.filter((a) => (a as { kind: string }).kind === "email_followup"), made.assets.filter((a) => (a as { kind: string }).kind !== "email_followup")];
178 const [thanks, rest] = [made.assets.filter((a) => (a as { kind: string }).kind === "email_followup"), made.assets.filter((a) => (a as { kind: string }).kind !== "email_followup" && (a as { kind: string }).kind !== "email_update")];
179179 expect(rest.filter((a) => a.text !== "").every((a) => a.text.includes("Sunday, March 7, 1–3 pm"))).toBe(true);
180180 expect(thanks.length).toBe(1);
181181 expect(thanks[0]!.text).toContain(SAMPLE.address);
src/pages/listing.ts +3 −1
@@ -113,7 +113,7 @@ export const statusForm = (l: Listing): string =>
113113 </form>`;
114114
115115 /** The band an unapproved flyer, email or square picture (its first social post) wears, on screen and on paper; nothing once approved. */
116 export const draftBand = (l: Launched, kind: "flyer" | "email" | "email_followup" | "email_reminder" | "social_short"): string =>
116 export const draftBand = (l: Launched, kind: "flyer" | "email" | "email_followup" | "email_reminder" | "email_update" | "social_short"): string =>
117117 l.assets.find((a) => a.kind === kind)?.approval == null
118118 ? `<p class="draft-band" role="note">Draft — not approved</p>`
119119 : "";
@@ -190,6 +190,7 @@ const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] =
190190 { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price", "social_reminder"] },
191191 { id: "flyer", label: "Flyer", kinds: ["flyer"] },
192192 { id: "email", label: "Email", kinds: ["email", "email_status", "email_price", "email_followup", "email_reminder"] },
193 { id: "update", label: "Seller update", kinds: ["email_update"] },
193194 ];
194195
195196 /** The tab that holds an asset, or a tab named outright; the first otherwise. */
@@ -265,6 +266,7 @@ export function listingPage(input: {
265266 ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l, origin)}</a></div>` : ""}
266267 ${t.id === "social" ? `<p><a class="button button-quiet" href="/listings/${l.id}/card">Square picture</a></p>` : ""}
267268 ${t.id === "email" ? `<p><a class="button button-quiet" href="/listings/${l.id}/email">Preview email</a>${l.assets.some((a) => a.kind === "email_followup") ? ` <a class="button button-quiet" href="/listings/${l.id}/email?which=followup">Preview follow-up</a>` : ""}${l.assets.some((a) => a.kind === "email_reminder") ? ` <a class="button button-quiet" href="/listings/${l.id}/email?which=reminder">Preview reminder</a>` : ""}</p>` : ""}
269 ${t.id === "update" ? draftBand(l, "email_update") : ""}
268270 ${l.assets
269271 .filter((a) => t.kinds.includes(a.kind))
270272 .map((a) => editor(l, a, editable, input.versions?.[a.kind] ?? []))
src/priceimprovement.test.ts +1 −1
@@ -124,7 +124,7 @@ test("the terminal lowers the price with r and shows the new assets", () => {
124124 expect(now.price).toBe(849000);
125125 expect(now.was_price).toBe(899000);
126126 expect(now.assets.map((a) => a.kind)).toContain("social_price");
127 expect(plain(tty.screen())).toContain("0 of 8 approved");
127 expect(plain(tty.screen())).toContain("0 of 9 approved");
128128 tty.press("r");
129129 for (let i = 0; i < 10; i++) tty.press("\x7f");
130130 for (const key of "lots") tty.press(key);
src/reminder.test.ts +2 −2
@@ -71,8 +71,8 @@ test("a listing with an open house gets both, unapproved, counted, with versions
7171 const l = read(db, id);
7272 expect(kinds(db, id)).toEqual(["social_reminder", "email_reminder"]);
7373 for (const kind of ["social_reminder", "email_reminder"]) expect(l.assets.find((a) => a.kind === kind)!.approval).toBeNull();
74 expect(l.assets.length).toBe(9);
75 expect(kit(l)).toContain("0 of 9 assets approved");
74 expect(l.assets.length).toBe(10);
75 expect(kit(l)).toContain("0 of 10 assets approved");
7676 expect(kinds(...(Object.values(launch({ open_houses: [] })) as [ReturnType<typeof openDb>, number]))).toEqual([]);
7777 });
7878
src/report.ts +2 −1
@@ -33,8 +33,9 @@ export interface ReportRow {
3333 readonly words: string | null;
3434 }
3535
36 /** The seller update is the agent's own note to the seller, not a thing that goes out: the report leaves it out. */
3637 export const reportRows = (l: Launched): ReportRow[] =>
37 l.assets.map((a: Asset) => {
38 l.assets.filter((a) => a.kind !== "email_update").map((a: Asset) => {
3839 const approved = approvedLine(a.approval);
3940 return { label: a.label, approved, words: approved === null ? null : a.text };
4041 });
src/sample.test.ts +2 −2
@@ -58,11 +58,11 @@ const approvedOf = (db: ReturnType<typeof fresh>, like: string) => {
5858 return r.value.assets.filter((a) => a.approval !== null);
5959 };
6060
61 test("Alder Lane opens with all nine assets approved by its sample agent; Birchwood has none", () => {
61 test("Alder Lane opens with all ten assets approved by its sample agent; Birchwood has none", () => {
6262 const db = fresh();
6363 seed(db);
6464 const alder = approvedOf(db, "412 Alder%");
65 expect(alder).toHaveLength(9);
65 expect(alder).toHaveLength(10);
6666 for (const a of alder) expect(a.approval).toMatchObject({ by_name: "Morgan Reyes (sample)", via: "sample" });
6767 expect(approvedOf(db, "27 Birchwood%")).toHaveLength(0);
6868 expect(approvedOf(db, "88 Meridian%")).toHaveLength(0);
src/sample.ts +24 −5
@@ -6,7 +6,7 @@
66 */
77
88 import type { Database } from "bun:sqlite";
9 import { insertListing, markStatus } from "./actions.ts";
9 import { draftMissingUpdates, insertListing, markStatus, redraftUpdate } from "./actions.ts";
1010 import type { ListingInput } from "./listing.ts";
1111
1212 export const SAMPLE: ListingInput = {
@@ -133,6 +133,19 @@ const SAMPLES: readonly Sample[] = [
133133 },
134134 ];
135135
136 /** Every asset of a sample signed off by its sample agent; the seller update last, so it reports the rest. */
137 function signOff(db: Database, id: number, name: string): void {
138 const sign = (kinds: string) =>
139 db
140 .query(
141 `UPDATE asset SET approved_by_id = 'sample', approved_by_name = ?2, approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), approved_via = 'sample' WHERE listing_id = ?1 AND kind ${kinds}`,
142 )
143 .run(id, name);
144 sign("!= 'email_update'");
145 redraftUpdate(db, id);
146 sign("= 'email_update'");
147 }
148
136149 /** Plant each sample that has not been planted before; a removed one stays removed. */
137150 export function seed(db: Database): void {
138151 db.transaction(() => {
@@ -145,10 +158,16 @@ export function seed(db: Database): void {
145158 JSON.stringify(Object.fromEntries(s.photos.map((name, i) => [name, s.alt[i]]))),
146159 );
147160 // Alder Lane opens finished: every asset signed off by its sample agent, never by a person's door.
148 if (s.name === "sample")
149 db.query(
150 "UPDATE asset SET approved_by_id = 'sample', approved_by_name = ?2, approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), approved_via = 'sample' WHERE listing_id = ?1",
151 ).run(made.id, `${s.listing.agent_name} (sample)`);
161 if (s.name === "sample") signOff(db, made.id, `${s.listing.agent_name} (sample)`);
162 }
163 // The sample planted before the seller update existed gets its draft, signed like the rest it reports.
164 for (const id of draftMissingUpdates(db)) {
165 const row = db
166 .query<{ name: string | null; open: number }, [number]>(
167 "SELECT (SELECT approved_by_name FROM asset WHERE listing_id = ?1 AND kind = 'mls') AS name, (SELECT count(*) FROM asset WHERE listing_id = ?1 AND kind != 'email_update' AND approved_via IS NOT 'sample') AS open",
168 )
169 .get(id)!;
170 if (row.name !== null && row.open === 0) signOff(db, id, row.name);
152171 }
153172 // A sample planted before statuses existed is marked sold once too; after that it is the owner's to change.
154173 for (const s of SAMPLES) {
src/status.test.ts +8 −8
@@ -77,7 +77,7 @@ test("the status words: in every tone, clear of Fair Housing, naming the street
7777 test("setting the status adds the two assets, each needing its own approval, and leaves the rest alone", () => {
7878 const { db, id } = launch();
7979 expect(get(db, id)).toMatchObject({ status: "active", sold_price: null });
80 expect(get(db, id).assets).toHaveLength(6);
80 expect(get(db, id).assets).toHaveLength(7);
8181 const mls = get(db, id).assets.find((a) => a.kind === "mls")!;
8282 expect(editAsset(db, me, { id, kind: "mls", text: "My own words about the house." }).ok).toBe(true);
8383 const fresh = get(db, id).assets.find((a) => a.kind === "mls")!;
@@ -87,7 +87,7 @@ test("setting the status adds the two assets, each needing its own approval, and
8787 const sold = setStatus(db, me, { id, status: "sold", sold_price: "752000" });
8888 if (!sold.ok) throw new Error(sold.message);
8989 expect(sold.value).toMatchObject({ status: "sold", sold_price: 752000 });
90 expect(sold.value.assets.map((a) => a.kind)).toEqual(["mls", "social_short", "social_medium", "social_carousel", "social_status", "flyer", "email", "email_status"]);
90 expect(sold.value.assets.map((a) => a.kind)).toEqual(["mls", "social_short", "social_medium", "social_carousel", "social_status", "flyer", "email", "email_status", "email_update"]);
9191 const mine = sold.value.assets.find((a) => a.kind === "mls")!;
9292 expect(mine.text).toBe("My own words about the house.");
9393 expect(mine.approval).not.toBeNull();
@@ -100,7 +100,7 @@ test("setting the status adds the two assets, each needing its own approval, and
100100
101101 // The count grows to include them.
102102 const summary = listListings(db, me);
103 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(8);
103 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(9);
104104 expect(summary.ok && summary.value[0]!.approvals.filter((a) => a.approved)).toHaveLength(1);
105105
106106 // Each is approved on its own.
@@ -133,7 +133,7 @@ test("setting the status adds the two assets, each needing its own approval, and
133133 const resold = setStatus(db, me, { id, status: "sold" });
134134 expect(resold.ok).toBe(true);
135135 const away = setStatus(db, me, { id, status: "active" });
136 expect(away.ok && away.value.assets).toHaveLength(6);
136 expect(away.ok && away.value.assets).toHaveLength(7);
137137 });
138138
139139 test("who may: the listing's agent or an admin, nobody else, and a bad status changes nothing", () => {
@@ -168,7 +168,7 @@ test("the pill, the flyer band and the sample", () => {
168168 const page = listingPage({ who: me, listing: sold.value, editable: true });
169169 expect(page).toContain(`action="/listings/${id}/status"`);
170170 expect(page).toContain('<option value="sold" selected>');
171 expect(page).toContain("0 of 8 approved");
171 expect(page).toContain("0 of 9 approved");
172172 expect(page).toContain('id="social_status"');
173173 expect(page).toContain('id="email_status"');
174174 expect(listingPage({ who: other, listing: sold.value, editable: false })).not.toContain("/status");
@@ -180,7 +180,7 @@ test("the pill, the flyer band and the sample", () => {
180180 const sample = listed.ok ? listed.value.filter((l) => l.status === "sold") : [];
181181 expect(sample.map((l) => l.address)).toEqual([SAMPLE_CONDO.address]);
182182 expect(sample[0]!.sold_price).toBe(752000);
183 expect(sample[0]!.approvals).toHaveLength(8);
183 expect(sample[0]!.approvals).toHaveLength(9);
184184 expect(sample[0]!.flags).toBe(0);
185185
186186 // A database seeded before statuses existed gets the sold sample once, and keeps a later change.
@@ -190,7 +190,7 @@ test("the pill, the flyer band and the sample", () => {
190190 seeded.run("DELETE FROM seeded WHERE name = 'sample-condo-sold'");
191191 seed(seeded);
192192 expect(get(seeded, meridian)).toMatchObject({ status: "sold", sold_price: 752000 });
193 expect(get(seeded, meridian).assets).toHaveLength(8);
193 expect(get(seeded, meridian).assets).toHaveLength(9);
194194 expect(setStatus(seeded, admin, { id: meridian, status: "active" }).ok).toBe(true);
195195 seed(seeded);
196196 expect(get(seeded, meridian).status).toBe("active");
@@ -241,7 +241,7 @@ test("the page and the API", async () => {
241241 expect(sold.status).toBe(200);
242242 const body = (await sold.json()) as { status: string; sold_price: number; assets: { kind: string }[] };
243243 expect(body).toMatchObject({ status: "sold", sold_price: 752000 });
244 expect(body.assets).toHaveLength(8);
244 expect(body.assets).toHaveLength(9);
245245 expect((await patch({ status: "pending" })).status).toBe(422);
246246 expect((await patch({ status: "active" }, { ...mint("someone-else"), "content-type": "application/json" })).status).toBe(403);
247247
src/tty.test.ts +22 −7
@@ -112,10 +112,10 @@ test("a approves the asset under the cursor, and withdraws it; a stranger's a is
112112 const { db } = fresh();
113113 const tty = session(db, me);
114114 tty.press("\r");
115 expect(plain(tty.screen())).toContain("0 of 6 approved");
115 expect(plain(tty.screen())).toContain("0 of 7 approved");
116116 tty.press("a");
117117 expect(plain(tty.screen())).toContain("Approved.");
118 expect(plain(tty.screen())).toContain("1 of 6 approved");
118 expect(plain(tty.screen())).toContain("1 of 7 approved");
119119 expect(plain(tty.screen())).toContain("approved by me");
120120 const stored = db.query<{ approved_via: string }, []>("SELECT approved_via FROM asset WHERE approved_via IS NOT NULL").all();
121121 expect(stored.map((r) => r.approved_via)).toEqual(["tty"]);
@@ -124,7 +124,7 @@ test("a approves the asset under the cursor, and withdraws it; a stranger's a is
124124 const theirs = session(db, other);
125125 theirs.press("\r");
126126 theirs.press("a");
127 expect(plain(theirs.screen())).toContain("0 of 6 approved");
127 expect(plain(theirs.screen())).toContain("0 of 7 approved");
128128 });
129129
130130 test("p previews the email as the client reads it; esc returns to the assets", () => {
@@ -145,12 +145,12 @@ test("p previews the email as the client reads it; esc returns to the assets", (
145145 test("the listings view says what is approved, and the email preview is a draft until approved", () => {
146146 const { db } = fresh();
147147 const tty = session(db, me);
148 expect(plain(tty.screen())).toContain("0 of 6 approved");
148 expect(plain(tty.screen())).toContain("0 of 7 approved");
149149 tty.press("\r");
150150 tty.press("p");
151151 expect(plain(tty.screen())).toContain("DRAFT — not approved");
152152 tty.press("\x1b");
153 for (let i = 0; i < 6; i++) {
153 for (let i = 0; i < 7; i++) {
154154 tty.press("a");
155155 tty.press("j");
156156 }
@@ -210,10 +210,10 @@ test("the terminal shows a social post's length, and a short post over X needs a
210210
211211 tty.press("a");
212212 expect(plain(tty.screen())).toContain("Too long for X by 34. Press a again to approve it anyway.");
213 expect(plain(tty.screen())).toContain("0 of 6 approved");
213 expect(plain(tty.screen())).toContain("0 of 7 approved");
214214 tty.press("a");
215215 expect(plain(tty.screen())).toContain("Approved, too long for X.");
216 expect(plain(tty.screen())).toContain("1 of 6 approved");
216 expect(plain(tty.screen())).toContain("1 of 7 approved");
217217 // Another key between the two presses starts the question again.
218218 tty.press("a"); // withdraw
219219 tty.press("a");
@@ -410,3 +410,18 @@ test("O previews the Open tomorrow reminder, link included; a listing without an
410410 tty.press("O");
411411 expect(plain(tty.screen())).toContain("This listing has no open tomorrow reminder.");
412412 });
413
414 test("the seller update is the last asset: it reads as a draft, and a approves it", () => {
415 const { db } = fresh();
416 const tty = session(db, me);
417 tty.press("\r");
418 for (let i = 0; i < 6; i++) tty.press("j");
419 const seen = plain(tty.screen());
420 expect(seen).toContain("▸ Seller update");
421 expect(seen).toContain("Subject: Your weekly update — 412 Alder Lane");
422 tty.press("a");
423 expect(plain(tty.screen())).toContain("Approved.");
424 expect(plain(tty.screen())).toContain("approved by me");
425 tty.press("a");
426 expect(plain(tty.screen())).toContain("Approval withdrawn.");
427 });
src/update.test.ts added +176
@@ -0,0 +1,176 @@
1 /**
2 * The seller update: the weekly note drafted from the approvals of the last
3 * seven days and the launch week's next seven, an asset like the others
4 * (checked, editable, approved by a person only) that follows its facts and
5 * its status.
6 */
7 import { expect, test } from "bun:test";
8 import { mkdtempSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { approveAsset, editAsset, getListing, launchListing, restoreAsset, setStatus, updateFacts } from "./actions.ts";
12 import { openDb } from "./db.ts";
13 import { check } from "./fairhousing.ts";
14 import { rpc } from "./mcp.ts";
15 import { listingPage } from "./pages/listing.ts";
16 import { seed, SAMPLE } from "./sample.ts";
17 import { sellerUpdate } from "./update.ts";
18 import type { Who } from "./who.ts";
19
20 const me: Who = { role: "user", id: "u1", name: "Pat" };
21 const day = (n: number) => new Date(Date.now() + n * 86_400_000).toISOString().slice(0, 10);
22
23 const launch = (extra: Record<string, unknown> = {}) => {
24 const db = openDb(mkdtempSync(join(tmpdir(), "app-update-")));
25 const made = launchListing(db, me, { fields: { ...SAMPLE, open_houses: [], ...extra } });
26 if (!made.ok) throw new Error(made.message);
27 return { db, id: made.value.id };
28 };
29 const read = (db: ReturnType<typeof openDb>, id: number) => {
30 const found = getListing(db, me, { id });
31 if (!found.ok) throw new Error(found.message);
32 return found.value;
33 };
34 const update = (db: ReturnType<typeof openDb>, id: number) => read(db, id).assets.find((a) => a.kind === "email_update")!;
35 const approve = (db: ReturnType<typeof openDb>, id: number, kind: string) =>
36 approveAsset(db, me, { id, kind, door: "page", seen: read(db, id).assets.find((a) => a.kind === kind)!.updated_at });
37
38 test("a launched listing carries a draft addressed by the agent's name, clear of the Fair Housing check", () => {
39 const { db, id } = launch();
40 const a = update(db, id);
41 expect(a.label).toBe("Seller update");
42 expect(a.approval).toBeNull();
43 expect(a.text).toStartWith("Subject: Your weekly update — 412 Alder Lane\n");
44 expect(a.text).toContain(SAMPLE.address);
45 expect(a.text).toContain("Nothing new was approved this week.");
46 expect(a.text).toContain(`/homes/${id}`);
47 expect(a.text.trimEnd().endsWith(`${SAMPLE.agent_phone} · ${SAMPLE.agent_email}`)).toBe(true);
48 expect(a.text).toContain(SAMPLE.agent_name);
49 expect(a.check.clear).toBe(true);
50 });
51
52 test("it lists only this week's approvals, with who and when, and follows them while it is a draft", () => {
53 const { db, id } = launch();
54 expect(approve(db, id, "mls").ok).toBe(true);
55 const text = update(db, id).text;
56 expect(text).toContain("- MLS description: Approved by Pat on ");
57 expect(text).not.toContain("Nothing new was approved");
58 expect(text).not.toContain("Short post:");
59
60 // An approval from three weeks ago is not this week's.
61 const l = read(db, id);
62 const old = { ...l, assets: l.assets.map((a) => (a.kind === "mls" ? { ...a, approval: { ...a.approval!, at: `${day(-21)}T10:00:00.000Z` } } : a)) };
63 expect(sellerUpdate(old, new Date())).toContain("Nothing new was approved this week.");
64 expect(sellerUpdate(l, new Date(Date.now() + 8 * 86_400_000))).toContain("Nothing new was approved this week.");
65 });
66
67 test("it names the next seven days of the launch week and the open house dates", () => {
68 const soon = { date: day(3), start: "13:00", end: "15:00" };
69 const later = { date: day(30), start: "10:00", end: "12:00" };
70 const { db, id } = launch({ open_houses: [soon, later] });
71 const text = update(db, id).text;
72 expect(text).toContain("What is scheduled over the next seven days:");
73 expect(text).toContain("Goes on the MLS (MLS description)");
74 expect(text).toContain("Open house (Flyer copy)");
75 const week = text.split("Open houses:")[0]!;
76 expect(week).toContain(soon.date);
77 expect(week).not.toContain(later.date);
78 const opens = text.split("Open houses:")[1]!;
79 expect(opens).toContain(`${soon.date}`);
80 expect(opens).toContain("13:00–15:00");
81 expect(opens).toContain(later.date);
82 });
83
84 test("editing the facts or the status clears its approval and writes it again", () => {
85 const { db, id } = launch();
86 const done = (r: { ok: boolean }) => expect(r.ok).toBe(true);
87 done(approve(db, id, "email_update"));
88 expect(update(db, id).approval).not.toBeNull();
89 done(updateFacts(db, me, { id, fields: { price: 650000 } }));
90 expect(update(db, id).approval).toBeNull();
91 expect(update(db, id).text).toContain("$650,000");
92
93 done(approve(db, id, "email_update"));
94 done(setStatus(db, me, { id, status: "sold", sold_price: 640000 }));
95 expect(update(db, id).approval).toBeNull();
96 expect(update(db, id).text).toContain("Sold at $640,000");
97
98 // Words a person edited keep what they wrote, but the status still takes the approval.
99 const edited = editAsset(db, me, { id, kind: "email_update", text: "Hello — my own note." });
100 expect(edited.ok).toBe(true);
101 done(approve(db, id, "email_update"));
102 done(setStatus(db, me, { id, status: "active" }));
103 expect(update(db, id)).toMatchObject({ text: "Hello — my own note.", approval: null });
104 });
105
106 test("only a person approves it: the API and the MCP are refused, and an assistant may withdraw", () => {
107 const { db, id } = launch();
108 const seen = update(db, id).updated_at;
109 const api = approveAsset(db, me, { id, kind: "email_update", approved: true, seen, door: "api" });
110 expect(!api.ok && api.error).toBe("person_required");
111 const call = (args: unknown) => rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "approve_asset", arguments: args } });
112 const refused = call({ id, kind: "email_update", approved: true }).body as { result: { isError: boolean; content: { text: string }[] } };
113 expect(refused.result.isError).toBe(true);
114 expect(refused.result.content[0]!.text).toStartWith("person_required:");
115 expect(update(db, id).approval).toBeNull();
116 expect(approve(db, id, "email_update").ok).toBe(true);
117 expect(approveAsset(db, me, { id, kind: "email_update", approved: false, door: "mcp" }).ok).toBe(true);
118 expect(update(db, id).approval).toBeNull();
119 });
120
121 test("the listing page has a Seller update section with the draft band, Copy and Approve until approved", () => {
122 const { db, id } = launch();
123 const page = (tab = "update") => listingPage({ who: me, listing: read(db, id), editable: true, tab });
124 const html = page();
125 expect(html).toContain('id="email_update"');
126 expect(html).toContain("Seller update");
127 expect(html).toContain('class="draft-band"');
128 expect(html).toContain('data-copy data-asset="Seller update"');
129 expect(html).toContain(`formaction="/listings/${id}/assets/email_update/approve"`);
130 expect(approve(db, id, "email_update").ok).toBe(true);
131 const signed = page();
132 expect(signed.slice(signed.indexOf('id="panel-update"'), signed.indexOf('id="email_update"'))).not.toContain("draft-band");
133 expect(signed).toContain("Withdraw");
134 });
135
136 test("typed fields are escaped on the page and the update is plain text", () => {
137 const evil = `<img src=x onerror=alert(1)>`;
138 const { db, id } = launch({ agent_name: `Pat ${evil}`, brokerage: `<script>alert(2)</script> Realty` });
139 const a = update(db, id);
140 expect(a.text).toContain(evil);
141 const html = listingPage({ who: me, listing: read(db, id), editable: true, tab: "update" });
142 expect(html).not.toContain("<img src=x");
143 expect(html).not.toContain("<script>alert(2)");
144 expect(html).toContain("&lt;img src=x onerror=alert(1)&gt;");
145 });
146
147 test("the Alder Lane sample's update is approved, lists its nine assets and the property page", () => {
148 const db = openDb(mkdtempSync(join(tmpdir(), "app-update-sample-")));
149 seed(db);
150 const row = db.query<{ id: number }, []>("SELECT id FROM listing WHERE address LIKE '412 Alder%'").get()!;
151 const a = read(db, row.id).assets.find((x) => x.kind === "email_update")!;
152 expect(a.approval).toMatchObject({ via: "sample" });
153 expect(a.text.match(/Approved by Morgan Reyes \(sample\) on /g)).toHaveLength(9);
154 expect(a.text).toContain(`/homes/${row.id}`);
155 expect(check(a.text).clear).toBe(true);
156 });
157
158 test("editing or restoring an approved asset drops it from an unedited draft", () => {
159 const { db, id } = launch();
160 const listed = () => update(db, id).text;
161 expect(approve(db, id, "mls").ok).toBe(true);
162 expect(approve(db, id, "email").ok).toBe(true);
163 expect(listed()).toContain("- MLS description: Approved by Pat");
164 expect(listed()).toContain("- Email blast: Approved by Pat");
165
166 expect(editAsset(db, me, { id, kind: "mls", text: "A new description of the home." }).ok).toBe(true);
167 expect(listed()).not.toContain("- MLS description:");
168 expect(listed()).toContain("- Email blast: Approved by Pat");
169
170 // Restoring the earlier words is a save too: the approval it cleared stays gone.
171 expect(approve(db, id, "mls").ok).toBe(true);
172 expect(listed()).toContain("- MLS description:");
173 expect(restoreAsset(db, me, { id, kind: "mls" }).ok).toBe(true);
174 expect(listed()).not.toContain("- MLS description:");
175
176 });
src/update.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * The seller update as words: the weekly note the agent owes a seller, drafted
3 * from what the app already knows. What went out (every asset a person
4 * approved in the last seven days, with who and when), what the launch week
5 * has coming in the next seven days, the open house dates and the property
6 * page link. Pure: the listing and "now" in, plain text out. The words are
7 * stored as an asset like the others, so they are checked, editable and
8 * approved by a person the same way.
9 */
10
11 import { publicOrigin } from "./env.ts";
12 import { isOffMarket, money, statusLine, street } from "./generate.ts";
13 import { propertyPath, type Launched } from "./listing.ts";
14 import { approvedLine, oneLine } from "./report.ts";
15 import { addDays, dayName, scheduleRows } from "./schedule.ts";
16
17 /** The days a week looks back over and forward to. */
18 const WEEK = 7;
19
20 export function sellerUpdate(l: Launched, now: Date, origin = publicOrigin): string {
21 const today = now.toISOString().slice(0, 10);
22 const since = new Date(now.getTime() - WEEK * 86_400_000).toISOString();
23 const sent = l.assets
24 .filter((a) => a.kind !== "email_update" && a.approval !== null && a.approval.at >= since)
25 .map((a) => `- ${a.label}: ${approvedLine(a.approval)}`);
26 const last = addDays(today, WEEK - 1);
27 const coming = scheduleRows(l)
28 .filter((r) => r.day >= today && r.day <= last)
29 .map((r) => `- ${dayName(r.day)}: ${r.what} (${r.label})`);
30 const opens = (isOffMarket(l.status) ? [] : l.open_houses)
31 .filter((o) => o.date >= today)
32 .map((o) => `- ${dayName(o.date)}, ${o.start}–${o.end}`);
33 const page = l.assets.find((a) => a.kind === "mls")?.approval == null ? " (it opens once the listing description is approved)" : "";
34 const lines = [
35 `Subject: Your weekly update — ${street(l.address)}`,
36 "",
37 "Hello,",
38 "",
39 `Here is your update for the week of ${today} on ${oneLine(l.address)}. Its status is ${oneLine(statusLine(l))}, at ${money(l.price)}.`,
40 "",
41 "What went out this week:",
42 ...(sent.length === 0 ? ["- Nothing new was approved this week."] : sent),
43 "",
44 "What is scheduled over the next seven days:",
45 ...(coming.length === 0 ? ["- Nothing is scheduled."] : coming),
46 ...(opens.length === 0 ? [] : ["", "Open houses:", ...opens]),
47 "",
48 `Your property page${page}: ${propertyPath(l, origin)}`,
49 "",
50 "Reply to this email with any questions.",
51 "",
52 oneLine(l.agent_name),
53 `${oneLine(l.brokerage)} · License ${oneLine(l.license)}`,
54 `${oneLine(l.agent_phone)} · ${oneLine(l.agent_email)}`,
55 ];
56 return lines.join("\n");
57 }
src/walkthrough.test.ts +14 −14
@@ -123,8 +123,8 @@ test("every step of the section works, with the labels it names", async () => {
123123
124124 // 4. Approve one.
125125 const page = await (await get(mine)).text();
126 expect(page).toContain("0 of 6 approved");
127 expect(section).toContain("1 of 6 approved");
126 expect(page).toContain("0 of 7 approved");
127 expect(section).toContain("1 of 7 approved");
128128 expect(textareaOf(page)).toBe("My own words about 9 Walkthrough Way, written by hand.");
129129 const approveForm = new FormData();
130130 approveForm.set("approved", "1");
@@ -134,7 +134,7 @@ test("every step of the section works, with the labels it names", async () => {
134134 const approved = await post(`${mine}/assets/mls/approve`, approveForm);
135135 expect(approved.status).toBe(303);
136136 const afterApprove = await (await get(mine)).text();
137 expect(afterApprove).toContain("1 of 6 approved");
137 expect(afterApprove).toContain("1 of 7 approved");
138138
139139 // 5. Change the tone: every asset again, approval cleared.
140140 expect(afterApprove).toMatch(control("Rewrite all"));
@@ -142,13 +142,13 @@ test("every step of the section works, with the labels it names", async () => {
142142 tone.set("tone", "bold");
143143 expect((await post(`${mine}/tone`, tone)).status).toBe(303);
144144 const afterTone = await (await get(mine)).text();
145 expect(afterTone).toContain("0 of 6 approved");
145 expect(afterTone).toContain("0 of 7 approved");
146146 // The words were written again: the edit is gone and the text is the bold tone's.
147147 expect(textareaOf(afterTone)).not.toContain("written by hand");
148148 expect(textareaOf(afterTone)).not.toBe(textareaOf(afterApprove));
149149 expect(afterTone).toContain('<option value="bold" selected>');
150150 expect(textareaOf(afterApprove)).toContain("written by hand");
151 expect(section).toContain("0 of 6 approved");
151 expect(section).toContain("0 of 7 approved");
152152
153153 // 6. Put back the MLS words: Earlier versions, Restore, checked again, approval clear.
154154 expect(section).toContain("**Earlier versions (1)**");
@@ -163,7 +163,7 @@ test("every step of the section works, with the labels it names", async () => {
163163 expect((await post(restoreForm[1]!, put)).status).toBe(303);
164164 const afterRestore = await (await get(mine)).text();
165165 expect(textareaOf(afterRestore)).toBe("My own words about 9 Walkthrough Way, written by hand.");
166 expect(afterRestore).toContain("0 of 6 approved");
166 expect(afterRestore).toContain("0 of 7 approved");
167167
168168 // 7. Drop the price: Edit facts, the new price on the flyer, approvals cleared.
169169 expect(section).toContain("**Edit facts**");
@@ -178,7 +178,7 @@ test("every step of the section works, with the labels it names", async () => {
178178 approveAgain.set("seen", /name="seen" value="([^"]+)"/.exec(afterRestore)![1]!.replaceAll("&amp;", "&"));
179179 approveAgain.set("text", textareaOf(afterRestore));
180180 await post(`${mine}/assets/mls/approve`, approveAgain);
181 expect(await (await get(mine)).text()).toContain("1 of 6 approved");
181 expect(await (await get(mine)).text()).toContain("1 of 7 approved");
182182 const drop = new FormData();
183183 for (const k of ["beds", "baths", "sqft", "lot", "year", "tone", "palette",
184184 "agent_name", "brokerage", "license", "agent_phone", "agent_email"] as const)
@@ -189,7 +189,7 @@ test("every step of the section works, with the labels it names", async () => {
189189 expect((await post(`${mine}/facts`, drop)).status).toBe(303);
190190 const afterDrop = await (await get(mine)).text();
191191 expect(afterDrop).toContain("$649,000");
192 expect(afterDrop).toContain("0 of 8 approved");
192 expect(afterDrop).toContain("0 of 9 approved");
193193 expect(await (await get(`${mine}/flyer`)).text()).toContain("$649,000");
194194
195195 // 7. Add an open house: Edit facts, the band on the flyer, the line in the email, approvals cleared.
@@ -207,7 +207,7 @@ test("every step of the section works, with the labels it names", async () => {
207207 approveOpen.set("seen", /name="seen" value="([^"]+)"/.exec(beforeOpen)![1]!.replaceAll("&amp;", "&"));
208208 approveOpen.set("text", textareaOf(beforeOpen));
209209 await post(`${mine}/assets/mls/approve`, approveOpen);
210 expect(await (await get(mine)).text()).toContain("1 of 8 approved");
210 expect(await (await get(mine)).text()).toContain("1 of 9 approved");
211211 const hold = new FormData();
212212 for (const [k, v] of drop.entries()) hold.set(k, v);
213213 hold.set("tone", "bold");
@@ -216,7 +216,7 @@ test("every step of the section works, with the labels it names", async () => {
216216 hold.set("open1_end", "15:00");
217217 expect((await post(`${mine}/facts`, hold)).status).toBe(303);
218218 const afterOpen = await (await get(mine)).text();
219 expect(afterOpen).toContain("0 of 11 approved");
219 expect(afterOpen).toContain("0 of 12 approved");
220220 expect(await (await get(`${mine}/flyer`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
221221 expect(await (await get(`${mine}/email`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
222222 const bad = new FormData();
@@ -231,7 +231,7 @@ test("every step of the section works, with the labels it names", async () => {
231231
232232 // 8. Mark it sold: the status post and email, eight to approve, the band, the list.
233233 expect(section).toContain("**Mark it sold.**");
234 expect(section).toContain("0 of 8 approved");
234 expect(section).toContain("0 of 9 approved");
235235 expect(section).toContain("88 Meridian Street");
236236 expect(afterTone).toContain('name="status"');
237237 expect(afterTone).toContain('<option value="sold">');
@@ -241,20 +241,20 @@ test("every step of the section works, with the labels it names", async () => {
241241 sell.set("sold_price", "640000");
242242 expect((await post(`${mine}/status`, sell)).status).toBe(303);
243243 const afterSold = await (await get(mine)).text();
244 expect(afterSold).toContain("0 of 10 approved");
244 expect(afterSold).toContain("0 of 11 approved");
245245 expect(afterSold).toContain("Sold at $640,000");
246246 expect(afterSold).toContain("Just sold — 9 Walkthrough Way");
247247 expect(await (await get(`${mine}/flyer`)).text()).toContain('<p class="flyer-status status-sold" role="note">Sold at $640,000</p>');
248248 const homeSold = await (await get("/")).text();
249249 expect(homeSold).toContain("Sold at $640,000");
250 expect(homeSold).toContain("0 of 8 approved");
250 expect(homeSold).toContain("0 of 9 approved");
251251 expect(homeSold).toContain("Sold at $752,000");
252252 const meridian = await (await get(`/listings/${idOf(homeSold, "88 Meridian Street")}`)).text();
253253 expect(meridian).toContain("Just sold — 88 Meridian Street");
254254 const active = new FormData();
255255 active.set("status", "active");
256256 expect((await post(`${mine}/status`, active)).status).toBe(303);
257 expect(await (await get(mine)).text()).toContain("0 of 8 approved");
257 expect(await (await get(mine)).text()).toContain("0 of 9 approved");
258258
259259 // 9. Print the flyer.
260260 expect(afterTone).toMatch(control("Print flyer"));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs