Public: anyone can read this; the crew can change it.
Get a 'Just listed' text message to past clients to approve, short enough for one SMS, with the property page link #123
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/117-get-a-just-listed-text-message-to-past-c → main 2 commits fork
23 files
changed +326
−85
README.md +15 −8
| @@ -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 8 approved". | |
| 26 | something to say). The top of the page now says "1 of 9 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 8 approved" again, because you have not read the new words yet. | |
| 30 | "0 of 9 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 8 approved". The photos stay. | |
| 47 | approval is cleared, so it says "0 of 9 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 |
| @@ -79,7 +79,7 @@ to make a listing of your own. | ||
| 79 | 79 | times exactly as you typed them, with your contact, and the property page |
| 80 | 80 | link in the text of both. With two open houses the subject names both and |
| 81 | 81 | claims no "tomorrow". Like every asset they are unapproved drafts that count |
| 82 | in "0 of 10 approved", keep versions, are Fair Housing checked, are rewritten | |
| 82 | in "0 of 11 approved", keep versions, are Fair Housing checked, are rewritten | |
| 83 | 83 | (and unapproved) when the facts change, and go when the open house does or |
| 84 | 84 | when the listing is sold or leaves the market. |
| 85 | 85 | **Copy a post.** Once you approve a social post, a **Copy post** button |
| @@ -98,7 +98,7 @@ to make a listing of your own. | ||
| 98 | 98 | status**. Two more assets appear, a status post under Social and a status |
| 99 | 99 | email under Email ("Just sold — 9 Walkthrough Way"), written in the |
| 100 | 100 | listing's tone and checked like the rest; each needs your approval, so it |
| 101 | says "0 of 10 approved". The other assets and your approvals stay. The | |
| 101 | says "0 of 11 approved". The other assets and your approvals stay. The | |
| 102 | 102 | flyer wears a "Sold" band, and the list on the first screen shows the |
| 103 | 103 | status beside the approval count. *88 Meridian Street* is already sold, so |
| 104 | 104 | you can see it. **Under contract** does the same with its own words, and |
| @@ -242,11 +242,18 @@ list cannot drift from the app. | ||
| 242 | 242 | captions), the **flyer**, the **email blast** and a **30-second video tour |
| 243 | 243 | script** (6–8 numbered shots, each naming the photo to show, the words to |
| 244 | 244 | say and the on-screen caption, at most 80 spoken words, counted beside it |
| 245 | like the MLS length). Each has an edit box, a | |
| 245 | like the MLS length) and a **text to past clients** (a "Just listed" | |
| 246 | message of at most 160 characters, the property page link included, with | |
| 247 | the first open house if there is one). Each has an edit box, a | |
| 246 | 248 | **Copy** button, and the **Fair Housing check** beside it: green when |
| 247 | 249 | clear, amber with the risky words underlined and a one-line reason — |
| 248 | 250 | *"'family-friendly' implies familial status; say 'near parks and |
| 249 | 251 | schools'"*. The check runs again as you type and on every save. |
| 252 | Under the text is `N / 160 characters`; one over reads "Too long for one | |
| 253 | text by N" and its Approve button asks you to approve it anyway (audited as | |
| 254 | `approved_too_long_for_text`). A status change (coming soon, under contract, | |
| 255 | sold, back on market) writes a matching status text. Once the text is | |
| 256 | approved, **Copy text** copies it with its link. | |
| 250 | 257 | Under each social post's box is its length and the limits it fits (X 280, |
| 251 | 258 | Instagram 2,200, Facebook 63,206). A short post over 280 reads "Too long |
| 252 | 259 | for X by 34" in the same aside, and its Approve button says "Approve — |
| @@ -454,8 +461,8 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 454 | 461 | | ---------------------------------- | -------------------------------------------------------------- | |
| 455 | 462 | | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail | |
| 456 | 463 | | `src/listing.ts` | what a listing is, and the check at the boundary that one arrived | |
| 457 | | `src/generate.ts` | `generate(listing)`: the seven assets from the facts, the video tour script among them (more once it is under contract, sold or lowered in price), in three tones | | |
| 458 | | `src/postlength.ts` | a social post's length by what X, Instagram and Facebook count | | |
| 464 | | `src/generate.ts` | `generate(listing)`: the eight assets from the facts, the video tour script and the 160-character text among them (more once it is under contract, sold or lowered in price), in three tones | | |
| 465 | | `src/postlength.ts` | a social post's length by what X, Instagram and Facebook count; the MLS and text counts against their limits | | |
| 459 | 466 | | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite | |
| 460 | 467 | | `src/photos.ts` | photos as files beside the database, known by their bytes | |
| 461 | 468 | | `src/sample.ts` | the sample listing, planted once | |
contract/openapi.json +6
| @@ -649,6 +649,8 @@ | ||
| 649 | 649 | "flyer", |
| 650 | 650 | "email", |
| 651 | 651 | "video_script", |
| 652 | "text_message", | |
| 653 | "text_status", | |
| 652 | 654 | "email_status", |
| 653 | 655 | "email_price", |
| 654 | 656 | "email_followup", |
| @@ -1541,6 +1543,8 @@ | ||
| 1541 | 1543 | "flyer", |
| 1542 | 1544 | "email", |
| 1543 | 1545 | "video_script", |
| 1546 | "text_message", | |
| 1547 | "text_status", | |
| 1544 | 1548 | "email_status", |
| 1545 | 1549 | "email_price", |
| 1546 | 1550 | "email_followup", |
| @@ -1895,6 +1899,8 @@ | ||
| 1895 | 1899 | "flyer", |
| 1896 | 1900 | "email", |
| 1897 | 1901 | "video_script", |
| 1902 | "text_message", | |
| 1903 | "text_status", | |
| 1898 | 1904 | "email_status", |
| 1899 | 1905 | "email_price", |
| 1900 | 1906 | "email_followup", |
src/actions.ts +20
| @@ -461,6 +461,26 @@ export function draftMissingVideoScripts(db: Database): number[] { | ||
| 461 | 461 | return ids; |
| 462 | 462 | } |
| 463 | 463 | |
| 464 | /** | |
| 465 | * A listing launched before the text to past clients existed gets its draft, once (and its status text | |
| 466 | * where it already carries a status post), and keeps every other approval; the ids that got a text. | |
| 467 | */ | |
| 468 | export function draftMissingTexts(db: Database): number[] { | |
| 469 | const ids = db | |
| 470 | .query<{ id: number }, []>("SELECT id FROM listing WHERE id NOT IN (SELECT listing_id FROM asset WHERE kind = 'text_message')") | |
| 471 | .all() | |
| 472 | .map((r) => r.id); | |
| 473 | const put = db.query("INSERT OR IGNORE INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0)"); | |
| 474 | for (const id of ids) { | |
| 475 | const l = launched(db, id)!; | |
| 476 | const back = l.status === "active" && l.assets.some((a) => a.kind === "social_status"); | |
| 477 | const words = generate({ facts: { ...l, back_on_market: back }, agent: agentOf(l), link: propertyPath(l, publicOrigin) }); | |
| 478 | put.run(id, "text_message", words.text_message); | |
| 479 | if (words.text_status !== undefined) put.run(id, "text_status", words.text_status); | |
| 480 | } | |
| 481 | return ids; | |
| 482 | } | |
| 483 | ||
| 464 | 484 | /** How many earlier versions of one asset's words are kept. */ |
| 465 | 485 | export const VERSIONS_KEPT = 5; |
| 466 | 486 |
src/backonmarket.test.ts +2 −2
| @@ -44,7 +44,7 @@ test("the words: every tone, clear of Fair Housing, with address, price, contact | ||
| 44 | 44 | expect(check(text).findings).toEqual([]); |
| 45 | 45 | expect(text).toContain("412 Alder Lane"); |
| 46 | 46 | expect(text).toContain(`$${SAMPLE.price.toLocaleString("en-US")}`); |
| 47 | expect(text).toContain(agent.phone); | |
| 47 | if (kind !== "text_status") expect(text).toContain(agent.phone); | |
| 48 | 48 | expect(text).not.toMatch(/undefined|NaN| {2}/); |
| 49 | 49 | expect(/open house|Open|OPEN/.test(text)).toBe(open.length > 0); |
| 50 | 50 | } |
| @@ -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(8); | |
| 89 | expect(get(db, id).assets).toHaveLength(9); | |
| 90 | 90 | } |
| 91 | 91 | }); |
| 92 | 92 |
src/comingsoon.test.ts +5 −5
| @@ -46,8 +46,8 @@ 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(10); | |
| 50 | for (const a of soon.value.assets.filter((a) => a.kind.endsWith("_status"))) { | |
| 49 | expect(soon.value.assets).toHaveLength(12); | |
| 50 | for (const a of soon.value.assets.filter((a) => a.kind.endsWith("_status") && a.kind !== "text_status")) { | |
| 51 | 51 | expect(a.text).toMatch(/coming soon/i); |
| 52 | 52 | expect(a.text).toContain("412 Alder Lane"); |
| 53 | 53 | expect(a.text).toMatch(/first to see it/); |
| @@ -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(10); | |
| 69 | expect(summary.ok && summary.value[0]!.approvals).toHaveLength(12); | |
| 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(8); | |
| 76 | expect(back.ok && back.value.assets).toHaveLength(9); | |
| 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(10); | |
| 114 | expect(((await ok.json()) as { assets: unknown[] }).assets).toHaveLength(12); | |
| 115 | 115 | expect((await patch({ status: "pending" })).status).toBe(422); |
| 116 | 116 | |
| 117 | 117 | const post = (status: string) => { |
src/generate.ts +42 −3
| @@ -14,7 +14,7 @@ export const TONES = ["classic", "warm", "bold"] as const; | ||
| 14 | 14 | export type Tone = (typeof TONES)[number]; |
| 15 | 15 | |
| 16 | 16 | /** Every asset a listing always has; the status ones come and go with its status. */ |
| 17 | export const CORE_KINDS = ["mls", "social_short", "social_medium", "social_carousel", "flyer", "email", "video_script"] as const; | |
| 17 | export const CORE_KINDS = ["mls", "social_short", "social_medium", "social_carousel", "flyer", "email", "video_script", "text_message"] as const; | |
| 18 | 18 | |
| 19 | 19 | export const KINDS = [ |
| 20 | 20 | "mls", |
| @@ -26,6 +26,8 @@ export const KINDS = [ | ||
| 26 | 26 | "flyer", |
| 27 | 27 | "email", |
| 28 | 28 | "video_script", |
| 29 | "text_message", | |
| 30 | "text_status", | |
| 29 | 31 | "email_status", |
| 30 | 32 | "email_price", |
| 31 | 33 | "email_followup", |
| @@ -45,6 +47,8 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 45 | 47 | flyer: "Flyer copy", |
| 46 | 48 | email: "Email blast", |
| 47 | 49 | video_script: "Video tour script", |
| 50 | text_message: "Text to past clients", | |
| 51 | text_status: "Status text", | |
| 48 | 52 | email_status: "Status email", |
| 49 | 53 | social_price: "Price improvement post", |
| 50 | 54 | email_price: "Price improvement email", |
| @@ -55,7 +59,7 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 55 | 59 | }; |
| 56 | 60 | |
| 57 | 61 | /** The assets that exist only while a listing is coming soon, under contract, sold, or back on the market. */ |
| 58 | export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies readonly Kind[]; | |
| 62 | export const STATUS_KINDS = ["social_status", "email_status", "text_status"] as const satisfies readonly Kind[]; | |
| 59 | 63 | |
| 60 | 64 | /** The assets that exist only while an unsold, on-market listing has an open house: the reminder for the day before. */ |
| 61 | 65 | export const REMINDER_KINDS = ["social_reminder", "email_reminder"] as const satisfies readonly Kind[]; |
| @@ -445,6 +449,40 @@ function videoScript(f: Facts, link?: string): string { | ||
| 445 | 449 | .join("\n\n"); |
| 446 | 450 | } |
| 447 | 451 | |
| 452 | /** The most characters one SMS holds, link included, counted in code points. */ | |
| 453 | export const TEXT_LIMIT = 160; | |
| 454 | ||
| 455 | /** | |
| 456 | * One text to past clients: a lead naming the street, then the price, the beds | |
| 457 | * and baths and the first open house, each dropped (last first) until the whole, | |
| 458 | * property page link included, fits one SMS. A street too long for even that is cut short. | |
| 459 | */ | |
| 460 | function textMessage(f: Facts, link?: string, status = false): string { | |
| 461 | const price = status && (f.status === "under_contract" || f.status === "sold") ? "" : `${money(f.price)}.`; | |
| 462 | const sold = f.status === "sold" && f.sold_price != null ? ` at ${money(f.sold_price)}` : ""; | |
| 463 | const lead = (where: string): string => { | |
| 464 | if (!status) return { classic: `Just listed: ${where}.`, warm: `Just listed! I am excited to share ${where}.`, bold: `JUST LISTED: ${where}.` }[f.tone]; | |
| 465 | if (f.status === "sold") | |
| 466 | return { classic: `Just sold${sold}: ${where}.`, warm: `Just sold${sold}: ${where}! Thank you all.`, bold: `JUST SOLD${sold.toUpperCase()}: ${where}.` }[f.tone]; | |
| 467 | if (f.status === "under_contract") | |
| 468 | return { classic: `Under contract: ${where}.`, warm: `Good news: ${where} is under contract!`, bold: `UNDER CONTRACT: ${where}.` }[f.tone]; | |
| 469 | if (f.status === "coming_soon") return { classic: `Coming soon: ${where}.`, warm: `Coming soon! ${where}.`, bold: `COMING SOON: ${where}.` }[f.tone]; | |
| 470 | return { classic: `Back on the market: ${where}.`, warm: `Back on the market! ${where}.`, bold: `BACK ON THE MARKET: ${where}.` }[f.tone]; | |
| 471 | }; | |
| 472 | const first = f.open_houses?.[0]; | |
| 473 | const open = first === undefined || isOffMarket(f.status) || f.status === "sold" ? "" : `Open ${whenShort(first)}.`; | |
| 474 | const facts = status && (f.status === "under_contract" || f.status === "sold") ? "" : `${num(f.beds)} bd, ${num(f.baths)} ba.`; | |
| 475 | const make = (where: string, parts: readonly string[]): string => [lead(where), ...parts, link ?? ""].filter((p) => p !== "").join(" "); | |
| 476 | const size = (t: string): number => [...t].length; | |
| 477 | const where = street(f.address); | |
| 478 | for (const parts of [[price, facts, open], [price, open], [price], []]) { | |
| 479 | const text = make(where, parts.filter((p) => p !== "")); | |
| 480 | if (size(text) <= TEXT_LIMIT) return text; | |
| 481 | } | |
| 482 | const room = TEXT_LIMIT - size(make("", [])); | |
| 483 | return make(`${[...where].slice(0, Math.max(1, room - 1)).join("").trimEnd()}…`, []); | |
| 484 | } | |
| 485 | ||
| 448 | 486 | /** What a teaser says of the home: the price, then beds · baths · square feet. Never a date. */ |
| 449 | 487 | const teaserFacts = (f: Facts): string => |
| 450 | 488 | `${money(f.price)} · ${plural(f.beds, "bedroom")} · ${plural(f.baths, "bath")} · ${count(f.sqft)} square feet`; |
| @@ -681,7 +719,8 @@ export function generate(listing: { | ||
| 681 | 719 | flyer: flyer(f), |
| 682 | 720 | email: email(f, a), |
| 683 | 721 | video_script: videoScript(f, link), |
| 684 | ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}), | |
| 722 | text_message: textMessage(f, link), | |
| 723 | ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a), text_status: textMessage(f, link, true) } : {}), | |
| 685 | 724 | ...(cut ? { social_price: pricePost(f, a), email_price: priceEmail(f, a) } : {}), |
| 686 | 725 | ...(openHouseLine(f) === null ? {} : { email_followup: followUpEmail(f, a) }), |
| 687 | 726 | ...(openHouseLine(f) === null || !remindable(f.status) ? {} : { social_reminder: reminderPost(f, a, link), email_reminder: reminderEmail(f, a, link) }), |
src/listings.test.ts +8 −8
| @@ -444,7 +444,7 @@ test("the page says how many are approved, names the flags, and the form and API | ||
| 444 | 444 | const { to } = await launch(alice); |
| 445 | 445 | const id = Number(to.split("/").at(-1)); |
| 446 | 446 | let page = await (await fetch(`${base}/listings/${id}`)).text(); |
| 447 | expect(page).toContain("0 of 8 approved"); | |
| 447 | expect(page).toContain("0 of 9 approved"); | |
| 448 | 448 | expect(page).not.toContain("Approve</button>"); // anonymous readers have no button |
| 449 | 449 | // Two flagged phrases make the button say so. |
| 450 | 450 | const edit = new FormData(); |
| @@ -462,12 +462,12 @@ test("the page says how many are approved, names the flags, and the form and API | ||
| 462 | 462 | form.set("text", "unsaved typing"); |
| 463 | 463 | const stale = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" }); |
| 464 | 464 | expect(stale.status).toBe(303); |
| 465 | expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("0 of 8 approved"); | |
| 465 | expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("0 of 9 approved"); | |
| 466 | 466 | form.delete("text"); |
| 467 | 467 | const posted = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" }); |
| 468 | 468 | expect(posted.status).toBe(303); |
| 469 | 469 | page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text(); |
| 470 | expect(page).toContain("1 of 8 approved"); | |
| 470 | expect(page).toContain("1 of 9 approved"); | |
| 471 | 471 | expect(page).toContain("Approved by carol"); |
| 472 | 472 | expect(page).toContain("via the page"); |
| 473 | 473 | |
| @@ -489,8 +489,8 @@ test("the page says how many are approved, names the flags, and the form and API | ||
| 489 | 489 | expect(scripted.status).toBeGreaterThanOrEqual(400); |
| 490 | 490 | expect(JSON.stringify(await scripted.json())).toContain("person_required"); |
| 491 | 491 | expect(await formApprove("email", "2000-01-01T00:00:00.000Z")).toBe(303); |
| 492 | expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("1 of 8 approved"); | |
| 493 | for (const kind of ["social_short", "social_medium", "social_carousel", "flyer", "email", "video_script"]) | |
| 492 | expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("1 of 9 approved"); | |
| 493 | for (const kind of ["social_short", "social_medium", "social_carousel", "flyer", "email", "video_script", "text_message"]) | |
| 494 | 494 | expect(await formApprove(kind, at(kind))).toBe(303); |
| 495 | 495 | // The seller update reports those approvals, so its words (and `updated_at`) moved with them: read it again. |
| 496 | 496 | const moved = (await (await fetch(`${base}/api/listings/${id}`, { headers: alice })).json()) as { assets: { kind: string; updated_at: string }[] }; |
| @@ -538,7 +538,7 @@ test("the home list says what is approved, and flyer and email wear a draft band | ||
| 538 | 538 | const id = Number(to.split("/").at(-1)); |
| 539 | 539 | const card = (html: string) => html.slice(html.indexOf(`href="/listings/${id}"`)).split("</li>")[0]!; |
| 540 | 540 | const home = async () => card(await (await fetch(`${base}/`)).text()); |
| 541 | expect(await home()).toContain("0 of 8 approved"); | |
| 541 | expect(await home()).toContain("0 of 9 approved"); | |
| 542 | 542 | for (const path of ["flyer", "email"]) |
| 543 | 543 | expect(await (await fetch(`${base}${to}/${path}`)).text()).toContain("Draft — not approved"); |
| 544 | 544 | expect(await (await fetch(`${base}${to}`)).text()).toContain("Draft — not approved"); |
| @@ -553,10 +553,10 @@ test("the home list says what is approved, and flyer and email wear a draft band | ||
| 553 | 553 | for (const kind of ["flyer", "email"]) expect((await approve(kind)).status).toBe(303); |
| 554 | 554 | for (const path of ["flyer", "email"]) |
| 555 | 555 | expect(await (await fetch(`${base}${to}/${path}`)).text()).not.toContain("Draft — not approved"); |
| 556 | expect(await home()).toContain("2 of 8 approved"); | |
| 556 | expect(await home()).toContain("2 of 9 approved"); | |
| 557 | 557 | |
| 558 | 558 | const summary = ((await (await fetch(`${base}/api/listings`)).json()) as { id: number; approvals: { kind: string; approved: boolean }[] }[]).find((l) => l.id === id)!; |
| 559 | expect(summary.approvals).toHaveLength(8); | |
| 559 | expect(summary.approvals).toHaveLength(9); | |
| 560 | 560 | expect(summary.approvals.filter((a) => a.approved).map((a) => a.kind).sort()).toEqual(["email", "flyer"]); |
| 561 | 561 | |
| 562 | 562 | 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); |
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(10); | |
| 153 | expect(l.assets.length).toBe(12); | |
| 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 +7 −5
| @@ -65,7 +65,9 @@ test("every asset carries the line when set, in every tone, and none when cleare | ||
| 65 | 65 | const facts = { ...SAMPLE, tone, open_houses: [sunday] }; |
| 66 | 66 | const line = openHouseLine(facts)!; |
| 67 | 67 | const words = generate({ facts, agent: agentOf(SAMPLE) }); |
| 68 | for (const kind of CORE_KINDS) expect([tone, kind, words[kind].includes(line)]).toEqual([tone, kind, true]); | |
| 68 | for (const kind of CORE_KINDS.filter((k) => k !== "text_message")) expect([tone, kind, words[kind].includes(line)]).toEqual([tone, kind, true]); | |
| 69 | // The text has room for the open house, shorter: the day and time, not the whole line. | |
| 70 | expect(words.text_message).toMatch(/Open [A-Z][a-z]{2}/); | |
| 69 | 71 | expect(words.mls.split("\n").at(-1)).toBe(line); |
| 70 | 72 | expect(words.email.split("\n\n")[1]).toBe(line); |
| 71 | 73 | expect(flyerParts(words.flyer, line).open).toBe(line); |
| @@ -94,7 +96,7 @@ test("adding, changing and clearing it writes the assets again and clears approv | ||
| 94 | 96 | return again.ok ? again.value.assets.filter((a) => a.approval !== null).length : -1; |
| 95 | 97 | }; |
| 96 | 98 | expect(text("flyer")).not.toContain("Open "); |
| 97 | expect(approve()).toBe(7); | |
| 99 | expect(approve()).toBe(8); | |
| 98 | 100 | |
| 99 | 101 | const set = updateFacts(db, me, { id, fields: { open_houses: [sunday] } }); |
| 100 | 102 | expect(set).toMatchObject({ ok: true, value: { open_houses: [sunday] } }); |
| @@ -103,7 +105,7 @@ test("adding, changing and clearing it writes the assets again and clears approv | ||
| 103 | 105 | if (set.ok) expect(set.value.assets.every((a) => a.approval === null)).toBe(true); |
| 104 | 106 | |
| 105 | 107 | // A change to something else keeps it. |
| 106 | expect(approve()).toBe(10); | |
| 108 | expect(approve()).toBe(11); | |
| 107 | 109 | expect(updateFacts(db, me, { id, fields: { price: 650000 } })).toMatchObject({ ok: true, value: { open_houses: [sunday] } }); |
| 108 | 110 | |
| 109 | 111 | const moved = updateFacts(db, me, { id, fields: { open_houses: [{ ...sunday, date: "2027-03-14" }] } }); |
| @@ -111,7 +113,7 @@ test("adding, changing and clearing it writes the assets again and clears approv | ||
| 111 | 113 | expect(text("mls")).toContain("Sunday, March 14, 1–3 pm"); |
| 112 | 114 | expect(text("mls")).not.toContain("March 7"); |
| 113 | 115 | |
| 114 | expect(approve()).toBe(12); | |
| 116 | expect(approve()).toBe(13); | |
| 115 | 117 | const cleared = updateFacts(db, me, { id, fields: { open_houses: [] } }); |
| 116 | 118 | if (!cleared.ok) throw new Error(cleared.message); |
| 117 | 119 | expect(cleared.value.open_houses).toEqual([]); |
| @@ -175,7 +177,7 @@ test("PATCH /api/listings/:id sets and clears it, and the API describes it", asy | ||
| 175 | 177 | const made = (await launch.json()) as { id: number; open_houses: unknown[]; assets: { text: string }[] }; |
| 176 | 178 | expect(made.open_houses).toEqual([sunday]); |
| 177 | 179 | // 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" && (a as { kind: string }).kind !== "email_update")]; | |
| 180 | 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" && (a as { kind: string }).kind !== "text_message")]; | |
| 179 | 181 | expect(rest.filter((a) => a.text !== "").every((a) => a.text.includes("Sunday, March 7, 1–3 pm"))).toBe(true); |
| 180 | 182 | expect(thanks.length).toBe(1); |
| 181 | 183 | expect(thanks[0]!.text).toContain(SAMPLE.address); |
src/pages/listing.ts +7 −5
| @@ -17,11 +17,12 @@ import { | ||
| 17 | 17 | statusLine, |
| 18 | 18 | STATUS_LABELS, |
| 19 | 19 | STATUSES, |
| 20 | TEXT_LIMIT, | |
| 20 | 21 | TONES, |
| 21 | 22 | } from "../generate.ts"; |
| 22 | 23 | import { asset as assetHref, escape } from "../http.ts"; |
| 23 | 24 | import { altOf, approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts"; |
| 24 | import { countLine, MLS_LIMIT, isMls, overNote } from "../postlength.ts"; | |
| 25 | import { countLine, MLS_LIMIT, isMls, isText, overNote } from "../postlength.ts"; | |
| 25 | 26 | import { isSample } from "../photos.ts"; |
| 26 | 27 | import type { Who } from "../who.ts"; |
| 27 | 28 | import { dayOf } from "../showings.ts"; |
| @@ -137,7 +138,7 @@ export function approval(l: Launched, a: Asset): string { | ||
| 137 | 138 | const n = a.check.findings.length; |
| 138 | 139 | const long = overNote(a.kind, a.text) !== ""; |
| 139 | 140 | const label = n === 0 ? "Approve" : `Approve with ${n} ${n === 1 ? "flag" : "flags"}`; |
| 140 | const words = long ? `${label} — too long for ${isMls(a.kind) ? "the MLS" : "X"}` : label; | |
| 141 | const words = long ? `${label} — too long for ${isMls(a.kind) ? "the MLS" : isText(a.kind) ? "one text" : "X"}` : label; | |
| 141 | 142 | return `<input type="hidden" name="seen" value="${escape(a.updated_at)}">${long ? `\n <input type="hidden" name="too_long" value="1">` : ""} |
| 142 | 143 | <button class="button" data-approve aria-label="${words}: the ${escape(a.label)}" formaction="${to}" name="approved" value="1">${words}</button>`; |
| 143 | 144 | } |
| @@ -192,8 +193,8 @@ export const mlsCopy = (text: string): string => | ||
| 192 | 193 | function copyPost(l: Launched, a: Asset, origin: string): string { |
| 193 | 194 | if (a.approval === null) return ""; |
| 194 | 195 | const mls = isMls(a.kind); |
| 195 | if (!mls && !a.kind.startsWith("social_")) return ""; | |
| 196 | const name = mls ? "Copy for MLS" : "Copy post"; | |
| 196 | if (!mls && !a.kind.startsWith("social_") && !isText(a.kind)) return ""; | |
| 197 | const name = mls ? "Copy for MLS" : isText(a.kind) ? "Copy text" : "Copy post"; | |
| 197 | 198 | const what = mls ? `the ${escape(a.label)} as plain text` : `the ${escape(a.label)} with its property page link`; |
| 198 | 199 | const boxLabel = mls ? `${escape(a.label)} as plain text, ready to copy` : `${escape(a.label)} with its property page link, ready to copy`; |
| 199 | 200 | return `<div data-copy-post> |
| @@ -215,7 +216,7 @@ function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Ver | ||
| 215 | 216 | ${editable ? `<button class="button" aria-label="Save the ${escape(a.label)}">Save</button>` : ""} |
| 216 | 217 | <button type="button" class="button button-quiet" data-copy data-asset="${escape(a.label)}" hidden aria-label="Copy the ${escape(a.label)}">Copy</button> |
| 217 | 218 | ${editable ? `<span data-approval>${approval(l, a)}</span>` : a.approval === null ? "" : `<span class="approved">✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}${via(a.approval.via)}</span>`} |
| 218 | <small class="muted${isMls(a.kind) ? " count-mls" : ""}" data-count${isMls(a.kind) ? ` data-limit="${MLS_LIMIT}"` : ""}>${escape(countLine(a.kind, a.text) ?? `${a.text.length} characters`)}</small> | |
| 219 | <small class="muted${isMls(a.kind) ? " count-mls" : ""}" data-count${isMls(a.kind) ? ` data-limit="${MLS_LIMIT}"` : isText(a.kind) ? ` data-limit="${TEXT_LIMIT}"` : ""}>${escape(countLine(a.kind, a.text) ?? `${a.text.length} characters`)}</small> | |
| 219 | 220 | </div> |
| 220 | 221 | </form> |
| 221 | 222 | ${copyPost(l, a, origin)} |
| @@ -230,6 +231,7 @@ const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = | ||
| 230 | 231 | { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price", "social_reminder"] }, |
| 231 | 232 | { id: "flyer", label: "Flyer", kinds: ["flyer"] }, |
| 232 | 233 | { id: "video", label: "Video", kinds: ["video_script"] }, |
| 234 | { id: "text", label: "Text", kinds: ["text_message", "text_status"] }, | |
| 233 | 235 | { id: "email", label: "Email", kinds: ["email", "email_status", "email_price", "email_followup", "email_reminder"] }, |
| 234 | 236 | { id: "update", label: "Seller update", kinds: ["email_update"] }, |
| 235 | 237 | ]; |
src/postlength.ts +12 −3
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * weights (wide CJK characters, for one) are not modelled. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { SPOKEN_MAX, spokenWords } from "./generate.ts"; | |
| 8 | import { SPOKEN_MAX, spokenWords, TEXT_LIMIT } from "./generate.ts"; | |
| 9 | 9 | |
| 10 | 10 | export const LIMITS = { x: 280, instagram: 2200, facebook: 63206 } as const; |
| 11 | 11 | /** The common MLS public-remarks cap: what an MLS keeps of the description, counted in code points. */ |
| @@ -95,21 +95,30 @@ export const isMls = (kind: string): boolean => kind === "mls"; | ||
| 95 | 95 | export const overMls = (kind: string, text: string): number => |
| 96 | 96 | isMls(kind) ? Math.max(0, points(text) - MLS_LIMIT) : 0; |
| 97 | 97 | |
| 98 | /** What approving is refused for until the agent says so: a short post over X, or an MLS description over its cap; "" when it fits. */ | |
| 98 | export const isText = (kind: string): boolean => kind === "text_message" || kind === "text_status"; | |
| 99 | ||
| 100 | /** How many characters a text is over one SMS; 0 when it fits or for another asset. */ | |
| 101 | export const overText = (kind: string, text: string): number => | |
| 102 | isText(kind) ? Math.max(0, points(text) - TEXT_LIMIT) : 0; | |
| 103 | ||
| 104 | /** What approving is refused for until the agent says so: a short post over X, an MLS description over its cap, or a text over one SMS; "" when it fits. */ | |
| 99 | 105 | export function overNote(kind: string, text: string): string { |
| 100 | 106 | if (meantForX(kind)) return overXNote(text); |
| 107 | const long = overText(kind, text); | |
| 108 | if (long > 0) return `Too long for one text by ${long}`; | |
| 101 | 109 | const over = overMls(kind, text); |
| 102 | 110 | return over > 0 ? `Too long for the MLS remarks by ${over}` : ""; |
| 103 | 111 | } |
| 104 | 112 | |
| 105 | 113 | /** The audit outcome of an approval of words that are over a limit; "" when they fit. */ |
| 106 | 114 | export const overOutcome = (kind: string, text: string): string => |
| 107 | overNote(kind, text) === "" ? "" : isMls(kind) ? "approved_too_long_for_mls" : "approved_too_long_for_x"; | |
| 115 | overNote(kind, text) === "" ? "" : isMls(kind) ? "approved_too_long_for_mls" : isText(kind) ? "approved_too_long_for_text" : "approved_too_long_for_x"; | |
| 108 | 116 | |
| 109 | 117 | /** The count shown beside an asset's box: a post's lengths, the MLS description's `N / 1,000`, else null. */ |
| 110 | 118 | export function countLine(kind: string, text: string): string | null { |
| 111 | 119 | if (isPost(kind)) return lengthLine(text); |
| 112 | 120 | if (isMls(kind)) return `${points(text).toLocaleString("en-US")} / ${MLS_LIMIT.toLocaleString("en-US")} characters`; |
| 121 | if (isText(kind)) return `${points(text)} / ${TEXT_LIMIT} characters`; | |
| 113 | 122 | if (kind === "video_script") return `${spokenWords(text)} / ${SPOKEN_MAX} spoken words`; |
| 114 | 123 | return null; |
| 115 | 124 | } |
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 10 approved"); | |
| 127 | expect(plain(tty.screen())).toContain("0 of 11 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(11); | |
| 75 | expect(kit(l)).toContain("0 of 11 assets approved"); | |
| 74 | expect(l.assets.length).toBe(12); | |
| 75 | expect(kit(l)).toContain("0 of 12 assets approved"); | |
| 76 | 76 | expect(kinds(...(Object.values(launch({ open_houses: [] })) as [ReturnType<typeof openDb>, number]))).toEqual([]); |
| 77 | 77 | }); |
| 78 | 78 |
src/report.test.ts +2 −2
| @@ -27,8 +27,8 @@ test("the Alder Lane sample reports seven approved rows by its sample agent", as | ||
| 27 | 27 | const r = await call(`/listings/${id}/report`); |
| 28 | 28 | expect(r.status).toBe(200); |
| 29 | 29 | const html = await r.text(); |
| 30 | expect(rows(html)).toBe(10); | |
| 31 | expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(10); | |
| 30 | expect(rows(html)).toBe(11); | |
| 31 | expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(11); | |
| 32 | 32 | expect(html).not.toContain("Draft — not approved yet"); |
| 33 | 33 | expect(html).toContain(`/homes/${id}`); |
| 34 | 34 | expect(html).toContain("412 Alder Lane"); |
src/sample.test.ts +1 −1
| @@ -62,7 +62,7 @@ test("Alder Lane opens with all eleven assets approved by its sample agent; Birc | ||
| 62 | 62 | const db = fresh(); |
| 63 | 63 | seed(db); |
| 64 | 64 | const alder = approvedOf(db, "412 Alder%"); |
| 65 | expect(alder).toHaveLength(11); | |
| 65 | expect(alder).toHaveLength(12); | |
| 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 +15 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | import { draftMissingUpdates, draftMissingVideoScripts, insertListing, markStatus, redraftUpdate } from "./actions.ts"; | |
| 9 | import { draftMissingTexts, draftMissingUpdates, draftMissingVideoScripts, insertListing, markStatus, redraftUpdate } from "./actions.ts"; | |
| 10 | 10 | import type { ListingInput } from "./listing.ts"; |
| 11 | 11 | import { dayOf, type Interest } from "./showings.ts"; |
| 12 | 12 | |
| @@ -197,6 +197,20 @@ export function seed(db: Database): void { | ||
| 197 | 197 | redraftUpdate(db, id); |
| 198 | 198 | } |
| 199 | 199 | } |
| 200 | // A sample planted before the text to past clients existed gets it (and its status text), signed like the rest. | |
| 201 | for (const id of draftMissingTexts(db)) { | |
| 202 | const row = db | |
| 203 | .query<{ name: string | null; via: string | null }, [number]>( | |
| 204 | "SELECT approved_by_name AS name, approved_via AS via FROM asset WHERE listing_id = ?1 AND kind = 'mls'", | |
| 205 | ) | |
| 206 | .get(id); | |
| 207 | if (row?.via === "sample" && row.name !== null) { | |
| 208 | db.query( | |
| 209 | "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 IN ('text_message', 'text_status')", | |
| 210 | ).run(id, row.name); | |
| 211 | redraftUpdate(db, id); | |
| 212 | } | |
| 213 | } | |
| 200 | 214 | // The sample planted before the seller update existed gets its draft, signed like the rest it reports. |
| 201 | 215 | for (const id of draftMissingUpdates(db)) { |
| 202 | 216 | const row = db |
src/schedule.ts +1 −1
| @@ -51,7 +51,7 @@ export function scheduleRows(l: Launched): ScheduleRow[] { | ||
| 51 | 51 | rows.push(...row(launch, "Coming soon teaser post", "social_status"), ...row(launch, "Coming soon teaser email", "email_status")); |
| 52 | 52 | } |
| 53 | 53 | rows.push(...LAUNCH_KINDS.flatMap((k) => row(launch, k === "mls" ? "Goes on the MLS" : k === "email" ? "Email blast" : "Social post", k))); |
| 54 | if (l.status !== "coming_soon") rows.push(...STATUS_KINDS.flatMap((k) => row(launch, k === "social_status" ? "Status post" : "Status email", k))); | |
| 54 | if (l.status !== "coming_soon") rows.push(...STATUS_KINDS.flatMap((k) => row(launch, k === "social_status" ? "Status post" : k === "text_status" ? "Status text" : "Status email", k))); | |
| 55 | 55 | rows.push(...PRICE_KINDS.flatMap((k) => row(launch, k === "social_price" ? "Price improvement post" : "Price improvement email", k))); |
| 56 | 56 | for (const o of l.open_houses) { |
| 57 | 57 | rows.push(...row(addDays(o.date, -1), "Open house reminder post", "social_short")); |
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(8); | |
| 80 | expect(get(db, id).assets).toHaveLength(9); | |
| 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", "video_script", "email_status", "email_update"]); | |
| 90 | expect(sold.value.assets.map((a) => a.kind)).toEqual(["mls", "social_short", "social_medium", "social_carousel", "social_status", "flyer", "email", "video_script", "text_message", "text_status", "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(10); | |
| 103 | expect(summary.ok && summary.value[0]!.approvals).toHaveLength(12); | |
| 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(8); | |
| 136 | expect(away.ok && away.value.assets).toHaveLength(9); | |
| 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 10 approved"); | |
| 171 | expect(page).toContain("0 of 12 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(10); | |
| 183 | expect(sample[0]!.approvals).toHaveLength(12); | |
| 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(10); | |
| 193 | expect(get(seeded, meridian).assets).toHaveLength(12); | |
| 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(10); | |
| 244 | expect(body.assets).toHaveLength(12); | |
| 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/textmessage.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** The text to past clients: one SMS, link included, in each tone; approval over the limit needs saying so; copy only once approved. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { approveAsset, draftMissingTexts, editAsset, getListing, launchListing, setStatus } from "./actions.ts"; | |
| 7 | import { openDb } from "./db.ts"; | |
| 8 | import { generate, TEXT_LIMIT, TONES } from "./generate.ts"; | |
| 9 | import { agentOf } from "./listing.ts"; | |
| 10 | import { listingPage } from "./pages/listing.ts"; | |
| 11 | import { check } from "./fairhousing.ts"; | |
| 12 | import { countLine, overNote } from "./postlength.ts"; | |
| 13 | import { SAMPLE, SAMPLE_OPEN_HOUSE } from "./sample.ts"; | |
| 14 | import type { Who } from "./who.ts"; | |
| 15 | ||
| 16 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 17 | const link = "https://x.example/homes/123456"; | |
| 18 | const size = (t: string) => [...t].length; | |
| 19 | ||
| 20 | test("it fits one SMS with its link in every tone, with a long address and an open house", () => { | |
| 21 | const address = `${"Wolfeschlegelsteinhausenbergerdorff ".repeat(4)}Way, Portland, OR 97214`; | |
| 22 | for (const tone of TONES) | |
| 23 | for (const f of [SAMPLE, { ...SAMPLE, address }, { ...SAMPLE, address, open_houses: [SAMPLE_OPEN_HOUSE] }, { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }]) { | |
| 24 | const words = generate({ facts: { ...f, tone }, agent: agentOf(SAMPLE), link }).text_message; | |
| 25 | expect([tone, size(words) <= TEXT_LIMIT, words.endsWith(link)]).toEqual([tone, true, true]); | |
| 26 | } | |
| 27 | }); | |
| 28 | ||
| 29 | test("the open house shows when it fits, and the tone changes the words", () => { | |
| 30 | const texts = TONES.map((tone) => generate({ facts: { ...SAMPLE, tone, open_houses: [SAMPLE_OPEN_HOUSE] }, agent: agentOf(SAMPLE), link }).text_message); | |
| 31 | expect(new Set(texts).size).toBe(3); | |
| 32 | expect(texts[0]).toContain("Open"); | |
| 33 | }); | |
| 34 | ||
| 35 | test("the count line names 160, and a text over it needs saying so", () => { | |
| 36 | expect(countLine("text_message", "a".repeat(100))).toBe("100 / 160 characters"); | |
| 37 | expect(overNote("text_message", "a".repeat(163))).toBe("Too long for one text by 3"); | |
| 38 | expect(overNote("text_message", "a".repeat(160))).toBe(""); | |
| 39 | }); | |
| 40 | ||
| 41 | const setup = () => { | |
| 42 | const db = openDb(mkdtempSync(join(tmpdir(), "app-text-"))); | |
| 43 | const made = launchListing(db, me, { fields: { ...SAMPLE } }); | |
| 44 | if (!made.ok) throw new Error(made.message); | |
| 45 | return { db, id: made.value.id }; | |
| 46 | }; | |
| 47 | const asset = (db: ReturnType<typeof openDb>, id: number, kind: string) => { | |
| 48 | const found = getListing(db, me, { id }); | |
| 49 | if (!found.ok) throw new Error(found.message); | |
| 50 | return { listing: found.value, a: found.value.assets.find((x) => x.kind === kind)! }; | |
| 51 | }; | |
| 52 | const page = (db: ReturnType<typeof openDb>, id: number) => | |
| 53 | listingPage({ who: me, listing: asset(db, id, "mls").listing, editable: true, origin: "https://x.example" }); | |
| 54 | ||
| 55 | test("a launched listing has the text; copy of an unapproved text is refused, of an approved one offers it with its link", () => { | |
| 56 | const { db, id } = setup(); | |
| 57 | const { a } = asset(db, id, "text_message"); | |
| 58 | expect(a.label).toBe("Text to past clients"); | |
| 59 | expect(page(db, id)).not.toContain("Copy text"); | |
| 60 | expect(approveAsset(db, me, { id, kind: "text_message", door: "page", seen: a.updated_at }).ok).toBe(true); | |
| 61 | const html = page(db, id); | |
| 62 | expect(html).toContain("Copy text"); | |
| 63 | expect(/data-copy-source>([^<]*)<\/textarea>/.exec(html)![1]).toContain(`https://x.example/homes/${id}`); | |
| 64 | }); | |
| 65 | ||
| 66 | test("an unapproved status text offers no copy either, and an approved one does", () => { | |
| 67 | const { db, id } = setup(); | |
| 68 | expect(setStatus(db, me, { id, status: "sold", sold_price: "700000" }).ok).toBe(true); | |
| 69 | expect(page(db, id)).not.toContain("Copy text"); | |
| 70 | const { a } = asset(db, id, "text_status"); | |
| 71 | expect(approveAsset(db, me, { id, kind: "text_status", door: "page", seen: a.updated_at }).ok).toBe(true); | |
| 72 | expect(/data-copy-source>([^<]*)<\/textarea>/.exec(page(db, id))![1]).toContain(`https://x.example/homes/${id}`); | |
| 73 | }); | |
| 74 | ||
| 75 | test("every tone and status writes a one-SMS text and status text that pass Fair Housing", () => { | |
| 76 | const cases = [ | |
| 77 | { status: "under_contract", sold_price: null, back_on_market: false }, | |
| 78 | { status: "sold", sold_price: 700000, back_on_market: false }, | |
| 79 | { status: "coming_soon", sold_price: null, back_on_market: false }, | |
| 80 | { status: "active", sold_price: null, back_on_market: true }, | |
| 81 | ] as const; | |
| 82 | for (const tone of TONES) | |
| 83 | for (const c of cases) { | |
| 84 | const words = generate({ facts: { ...SAMPLE, tone, ...c }, agent: agentOf(SAMPLE), link }); | |
| 85 | for (const kind of ["text_message", "text_status"] as const) { | |
| 86 | const text = words[kind]!; | |
| 87 | expect([tone, c.status, kind, size(text) <= TEXT_LIMIT, text.endsWith(link), check(text).findings]).toEqual([tone, c.status, kind, true, true, []]); | |
| 88 | } | |
| 89 | } | |
| 90 | }); | |
| 91 | ||
| 92 | test("a listing launched before the text existed gets it once and keeps its approvals", () => { | |
| 93 | const { db, id } = setup(); | |
| 94 | const mls = asset(db, id, "mls").a; | |
| 95 | expect(approveAsset(db, me, { id, kind: "mls", door: "page", seen: mls.updated_at }).ok).toBe(true); | |
| 96 | db.query("DELETE FROM asset WHERE listing_id = ?1 AND kind = 'text_message'").run(id); | |
| 97 | expect(draftMissingTexts(db)).toEqual([id]); | |
| 98 | expect(draftMissingTexts(db)).toEqual([]); | |
| 99 | const found = getListing(db, me, { id }); | |
| 100 | if (!found.ok) throw new Error(found.message); | |
| 101 | expect(found.value.assets.filter((x) => x.kind === "text_message")).toHaveLength(1); | |
| 102 | expect(found.value.assets.find((x) => x.kind === "mls")!.approval).not.toBeNull(); | |
| 103 | expect(found.value.assets.find((x) => x.kind === "text_message")!.approval).toBeNull(); | |
| 104 | }); | |
| 105 | ||
| 106 | test("one over 160 is refused without tooLong, approved with it, and audited as too long for a text", () => { | |
| 107 | const { db, id } = setup(); | |
| 108 | const edited = editAsset(db, me, { id, kind: "text_message", text: `Just listed ${"a".repeat(170)}` }); | |
| 109 | expect(edited.ok).toBe(true); | |
| 110 | const { a } = asset(db, id, "text_message"); | |
| 111 | const refused = approveAsset(db, me, { id, kind: "text_message", door: "page", seen: a.updated_at }); | |
| 112 | expect(refused.ok).toBe(false); | |
| 113 | expect(page(db, id)).toContain("too long for one text"); | |
| 114 | expect(approveAsset(db, me, { id, kind: "text_message", door: "page", seen: a.updated_at, tooLong: true }).ok).toBe(true); | |
| 115 | const outcome = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%' ORDER BY rowid DESC LIMIT 1").get(); | |
| 116 | expect(outcome?.outcome).toBe("approved_too_long_for_text"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a status change writes a matching status text, and back on the market writes another", () => { | |
| 120 | const { db, id } = setup(); | |
| 121 | const sold = setStatus(db, me, { id, status: "under_contract" }); | |
| 122 | expect(sold.ok && sold.value.assets.find((x) => x.kind === "text_status")!.text).toMatch(/under contract/i); | |
| 123 | const back = setStatus(db, me, { id, status: "active" }); | |
| 124 | expect(back.ok && back.value.assets.find((x) => x.kind === "text_status")!.text).toMatch(/back on the market/i); | |
| 125 | }); |
src/tty.test.ts +29 −12
| @@ -113,10 +113,10 @@ test("a approves the asset under the cursor, and withdraws it; a stranger's a is | ||
| 113 | 113 | const { db } = fresh(); |
| 114 | 114 | const tty = session(db, me); |
| 115 | 115 | tty.press("\r"); |
| 116 | expect(plain(tty.screen())).toContain("0 of 8 approved"); | |
| 116 | expect(plain(tty.screen())).toContain("0 of 9 approved"); | |
| 117 | 117 | tty.press("a"); |
| 118 | 118 | expect(plain(tty.screen())).toContain("Approved."); |
| 119 | expect(plain(tty.screen())).toContain("1 of 8 approved"); | |
| 119 | expect(plain(tty.screen())).toContain("1 of 9 approved"); | |
| 120 | 120 | expect(plain(tty.screen())).toContain("approved by me"); |
| 121 | 121 | const stored = db.query<{ approved_via: string }, []>("SELECT approved_via FROM asset WHERE approved_via IS NOT NULL").all(); |
| 122 | 122 | expect(stored.map((r) => r.approved_via)).toEqual(["tty"]); |
| @@ -125,7 +125,7 @@ test("a approves the asset under the cursor, and withdraws it; a stranger's a is | ||
| 125 | 125 | const theirs = session(db, other); |
| 126 | 126 | theirs.press("\r"); |
| 127 | 127 | theirs.press("a"); |
| 128 | expect(plain(theirs.screen())).toContain("0 of 8 approved"); | |
| 128 | expect(plain(theirs.screen())).toContain("0 of 9 approved"); | |
| 129 | 129 | }); |
| 130 | 130 | |
| 131 | 131 | test("p previews the email as the client reads it; esc returns to the assets", () => { |
| @@ -146,12 +146,12 @@ test("p previews the email as the client reads it; esc returns to the assets", ( | ||
| 146 | 146 | test("the listings view says what is approved, and the email preview is a draft until approved", () => { |
| 147 | 147 | const { db } = fresh(); |
| 148 | 148 | const tty = session(db, me); |
| 149 | expect(plain(tty.screen())).toContain("0 of 8 approved"); | |
| 149 | expect(plain(tty.screen())).toContain("0 of 9 approved"); | |
| 150 | 150 | tty.press("\r"); |
| 151 | 151 | tty.press("p"); |
| 152 | 152 | expect(plain(tty.screen())).toContain("DRAFT — not approved"); |
| 153 | 153 | tty.press("\x1b"); |
| 154 | for (let i = 0; i < 8; i++) { | |
| 154 | for (let i = 0; i < 9; i++) { | |
| 155 | 155 | tty.press("a"); |
| 156 | 156 | tty.press("j"); |
| 157 | 157 | } |
| @@ -211,10 +211,10 @@ test("the terminal shows a social post's length, and a short post over X needs a | ||
| 211 | 211 | |
| 212 | 212 | tty.press("a"); |
| 213 | 213 | expect(plain(tty.screen())).toContain("Too long for X by 34. Press a again to approve it anyway."); |
| 214 | expect(plain(tty.screen())).toContain("0 of 8 approved"); | |
| 214 | expect(plain(tty.screen())).toContain("0 of 9 approved"); | |
| 215 | 215 | tty.press("a"); |
| 216 | 216 | expect(plain(tty.screen())).toContain("Approved, too long for X."); |
| 217 | expect(plain(tty.screen())).toContain("1 of 8 approved"); | |
| 217 | expect(plain(tty.screen())).toContain("1 of 9 approved"); | |
| 218 | 218 | // Another key between the two presses starts the question again. |
| 219 | 219 | tty.press("a"); // withdraw |
| 220 | 220 | tty.press("a"); |
| @@ -237,14 +237,31 @@ test("the terminal shows the MLS description's count, and one over 1,000 needs a | ||
| 237 | 237 | |
| 238 | 238 | tty.press("a"); |
| 239 | 239 | expect(plain(tty.screen())).toContain("Too long for the MLS remarks by 3. Press a again to approve it anyway."); |
| 240 | expect(plain(tty.screen())).toContain("0 of 8 approved"); | |
| 240 | expect(plain(tty.screen())).toContain("0 of 9 approved"); | |
| 241 | 241 | tty.press("a"); |
| 242 | 242 | expect(plain(tty.screen())).toContain("Approved, too long for the MLS."); |
| 243 | expect(plain(tty.screen())).toContain("1 of 8 approved"); | |
| 243 | expect(plain(tty.screen())).toContain("1 of 9 approved"); | |
| 244 | 244 | const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%'").all(); |
| 245 | 245 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_mls"]); |
| 246 | 246 | }); |
| 247 | 247 | |
| 248 | test("the terminal shows the text's count, and one over 160 needs a second a", () => { | |
| 249 | const { db, id } = fresh(); | |
| 250 | expect(editAsset(db, me, { id, kind: "text_message", text: "Just listed " + "a".repeat(151) }).ok).toBe(true); | |
| 251 | const tty = session(db, me); | |
| 252 | tty.press("\r"); | |
| 253 | for (let i = 0; i < 7; i++) tty.press("j"); // the text is the eighth asset | |
| 254 | const seen = plain(tty.screen()); | |
| 255 | expect(seen).toContain("163 / 160 characters"); | |
| 256 | expect(seen).toContain("! Too long for one text by 3"); | |
| 257 | tty.press("a"); | |
| 258 | expect(plain(tty.screen())).toContain("Too long for one text by 3. Press a again to approve it anyway."); | |
| 259 | tty.press("a"); | |
| 260 | expect(plain(tty.screen())).toContain("Approved, too long for one text."); | |
| 261 | const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%'").all(); | |
| 262 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_text"]); | |
| 263 | }); | |
| 264 | ||
| 248 | 265 | test("f cycles the listings filter and / searches by address, as the page does", () => { |
| 249 | 266 | const { db } = fresh(); |
| 250 | 267 | const tty = session(db, me); |
| @@ -312,7 +329,7 @@ test("R shows the seller report: a draft is marked and never worded, an approved | ||
| 312 | 329 | seen = plain(tty.screen()); |
| 313 | 330 | expect(seen).toContain("Approved by me on"); |
| 314 | 331 | expect(seen).toContain(words); |
| 315 | expect(seen).toContain("1 of 7 assets approved"); | |
| 332 | expect(seen).toContain("1 of 8 assets approved"); | |
| 316 | 333 | expect(seen).toContain("Draft — not approved yet"); |
| 317 | 334 | expect(seen).toContain(`/homes/${id}`); |
| 318 | 335 | }); |
| @@ -435,7 +452,7 @@ test("the seller update is the last asset: it reads as a draft, and a approves i | ||
| 435 | 452 | const { db } = fresh(); |
| 436 | 453 | const tty = session(db, me); |
| 437 | 454 | tty.press("\r"); |
| 438 | for (let i = 0; i < 7; i++) tty.press("j"); | |
| 455 | for (let i = 0; i < 8; i++) tty.press("j"); | |
| 439 | 456 | const seen = plain(tty.screen()); |
| 440 | 457 | expect(seen).toContain("▸ Seller update"); |
| 441 | 458 | expect(seen).toContain("Subject: Your weekly update — 412 Alder Lane"); |
| @@ -537,7 +554,7 @@ test("E in the email preview shows the .eml draft once approved, and says approv | ||
| 537 | 554 | tty.press("E"); |
| 538 | 555 | expect(plain(tty.screen())).toContain("needs approving first"); |
| 539 | 556 | tty.press("\x1b"); |
| 540 | for (let i = 0; i < 7; i++) { | |
| 557 | for (let i = 0; i < 8; i++) { | |
| 541 | 558 | tty.press("a"); |
| 542 | 559 | tty.press("j"); |
| 543 | 560 | } |
src/tty.ts +2 −2
| @@ -63,7 +63,7 @@ import { reportText } from "./report.ts"; | ||
| 63 | 63 | import { scheduleText, weekText } from "./schedule.ts"; |
| 64 | 64 | import { now } from "./clock.ts"; |
| 65 | 65 | import { approvalLabel, propertyLine } from "./listing.ts"; |
| 66 | import { countLine, isMls, overNote } from "./postlength.ts"; | |
| 66 | import { countLine, isMls, isText, overNote } from "./postlength.ts"; | |
| 67 | 67 | import { emailParts } from "./pages/email.ts"; |
| 68 | 68 | import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts"; |
| 69 | 69 | import type { Who } from "./who.ts"; |
| @@ -500,7 +500,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 500 | 500 | } else { |
| 501 | 501 | const done = approveAsset(db, me, { id: found.value.id, kind: asset.kind, approved: !withdrawing, seen: asset.updated_at, door: "tty", tooLong: long }); |
| 502 | 502 | said = done.ok |
| 503 | ? withdrawing ? "Approval withdrawn." : green(long ? `Approved, too long for ${isMls(asset.kind) ? "the MLS" : "X"}.` : "Approved.") | |
| 503 | ? withdrawing ? "Approval withdrawn." : green(long ? `Approved, too long for ${isMls(asset.kind) ? "the MLS" : isText(asset.kind) ? "one text" : "X"}.` : "Approved.") | |
| 504 | 504 | : done.message; |
| 505 | 505 | } |
| 506 | 506 | } |
src/update.test.ts +1 −1
| @@ -150,7 +150,7 @@ test("the Alder Lane sample's update is approved, lists its nine assets and the | ||
| 150 | 150 | const row = db.query<{ id: number }, []>("SELECT id FROM listing WHERE address LIKE '412 Alder%'").get()!; |
| 151 | 151 | const a = read(db, row.id).assets.find((x) => x.kind === "email_update")!; |
| 152 | 152 | expect(a.approval).toMatchObject({ via: "sample" }); |
| 153 | expect(a.text.match(/Approved by Morgan Reyes \(sample\) on /g)).toHaveLength(10); | |
| 153 | expect(a.text.match(/Approved by Morgan Reyes \(sample\) on /g)).toHaveLength(11); | |
| 154 | 154 | expect(a.text).toContain(`/homes/${row.id}`); |
| 155 | 155 | expect(check(a.text).clear).toBe(true); |
| 156 | 156 | }); |
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 8 approved"); | |
| 127 | expect(section).toContain("1 of 8 approved"); | |
| 126 | expect(page).toContain("0 of 9 approved"); | |
| 127 | expect(section).toContain("1 of 9 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 8 approved"); | |
| 137 | expect(afterApprove).toContain("1 of 9 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 8 approved"); | |
| 145 | expect(afterTone).toContain("0 of 9 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 8 approved"); | |
| 151 | expect(section).toContain("0 of 9 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 8 approved"); | |
| 166 | expect(afterRestore).toContain("0 of 9 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 8 approved"); | |
| 181 | expect(await (await get(mine)).text()).toContain("1 of 9 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 10 approved"); | |
| 192 | expect(afterDrop).toContain("0 of 11 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 10 approved"); | |
| 210 | expect(await (await get(mine)).text()).toContain("1 of 11 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 13 approved"); | |
| 219 | expect(afterOpen).toContain("0 of 14 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 10 approved"); | |
| 234 | expect(section).toContain("0 of 11 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 12 approved"); | |
| 244 | expect(afterSold).toContain("0 of 14 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 10 approved"); | |
| 250 | expect(homeSold).toContain("0 of 12 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 10 approved"); | |
| 257 | expect(await (await get(mine)).text()).toContain("0 of 11 approved"); | |
| 258 | 258 | |
| 259 | 259 | // 9. Print the flyer. |
| 260 | 260 | expect(afterTone).toMatch(control("Print flyer")); |