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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/75-each-week-get-a-seller-update-email-to-a → main 3 commits fork 1 thread · 1 unresolved
22 files
changed +413
−68
AGENTS.md +3 −1
| @@ -109,7 +109,9 @@ To add a concept: | ||
| 109 | 109 | the words (`generate(listing)`: plain data in, six texts out — a model goes |
| 110 | 110 | behind that one function), `src/fairhousing.ts` the check (`check(text)`: |
| 111 | 111 | 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 | |
| 113 | 115 | actions in `src/actions.ts` put them together; none of the four knows who |
| 114 | 116 | is asking. A new Fair Housing rule is a row in `RULES` and a case in |
| 115 | 117 | `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. | ||
| 23 | 23 | is only to read; the new listing is yours to change. |
| 24 | 24 | 4. **Approve one.** On your listing, read the MLS description, then press |
| 25 | 25 | **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". | |
| 27 | 27 | 5. **Change the tone.** Pick another tone beside the address (Classic, Warm |
| 28 | 28 | or Bold) and press **Rewrite all**. Every asset is written again in that |
| 29 | 29 | 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. | |
| 31 | 31 | 6. **Put back your MLS words.** Under the MLS box, press **Earlier versions |
| 32 | 32 | (1)**: it lists the words the rewrite replaced, when, by whom and why |
| 33 | 33 | ("rewrite in Bold"), and the first line of each. Press **Restore** by |
| @@ -44,7 +44,7 @@ to make a listing of your own. | ||
| 44 | 44 | square feet, lot, year, price, features and brand. Lower the price and |
| 45 | 45 | press **Save and write all again**: the new price is on the flyer, every |
| 46 | 46 | 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. | |
| 48 | 48 | 8. **Add an open house.** Press **Edit facts** again. Under "Open house" |
| 49 | 49 | pick a date, a start and an end time (Alder Lane's sample already has one), |
| 50 | 50 | 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. | ||
| 83 | 83 | status**. Two more assets appear, a status post under Social and a status |
| 84 | 84 | email under Email ("Just sold — 9 Walkthrough Way"), written in the |
| 85 | 85 | 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 | |
| 87 | 87 | flyer wears a "Sold" band, and the list on the first screen shows the |
| 88 | 88 | status beside the approval count. *88 Meridian Street* is already sold, so |
| 89 | 89 | 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. | ||
| 142 | 142 | time, no time zones), launch day being the day the listing was created. |
| 143 | 143 | Nothing is sent; the app only drafts. In the terminal, `w` on an open |
| 144 | 144 | 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. | |
| 145 | 156 | |
| 146 | 157 | `src/walkthrough.test.ts` walks these steps on every `bun test`, so this |
| 147 | 158 | list cannot drift from the app. |
| @@ -218,7 +229,7 @@ list cannot drift from the app. | ||
| 218 | 229 | asset under the cursor. |
| 219 | 230 | Each asset also has an **Approve** button ("Approve with 2 flags" when |
| 220 | 231 | 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, | |
| 222 | 233 | and editing an asset or **Rewrite all** clears the approval. The same |
| 223 | 234 | action is `a` in the terminal; `PATCH /api/listings/:id` with |
| 224 | 235 | `{ kind, approved: false }` and the `approve_asset` MCP tool can withdraw, |
| @@ -227,7 +238,7 @@ list cannot drift from the app. | ||
| 227 | 238 | if they changed, it answers 409 and approves nothing. Approval is a |
| 228 | 239 | record, not a gate: Copy and Print flyer do not look at it. |
| 229 | 240 | 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`; | |
| 231 | 242 | `GET /api/listings` and the MCP list carry `approvals` (`kind`, `approved`) per |
| 232 | 243 | listing. The flyer and the email preview wear a "Draft — not approved" band |
| 233 | 244 | (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 @@ | ||
| 652 | 652 | "email_price", |
| 653 | 653 | "email_followup", |
| 654 | 654 | "social_reminder", |
| 655 | "email_reminder" | |
| 655 | "email_reminder", | |
| 656 | "email_update" | |
| 656 | 657 | ] |
| 657 | 658 | }, |
| 658 | 659 | "text": { |
| @@ -1347,7 +1348,8 @@ | ||
| 1347 | 1348 | "email_price", |
| 1348 | 1349 | "email_followup", |
| 1349 | 1350 | "social_reminder", |
| 1350 | "email_reminder" | |
| 1351 | "email_reminder", | |
| 1352 | "email_update" | |
| 1351 | 1353 | ] |
| 1352 | 1354 | }, |
| 1353 | 1355 | "label": { |
| @@ -1695,7 +1697,8 @@ | ||
| 1695 | 1697 | "email_price", |
| 1696 | 1698 | "email_followup", |
| 1697 | 1699 | "social_reminder", |
| 1698 | "email_reminder" | |
| 1700 | "email_reminder", | |
| 1701 | "email_update" | |
| 1699 | 1702 | ] |
| 1700 | 1703 | }, |
| 1701 | 1704 | "approved": { |
src/actions.ts +53 −1
| @@ -49,6 +49,7 @@ import { | ||
| 49 | 49 | write as writePhoto, |
| 50 | 50 | } from "./photos.ts"; |
| 51 | 51 | import { headshotFrom } from "./agent.ts"; |
| 52 | import { sellerUpdate } from "./update.ts"; | |
| 52 | 53 | import { validVisitor, VISITORS_MAX, type Visitor } from "./visitors.ts"; |
| 53 | 54 | import type { Who } from "./who.ts"; |
| 54 | 55 | |
| @@ -380,6 +381,8 @@ function writeAssets( | ||
| 380 | 381 | ); |
| 381 | 382 | const drop = db.query("DELETE FROM asset WHERE listing_id = ?1 AND kind = ?2"); |
| 382 | 383 | 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; | |
| 383 | 386 | const body = words[kind]; |
| 384 | 387 | if (body === undefined) drop.run(listing.id, kind); |
| 385 | 388 | else { |
| @@ -387,6 +390,45 @@ function writeAssets( | ||
| 387 | 390 | put.run(listing.id, kind, body); |
| 388 | 391 | } |
| 389 | 392 | } |
| 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; | |
| 390 | 432 | } |
| 391 | 433 | |
| 392 | 434 | /** How many earlier versions of one asset's words are kept. */ |
| @@ -480,7 +522,14 @@ export function markStatus( | ||
| 480 | 522 | (remind && (REMINDER_KINDS as readonly string[]).includes(k)) || |
| 481 | 523 | db.query("SELECT 1 FROM asset WHERE listing_id = ?1 AND kind = ?2 AND edited = 0").get(id, k) !== null, |
| 482 | 524 | ); |
| 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); | |
| 484 | 533 | // Words a person edited keep everything they wrote but the open house, which an off-market listing no longer has. |
| 485 | 534 | if (isOffMarket(stand.status) && !isOffMarket(before.status)) { |
| 486 | 535 | const facts = { ...fromRow(row), status: "active" as const }; |
| @@ -726,6 +775,7 @@ function edit( | ||
| 726 | 775 | db.query( |
| 727 | 776 | "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", |
| 728 | 777 | ).run(text.value, found.value.id, input.kind); |
| 778 | if (input.kind !== "email_update") redraftUpdate(db, found.value.id); | |
| 729 | 779 | touch(db, found.value.id); |
| 730 | 780 | })(); |
| 731 | 781 | emit(db, "listing.changed", { id: found.value.id }); |
| @@ -965,6 +1015,7 @@ function approve( | ||
| 965 | 1015 | db.query( |
| 966 | 1016 | "UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL WHERE listing_id = ?1 AND kind = ?2", |
| 967 | 1017 | ).run(found.value.id, input.kind); |
| 1018 | if (input.kind !== "email_update") redraftUpdate(db, found.value.id); | |
| 968 | 1019 | emit(db, "listing.changed", { id: found.value.id }); |
| 969 | 1020 | return ok(launched(db, found.value.id)!); |
| 970 | 1021 | } |
| @@ -1208,6 +1259,7 @@ function changePhotos( | ||
| 1208 | 1259 | `UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL |
| 1209 | 1260 | WHERE listing_id = ?1 AND kind = ?2`, |
| 1210 | 1261 | ).run(now.id, kind); |
| 1262 | if (reshown) redraftUpdate(db, now.id); | |
| 1211 | 1263 | for (const f of files) writePhoto(db, f.name, f.bytes); |
| 1212 | 1264 | })(); |
| 1213 | 1265 | } catch (error) { |
src/backonmarket.test.ts +1 −1
| @@ -86,7 +86,7 @@ test("sold → active, and coming soon → active, take the status words away", | ||
| 86 | 86 | const { db, id } = launch(); |
| 87 | 87 | setStatus(db, me, { id, status: from }); |
| 88 | 88 | setStatus(db, me, { id, status: "active" }); |
| 89 | expect(get(db, id).assets).toHaveLength(6); | |
| 89 | expect(get(db, id).assets).toHaveLength(7); | |
| 90 | 90 | } |
| 91 | 91 | }); |
| 92 | 92 |
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 | ||
| 46 | 46 | const kinds = soon.value.assets.map((a) => a.kind); |
| 47 | 47 | expect(kinds).toContain("social_status"); |
| 48 | 48 | expect(kinds).toContain("email_status"); |
| 49 | expect(soon.value.assets).toHaveLength(8); | |
| 49 | expect(soon.value.assets).toHaveLength(9); | |
| 50 | 50 | for (const a of soon.value.assets.filter((a) => a.kind.endsWith("_status"))) { |
| 51 | 51 | expect(a.text).toMatch(/coming soon/i); |
| 52 | 52 | 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 | ||
| 66 | 66 | const soon = setStatus(db, me, { id, status: "coming_soon" }); |
| 67 | 67 | expect(soon.ok && soon.value.assets.filter((a) => a.approval !== null).map((a) => a.kind)).toEqual(["mls"]); |
| 68 | 68 | 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); | |
| 70 | 70 | |
| 71 | 71 | const sold = setStatus(db, me, { id, status: "sold" }); |
| 72 | 72 | expect(sold.ok && sold.value.assets.find((a) => a.kind === "social_status")!.text).toContain("Just sold"); |
| 73 | 73 | const again = setStatus(db, me, { id, status: "coming_soon" }); |
| 74 | 74 | expect(again.ok && again.value.assets.find((a) => a.kind === "social_status")!.text).toContain("Coming soon"); |
| 75 | 75 | 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); | |
| 77 | 77 | expect(back.ok && back.value.assets.filter((a) => a.approval !== null)).toHaveLength(1); |
| 78 | 78 | expect(setStatus(db, me, { id, status: "coming-soon" })).toMatchObject({ ok: false, error: "invalid" }); |
| 79 | 79 | }); |
| @@ -111,7 +111,7 @@ test("the web and API doors accept it and refuse the unknown with a 422", async | ||
| 111 | 111 | const patch = (body: unknown) => fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify(body) }); |
| 112 | 112 | const ok = await patch({ status: "coming_soon" }); |
| 113 | 113 | 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); | |
| 115 | 115 | expect((await patch({ status: "pending" })).status).toBe(422); |
| 116 | 116 | |
| 117 | 117 | const post = (status: string) => { |
src/generate.ts +2
| @@ -30,6 +30,7 @@ export const KINDS = [ | ||
| 30 | 30 | "email_followup", |
| 31 | 31 | "social_reminder", |
| 32 | 32 | "email_reminder", |
| 33 | "email_update", | |
| 33 | 34 | ] as const; |
| 34 | 35 | export type Kind = (typeof KINDS)[number]; |
| 35 | 36 | |
| @@ -48,6 +49,7 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 48 | 49 | email_followup: "Open house follow-up email", |
| 49 | 50 | social_reminder: "Open tomorrow post", |
| 50 | 51 | email_reminder: "Open tomorrow email", |
| 52 | email_update: "Seller update", | |
| 51 | 53 | }; |
| 52 | 54 | |
| 53 | 55 | /** 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 () => { | ||
| 59 | 59 | const launched = await call("/listings", { method: "POST", body: form }); |
| 60 | 60 | expect(launched.status).toBe(303); |
| 61 | 61 | 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"]) { | |
| 63 | 63 | const edit = new FormData(); |
| 64 | 64 | edit.set("text", PAYLOADS.asset); |
| 65 | 65 | 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 | ||
| 438 | 438 | const { to } = await launch(alice); |
| 439 | 439 | const id = Number(to.split("/").at(-1)); |
| 440 | 440 | 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"); | |
| 442 | 442 | expect(page).not.toContain("Approve</button>"); // anonymous readers have no button |
| 443 | 443 | // Two flagged phrases make the button say so. |
| 444 | 444 | const edit = new FormData(); |
| @@ -456,12 +456,12 @@ test("the page says how many are approved, names the flags, and the form and API | ||
| 456 | 456 | form.set("text", "unsaved typing"); |
| 457 | 457 | const stale = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" }); |
| 458 | 458 | 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"); | |
| 460 | 460 | form.delete("text"); |
| 461 | 461 | const posted = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" }); |
| 462 | 462 | expect(posted.status).toBe(303); |
| 463 | 463 | 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"); | |
| 465 | 465 | expect(page).toContain("Approved by carol"); |
| 466 | 466 | expect(page).toContain("via the page"); |
| 467 | 467 | |
| @@ -483,9 +483,12 @@ test("the page says how many are approved, names the flags, and the form and API | ||
| 483 | 483 | expect(scripted.status).toBeGreaterThanOrEqual(400); |
| 484 | 484 | expect(JSON.stringify(await scripted.json())).toContain("person_required"); |
| 485 | 485 | 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"); | |
| 487 | 487 | for (const kind of ["social_short", "social_medium", "social_carousel", "flyer", "email"]) |
| 488 | 488 | 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); | |
| 489 | 492 | page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text(); |
| 490 | 493 | expect(page).toContain("Ready to send"); |
| 491 | 494 | 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 | ||
| 529 | 532 | const id = Number(to.split("/").at(-1)); |
| 530 | 533 | const card = (html: string) => html.slice(html.indexOf(`href="/listings/${id}"`)).split("</li>")[0]!; |
| 531 | 534 | 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"); | |
| 533 | 536 | for (const path of ["flyer", "email"]) |
| 534 | 537 | expect(await (await fetch(`${base}${to}/${path}`)).text()).toContain("Draft — not approved"); |
| 535 | 538 | 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 | ||
| 544 | 547 | for (const kind of ["flyer", "email"]) expect((await approve(kind)).status).toBe(303); |
| 545 | 548 | for (const path of ["flyer", "email"]) |
| 546 | 549 | 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"); | |
| 548 | 551 | |
| 549 | 552 | 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); | |
| 551 | 554 | expect(summary.approvals.filter((a) => a.approved).map((a) => a.kind).sort()).toEqual(["email", "flyer"]); |
| 552 | 555 | |
| 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); | |
| 554 | 559 | expect(await home()).toContain("Ready to send"); |
| 555 | 560 | }); |
| 556 | 561 |
src/offmarket.test.ts +1 −1
| @@ -150,7 +150,7 @@ test("existing rows keep their statuses and assets across the migration", () => | ||
| 150 | 150 | const again = openDb(dir); |
| 151 | 151 | const l = get(again, made.value.id); |
| 152 | 152 | expect(l).toMatchObject({ status: "sold", sold_price: 700000 }); |
| 153 | expect(l.assets.length).toBe(8); | |
| 153 | expect(l.assets.length).toBe(9); | |
| 154 | 154 | }); |
| 155 | 155 | |
| 156 | 156 | 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 | ||
| 175 | 175 | const made = (await launch.json()) as { id: number; open_houses: unknown[]; assets: { text: string }[] }; |
| 176 | 176 | expect(made.open_houses).toEqual([sunday]); |
| 177 | 177 | // 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")]; | |
| 179 | 179 | expect(rest.filter((a) => a.text !== "").every((a) => a.text.includes("Sunday, March 7, 1–3 pm"))).toBe(true); |
| 180 | 180 | expect(thanks.length).toBe(1); |
| 181 | 181 | expect(thanks[0]!.text).toContain(SAMPLE.address); |
src/pages/listing.ts +3 −1
| @@ -113,7 +113,7 @@ export const statusForm = (l: Listing): string => | ||
| 113 | 113 | </form>`; |
| 114 | 114 | |
| 115 | 115 | /** 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 => | |
| 117 | 117 | l.assets.find((a) => a.kind === kind)?.approval == null |
| 118 | 118 | ? `<p class="draft-band" role="note">Draft — not approved</p>` |
| 119 | 119 | : ""; |
| @@ -190,6 +190,7 @@ const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = | ||
| 190 | 190 | { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price", "social_reminder"] }, |
| 191 | 191 | { id: "flyer", label: "Flyer", kinds: ["flyer"] }, |
| 192 | 192 | { id: "email", label: "Email", kinds: ["email", "email_status", "email_price", "email_followup", "email_reminder"] }, |
| 193 | { id: "update", label: "Seller update", kinds: ["email_update"] }, | |
| 193 | 194 | ]; |
| 194 | 195 | |
| 195 | 196 | /** The tab that holds an asset, or a tab named outright; the first otherwise. */ |
| @@ -265,6 +266,7 @@ export function listingPage(input: { | ||
| 265 | 266 | ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l, origin)}</a></div>` : ""} |
| 266 | 267 | ${t.id === "social" ? `<p><a class="button button-quiet" href="/listings/${l.id}/card">Square picture</a></p>` : ""} |
| 267 | 268 | ${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") : ""} | |
| 268 | 270 | ${l.assets |
| 269 | 271 | .filter((a) => t.kinds.includes(a.kind)) |
| 270 | 272 | .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", () => { | ||
| 124 | 124 | expect(now.price).toBe(849000); |
| 125 | 125 | expect(now.was_price).toBe(899000); |
| 126 | 126 | 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"); | |
| 128 | 128 | tty.press("r"); |
| 129 | 129 | for (let i = 0; i < 10; i++) tty.press("\x7f"); |
| 130 | 130 | 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 | ||
| 71 | 71 | const l = read(db, id); |
| 72 | 72 | expect(kinds(db, id)).toEqual(["social_reminder", "email_reminder"]); |
| 73 | 73 | 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"); | |
| 76 | 76 | expect(kinds(...(Object.values(launch({ open_houses: [] })) as [ReturnType<typeof openDb>, number]))).toEqual([]); |
| 77 | 77 | }); |
| 78 | 78 |
src/report.ts +2 −1
| @@ -33,8 +33,9 @@ export interface ReportRow { | ||
| 33 | 33 | readonly words: string | null; |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | /** The seller update is the agent's own note to the seller, not a thing that goes out: the report leaves it out. */ | |
| 36 | 37 | export const reportRows = (l: Launched): ReportRow[] => |
| 37 | l.assets.map((a: Asset) => { | |
| 38 | l.assets.filter((a) => a.kind !== "email_update").map((a: Asset) => { | |
| 38 | 39 | const approved = approvedLine(a.approval); |
| 39 | 40 | return { label: a.label, approved, words: approved === null ? null : a.text }; |
| 40 | 41 | }); |
src/sample.test.ts +2 −2
| @@ -58,11 +58,11 @@ const approvedOf = (db: ReturnType<typeof fresh>, like: string) => { | ||
| 58 | 58 | return r.value.assets.filter((a) => a.approval !== null); |
| 59 | 59 | }; |
| 60 | 60 | |
| 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", () => { | |
| 62 | 62 | const db = fresh(); |
| 63 | 63 | seed(db); |
| 64 | 64 | const alder = approvedOf(db, "412 Alder%"); |
| 65 | expect(alder).toHaveLength(9); | |
| 65 | expect(alder).toHaveLength(10); | |
| 66 | 66 | for (const a of alder) expect(a.approval).toMatchObject({ by_name: "Morgan Reyes (sample)", via: "sample" }); |
| 67 | 67 | expect(approvedOf(db, "27 Birchwood%")).toHaveLength(0); |
| 68 | 68 | expect(approvedOf(db, "88 Meridian%")).toHaveLength(0); |
src/sample.ts +24 −5
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | import { insertListing, markStatus } from "./actions.ts"; | |
| 9 | import { draftMissingUpdates, insertListing, markStatus, redraftUpdate } from "./actions.ts"; | |
| 10 | 10 | import type { ListingInput } from "./listing.ts"; |
| 11 | 11 | |
| 12 | 12 | export const SAMPLE: ListingInput = { |
| @@ -133,6 +133,19 @@ const SAMPLES: readonly Sample[] = [ | ||
| 133 | 133 | }, |
| 134 | 134 | ]; |
| 135 | 135 | |
| 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 | ||
| 136 | 149 | /** Plant each sample that has not been planted before; a removed one stays removed. */ |
| 137 | 150 | export function seed(db: Database): void { |
| 138 | 151 | db.transaction(() => { |
| @@ -145,10 +158,16 @@ export function seed(db: Database): void { | ||
| 145 | 158 | JSON.stringify(Object.fromEntries(s.photos.map((name, i) => [name, s.alt[i]]))), |
| 146 | 159 | ); |
| 147 | 160 | // 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); | |
| 152 | 171 | } |
| 153 | 172 | // A sample planted before statuses existed is marked sold once too; after that it is the owner's to change. |
| 154 | 173 | 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 | ||
| 77 | 77 | test("setting the status adds the two assets, each needing its own approval, and leaves the rest alone", () => { |
| 78 | 78 | const { db, id } = launch(); |
| 79 | 79 | 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); | |
| 81 | 81 | const mls = get(db, id).assets.find((a) => a.kind === "mls")!; |
| 82 | 82 | expect(editAsset(db, me, { id, kind: "mls", text: "My own words about the house." }).ok).toBe(true); |
| 83 | 83 | 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 | ||
| 87 | 87 | const sold = setStatus(db, me, { id, status: "sold", sold_price: "752000" }); |
| 88 | 88 | if (!sold.ok) throw new Error(sold.message); |
| 89 | 89 | 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"]); | |
| 91 | 91 | const mine = sold.value.assets.find((a) => a.kind === "mls")!; |
| 92 | 92 | expect(mine.text).toBe("My own words about the house."); |
| 93 | 93 | expect(mine.approval).not.toBeNull(); |
| @@ -100,7 +100,7 @@ test("setting the status adds the two assets, each needing its own approval, and | ||
| 100 | 100 | |
| 101 | 101 | // The count grows to include them. |
| 102 | 102 | 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); | |
| 104 | 104 | expect(summary.ok && summary.value[0]!.approvals.filter((a) => a.approved)).toHaveLength(1); |
| 105 | 105 | |
| 106 | 106 | // Each is approved on its own. |
| @@ -133,7 +133,7 @@ test("setting the status adds the two assets, each needing its own approval, and | ||
| 133 | 133 | const resold = setStatus(db, me, { id, status: "sold" }); |
| 134 | 134 | expect(resold.ok).toBe(true); |
| 135 | 135 | 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); | |
| 137 | 137 | }); |
| 138 | 138 | |
| 139 | 139 | 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", () => { | ||
| 168 | 168 | const page = listingPage({ who: me, listing: sold.value, editable: true }); |
| 169 | 169 | expect(page).toContain(`action="/listings/${id}/status"`); |
| 170 | 170 | expect(page).toContain('<option value="sold" selected>'); |
| 171 | expect(page).toContain("0 of 8 approved"); | |
| 171 | expect(page).toContain("0 of 9 approved"); | |
| 172 | 172 | expect(page).toContain('id="social_status"'); |
| 173 | 173 | expect(page).toContain('id="email_status"'); |
| 174 | 174 | 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", () => { | ||
| 180 | 180 | const sample = listed.ok ? listed.value.filter((l) => l.status === "sold") : []; |
| 181 | 181 | expect(sample.map((l) => l.address)).toEqual([SAMPLE_CONDO.address]); |
| 182 | 182 | expect(sample[0]!.sold_price).toBe(752000); |
| 183 | expect(sample[0]!.approvals).toHaveLength(8); | |
| 183 | expect(sample[0]!.approvals).toHaveLength(9); | |
| 184 | 184 | expect(sample[0]!.flags).toBe(0); |
| 185 | 185 | |
| 186 | 186 | // 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", () => { | ||
| 190 | 190 | seeded.run("DELETE FROM seeded WHERE name = 'sample-condo-sold'"); |
| 191 | 191 | seed(seeded); |
| 192 | 192 | 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); | |
| 194 | 194 | expect(setStatus(seeded, admin, { id: meridian, status: "active" }).ok).toBe(true); |
| 195 | 195 | seed(seeded); |
| 196 | 196 | expect(get(seeded, meridian).status).toBe("active"); |
| @@ -241,7 +241,7 @@ test("the page and the API", async () => { | ||
| 241 | 241 | expect(sold.status).toBe(200); |
| 242 | 242 | const body = (await sold.json()) as { status: string; sold_price: number; assets: { kind: string }[] }; |
| 243 | 243 | expect(body).toMatchObject({ status: "sold", sold_price: 752000 }); |
| 244 | expect(body.assets).toHaveLength(8); | |
| 244 | expect(body.assets).toHaveLength(9); | |
| 245 | 245 | expect((await patch({ status: "pending" })).status).toBe(422); |
| 246 | 246 | expect((await patch({ status: "active" }, { ...mint("someone-else"), "content-type": "application/json" })).status).toBe(403); |
| 247 | 247 |
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 | ||
| 112 | 112 | const { db } = fresh(); |
| 113 | 113 | const tty = session(db, me); |
| 114 | 114 | tty.press("\r"); |
| 115 | expect(plain(tty.screen())).toContain("0 of 6 approved"); | |
| 115 | expect(plain(tty.screen())).toContain("0 of 7 approved"); | |
| 116 | 116 | tty.press("a"); |
| 117 | 117 | 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"); | |
| 119 | 119 | expect(plain(tty.screen())).toContain("approved by me"); |
| 120 | 120 | const stored = db.query<{ approved_via: string }, []>("SELECT approved_via FROM asset WHERE approved_via IS NOT NULL").all(); |
| 121 | 121 | 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 | ||
| 124 | 124 | const theirs = session(db, other); |
| 125 | 125 | theirs.press("\r"); |
| 126 | 126 | theirs.press("a"); |
| 127 | expect(plain(theirs.screen())).toContain("0 of 6 approved"); | |
| 127 | expect(plain(theirs.screen())).toContain("0 of 7 approved"); | |
| 128 | 128 | }); |
| 129 | 129 | |
| 130 | 130 | 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", ( | ||
| 145 | 145 | test("the listings view says what is approved, and the email preview is a draft until approved", () => { |
| 146 | 146 | const { db } = fresh(); |
| 147 | 147 | 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"); | |
| 149 | 149 | tty.press("\r"); |
| 150 | 150 | tty.press("p"); |
| 151 | 151 | expect(plain(tty.screen())).toContain("DRAFT — not approved"); |
| 152 | 152 | tty.press("\x1b"); |
| 153 | for (let i = 0; i < 6; i++) { | |
| 153 | for (let i = 0; i < 7; i++) { | |
| 154 | 154 | tty.press("a"); |
| 155 | 155 | tty.press("j"); |
| 156 | 156 | } |
| @@ -210,10 +210,10 @@ test("the terminal shows a social post's length, and a short post over X needs a | ||
| 210 | 210 | |
| 211 | 211 | tty.press("a"); |
| 212 | 212 | 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"); | |
| 214 | 214 | tty.press("a"); |
| 215 | 215 | 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"); | |
| 217 | 217 | // Another key between the two presses starts the question again. |
| 218 | 218 | tty.press("a"); // withdraw |
| 219 | 219 | tty.press("a"); |
| @@ -410,3 +410,18 @@ test("O previews the Open tomorrow reminder, link included; a listing without an | ||
| 410 | 410 | tty.press("O"); |
| 411 | 411 | expect(plain(tty.screen())).toContain("This listing has no open tomorrow reminder."); |
| 412 | 412 | }); |
| 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("<img src=x onerror=alert(1)>"); | |
| 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 () => { | ||
| 123 | 123 | |
| 124 | 124 | // 4. Approve one. |
| 125 | 125 | 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"); | |
| 128 | 128 | expect(textareaOf(page)).toBe("My own words about 9 Walkthrough Way, written by hand."); |
| 129 | 129 | const approveForm = new FormData(); |
| 130 | 130 | approveForm.set("approved", "1"); |
| @@ -134,7 +134,7 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 134 | 134 | const approved = await post(`${mine}/assets/mls/approve`, approveForm); |
| 135 | 135 | expect(approved.status).toBe(303); |
| 136 | 136 | const afterApprove = await (await get(mine)).text(); |
| 137 | expect(afterApprove).toContain("1 of 6 approved"); | |
| 137 | expect(afterApprove).toContain("1 of 7 approved"); | |
| 138 | 138 | |
| 139 | 139 | // 5. Change the tone: every asset again, approval cleared. |
| 140 | 140 | expect(afterApprove).toMatch(control("Rewrite all")); |
| @@ -142,13 +142,13 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 142 | 142 | tone.set("tone", "bold"); |
| 143 | 143 | expect((await post(`${mine}/tone`, tone)).status).toBe(303); |
| 144 | 144 | const afterTone = await (await get(mine)).text(); |
| 145 | expect(afterTone).toContain("0 of 6 approved"); | |
| 145 | expect(afterTone).toContain("0 of 7 approved"); | |
| 146 | 146 | // The words were written again: the edit is gone and the text is the bold tone's. |
| 147 | 147 | expect(textareaOf(afterTone)).not.toContain("written by hand"); |
| 148 | 148 | expect(textareaOf(afterTone)).not.toBe(textareaOf(afterApprove)); |
| 149 | 149 | expect(afterTone).toContain('<option value="bold" selected>'); |
| 150 | 150 | expect(textareaOf(afterApprove)).toContain("written by hand"); |
| 151 | expect(section).toContain("0 of 6 approved"); | |
| 151 | expect(section).toContain("0 of 7 approved"); | |
| 152 | 152 | |
| 153 | 153 | // 6. Put back the MLS words: Earlier versions, Restore, checked again, approval clear. |
| 154 | 154 | expect(section).toContain("**Earlier versions (1)**"); |
| @@ -163,7 +163,7 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 163 | 163 | expect((await post(restoreForm[1]!, put)).status).toBe(303); |
| 164 | 164 | const afterRestore = await (await get(mine)).text(); |
| 165 | 165 | 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"); | |
| 167 | 167 | |
| 168 | 168 | // 7. Drop the price: Edit facts, the new price on the flyer, approvals cleared. |
| 169 | 169 | expect(section).toContain("**Edit facts**"); |
| @@ -178,7 +178,7 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 178 | 178 | approveAgain.set("seen", /name="seen" value="([^"]+)"/.exec(afterRestore)![1]!.replaceAll("&", "&")); |
| 179 | 179 | approveAgain.set("text", textareaOf(afterRestore)); |
| 180 | 180 | 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"); | |
| 182 | 182 | const drop = new FormData(); |
| 183 | 183 | for (const k of ["beds", "baths", "sqft", "lot", "year", "tone", "palette", |
| 184 | 184 | "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 () => { | ||
| 189 | 189 | expect((await post(`${mine}/facts`, drop)).status).toBe(303); |
| 190 | 190 | const afterDrop = await (await get(mine)).text(); |
| 191 | 191 | expect(afterDrop).toContain("$649,000"); |
| 192 | expect(afterDrop).toContain("0 of 8 approved"); | |
| 192 | expect(afterDrop).toContain("0 of 9 approved"); | |
| 193 | 193 | expect(await (await get(`${mine}/flyer`)).text()).toContain("$649,000"); |
| 194 | 194 | |
| 195 | 195 | // 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 () => { | ||
| 207 | 207 | approveOpen.set("seen", /name="seen" value="([^"]+)"/.exec(beforeOpen)![1]!.replaceAll("&", "&")); |
| 208 | 208 | approveOpen.set("text", textareaOf(beforeOpen)); |
| 209 | 209 | 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"); | |
| 211 | 211 | const hold = new FormData(); |
| 212 | 212 | for (const [k, v] of drop.entries()) hold.set(k, v); |
| 213 | 213 | hold.set("tone", "bold"); |
| @@ -216,7 +216,7 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 216 | 216 | hold.set("open1_end", "15:00"); |
| 217 | 217 | expect((await post(`${mine}/facts`, hold)).status).toBe(303); |
| 218 | 218 | const afterOpen = await (await get(mine)).text(); |
| 219 | expect(afterOpen).toContain("0 of 11 approved"); | |
| 219 | expect(afterOpen).toContain("0 of 12 approved"); | |
| 220 | 220 | expect(await (await get(`${mine}/flyer`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm."); |
| 221 | 221 | expect(await (await get(`${mine}/email`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm."); |
| 222 | 222 | const bad = new FormData(); |
| @@ -231,7 +231,7 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 231 | 231 | |
| 232 | 232 | // 8. Mark it sold: the status post and email, eight to approve, the band, the list. |
| 233 | 233 | expect(section).toContain("**Mark it sold.**"); |
| 234 | expect(section).toContain("0 of 8 approved"); | |
| 234 | expect(section).toContain("0 of 9 approved"); | |
| 235 | 235 | expect(section).toContain("88 Meridian Street"); |
| 236 | 236 | expect(afterTone).toContain('name="status"'); |
| 237 | 237 | expect(afterTone).toContain('<option value="sold">'); |
| @@ -241,20 +241,20 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 241 | 241 | sell.set("sold_price", "640000"); |
| 242 | 242 | expect((await post(`${mine}/status`, sell)).status).toBe(303); |
| 243 | 243 | const afterSold = await (await get(mine)).text(); |
| 244 | expect(afterSold).toContain("0 of 10 approved"); | |
| 244 | expect(afterSold).toContain("0 of 11 approved"); | |
| 245 | 245 | expect(afterSold).toContain("Sold at $640,000"); |
| 246 | 246 | expect(afterSold).toContain("Just sold — 9 Walkthrough Way"); |
| 247 | 247 | expect(await (await get(`${mine}/flyer`)).text()).toContain('<p class="flyer-status status-sold" role="note">Sold at $640,000</p>'); |
| 248 | 248 | const homeSold = await (await get("/")).text(); |
| 249 | 249 | expect(homeSold).toContain("Sold at $640,000"); |
| 250 | expect(homeSold).toContain("0 of 8 approved"); | |
| 250 | expect(homeSold).toContain("0 of 9 approved"); | |
| 251 | 251 | expect(homeSold).toContain("Sold at $752,000"); |
| 252 | 252 | const meridian = await (await get(`/listings/${idOf(homeSold, "88 Meridian Street")}`)).text(); |
| 253 | 253 | expect(meridian).toContain("Just sold — 88 Meridian Street"); |
| 254 | 254 | const active = new FormData(); |
| 255 | 255 | active.set("status", "active"); |
| 256 | 256 | 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"); | |
| 258 | 258 | |
| 259 | 259 | // 9. Print the flyer. |
| 260 | 260 | expect(afterTone).toMatch(control("Print flyer")); |