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

Merged opened this

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

Merged

mason/listing-launch:mason/117-get-a-just-listed-text-message-to-past-c → main 2 commits fork

  • Merged. mason/117-get-a-just-listed-text-message-to-past-c was merged into main as 578d1fc.

Review

Sign in to review this change.
23 files changed +326 −85
README.md +15 −8
@@ -23,11 +23,11 @@ to make a listing of your own.
2323 is only to read; the new listing is yours to change.
2424 4. **Approve one.** On your listing, read the MLS description, then press
2525 **Approve** under it (**Approve with 1 flag** when the check still has
26 something to say). The top of the page now says "1 of 8 approved".
26 something to say). The top of the page now says "1 of 9 approved".
2727 5. **Change the tone.** Pick another tone beside the address (Classic, Warm
2828 or Bold) and press **Rewrite all**. Every asset is written again in that
2929 tone, your edits are replaced, and the approval is cleared: it says
30 "0 of 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.
3131 6. **Put back your MLS words.** Under the MLS box, press **Earlier versions
3232 (1)**: it lists the words the rewrite replaced, when, by whom and why
3333 ("rewrite in Bold"), and the first line of each. Press **Restore** by
@@ -44,7 +44,7 @@ to make a listing of your own.
4444 square feet, lot, year, price, features and brand. Lower the price and
4545 press **Save and write all again**: the new price is on the flyer, every
4646 asset is written again from the facts (your edits are replaced), and the
47 approval is cleared, so it says "0 of 8 approved". The photos stay.
47 approval is cleared, so it says "0 of 9 approved". The photos stay.
4848 8. **Add an open house.** Press **Edit facts** again. Under "Open house"
4949 pick a date, a start and an end time (Alder Lane's sample already has one),
5050 then press **Save and write all again**. The flyer gets a band under the
@@ -79,7 +79,7 @@ to make a listing of your own.
7979 times exactly as you typed them, with your contact, and the property page
8080 link in the text of both. With two open houses the subject names both and
8181 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
8383 (and unapproved) when the facts change, and go when the open house does or
8484 when the listing is sold or leaves the market.
8585 **Copy a post.** Once you approve a social post, a **Copy post** button
@@ -98,7 +98,7 @@ to make a listing of your own.
9898 status**. Two more assets appear, a status post under Social and a status
9999 email under Email ("Just sold — 9 Walkthrough Way"), written in the
100100 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
102102 flyer wears a "Sold" band, and the list on the first screen shows the
103103 status beside the approval count. *88 Meridian Street* is already sold, so
104104 you can see it. **Under contract** does the same with its own words, and
@@ -242,11 +242,18 @@ list cannot drift from the app.
242242 captions), the **flyer**, the **email blast** and a **30-second video tour
243243 script** (6–8 numbered shots, each naming the photo to show, the words to
244244 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
246248 **Copy** button, and the **Fair Housing check** beside it: green when
247249 clear, amber with the risky words underlined and a one-line reason —
248250 *"'family-friendly' implies familial status; say 'near parks and
249251 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.
250257 Under each social post's box is its length and the limits it fits (X 280,
251258 Instagram 2,200, Facebook 63,206). A short post over 280 reads "Too long
252259 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.
454461 | ---------------------------------- | -------------------------------------------------------------- |
455462 | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail |
456463 | `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 |
459466 | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite |
460467 | `src/photos.ts` | photos as files beside the database, known by their bytes |
461468 | `src/sample.ts` | the sample listing, planted once |
contract/openapi.json +6
@@ -649,6 +649,8 @@
649649 "flyer",
650650 "email",
651651 "video_script",
652 "text_message",
653 "text_status",
652654 "email_status",
653655 "email_price",
654656 "email_followup",
@@ -1541,6 +1543,8 @@
15411543 "flyer",
15421544 "email",
15431545 "video_script",
1546 "text_message",
1547 "text_status",
15441548 "email_status",
15451549 "email_price",
15461550 "email_followup",
@@ -1895,6 +1899,8 @@
18951899 "flyer",
18961900 "email",
18971901 "video_script",
1902 "text_message",
1903 "text_status",
18981904 "email_status",
18991905 "email_price",
19001906 "email_followup",
src/actions.ts +20
@@ -461,6 +461,26 @@ export function draftMissingVideoScripts(db: Database): number[] {
461461 return ids;
462462 }
463463
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
464484 /** How many earlier versions of one asset's words are kept. */
465485 export const VERSIONS_KEPT = 5;
466486
src/backonmarket.test.ts +2 −2
@@ -44,7 +44,7 @@ test("the words: every tone, clear of Fair Housing, with address, price, contact
4444 expect(check(text).findings).toEqual([]);
4545 expect(text).toContain("412 Alder Lane");
4646 expect(text).toContain(`$${SAMPLE.price.toLocaleString("en-US")}`);
47 expect(text).toContain(agent.phone);
47 if (kind !== "text_status") expect(text).toContain(agent.phone);
4848 expect(text).not.toMatch(/undefined|NaN| {2}/);
4949 expect(/open house|Open|OPEN/.test(text)).toBe(open.length > 0);
5050 }
@@ -86,7 +86,7 @@ test("sold → active, and coming soon → active, take the status words away",
8686 const { db, id } = launch();
8787 setStatus(db, me, { id, status: from });
8888 setStatus(db, me, { id, status: "active" });
89 expect(get(db, id).assets).toHaveLength(8);
89 expect(get(db, id).assets).toHaveLength(9);
9090 }
9191 });
9292
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
4646 const kinds = soon.value.assets.map((a) => a.kind);
4747 expect(kinds).toContain("social_status");
4848 expect(kinds).toContain("email_status");
49 expect(soon.value.assets).toHaveLength(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")) {
5151 expect(a.text).toMatch(/coming soon/i);
5252 expect(a.text).toContain("412 Alder Lane");
5353 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
6666 const soon = setStatus(db, me, { id, status: "coming_soon" });
6767 expect(soon.ok && soon.value.assets.filter((a) => a.approval !== null).map((a) => a.kind)).toEqual(["mls"]);
6868 const summary = listListings(db, me);
69 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(10);
69 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(12);
7070
7171 const sold = setStatus(db, me, { id, status: "sold" });
7272 expect(sold.ok && sold.value.assets.find((a) => a.kind === "social_status")!.text).toContain("Just sold");
7373 const again = setStatus(db, me, { id, status: "coming_soon" });
7474 expect(again.ok && again.value.assets.find((a) => a.kind === "social_status")!.text).toContain("Coming soon");
7575 const back = setStatus(db, me, { id, status: "active" });
76 expect(back.ok && back.value.assets).toHaveLength(8);
76 expect(back.ok && back.value.assets).toHaveLength(9);
7777 expect(back.ok && back.value.assets.filter((a) => a.approval !== null)).toHaveLength(1);
7878 expect(setStatus(db, me, { id, status: "coming-soon" })).toMatchObject({ ok: false, error: "invalid" });
7979 });
@@ -111,7 +111,7 @@ test("the web and API doors accept it and refuse the unknown with a 422", async
111111 const patch = (body: unknown) => fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify(body) });
112112 const ok = await patch({ status: "coming_soon" });
113113 expect(ok.status).toBe(200);
114 expect(((await ok.json()) as { assets: unknown[] }).assets).toHaveLength(10);
114 expect(((await ok.json()) as { assets: unknown[] }).assets).toHaveLength(12);
115115 expect((await patch({ status: "pending" })).status).toBe(422);
116116
117117 const post = (status: string) => {
src/generate.ts +42 −3
@@ -14,7 +14,7 @@ export const TONES = ["classic", "warm", "bold"] as const;
1414 export type Tone = (typeof TONES)[number];
1515
1616 /** 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;
1818
1919 export const KINDS = [
2020 "mls",
@@ -26,6 +26,8 @@ export const KINDS = [
2626 "flyer",
2727 "email",
2828 "video_script",
29 "text_message",
30 "text_status",
2931 "email_status",
3032 "email_price",
3133 "email_followup",
@@ -45,6 +47,8 @@ export const LABELS: Readonly<Record<Kind, string>> = {
4547 flyer: "Flyer copy",
4648 email: "Email blast",
4749 video_script: "Video tour script",
50 text_message: "Text to past clients",
51 text_status: "Status text",
4852 email_status: "Status email",
4953 social_price: "Price improvement post",
5054 email_price: "Price improvement email",
@@ -55,7 +59,7 @@ export const LABELS: Readonly<Record<Kind, string>> = {
5559 };
5660
5761 /** 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[];
5963
6064 /** The assets that exist only while an unsold, on-market listing has an open house: the reminder for the day before. */
6165 export const REMINDER_KINDS = ["social_reminder", "email_reminder"] as const satisfies readonly Kind[];
@@ -445,6 +449,40 @@ function videoScript(f: Facts, link?: string): string {
445449 .join("\n\n");
446450 }
447451
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
448486 /** What a teaser says of the home: the price, then beds · baths · square feet. Never a date. */
449487 const teaserFacts = (f: Facts): string =>
450488 `${money(f.price)} · ${plural(f.beds, "bedroom")} · ${plural(f.baths, "bath")} · ${count(f.sqft)} square feet`;
@@ -681,7 +719,8 @@ export function generate(listing: {
681719 flyer: flyer(f),
682720 email: email(f, a),
683721 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) } : {}),
685724 ...(cut ? { social_price: pricePost(f, a), email_price: priceEmail(f, a) } : {}),
686725 ...(openHouseLine(f) === null ? {} : { email_followup: followUpEmail(f, a) }),
687726 ...(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
444444 const { to } = await launch(alice);
445445 const id = Number(to.split("/").at(-1));
446446 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");
448448 expect(page).not.toContain("Approve</button>"); // anonymous readers have no button
449449 // Two flagged phrases make the button say so.
450450 const edit = new FormData();
@@ -462,12 +462,12 @@ test("the page says how many are approved, names the flags, and the form and API
462462 form.set("text", "unsaved typing");
463463 const stale = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" });
464464 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");
466466 form.delete("text");
467467 const posted = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" });
468468 expect(posted.status).toBe(303);
469469 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");
471471 expect(page).toContain("Approved by carol");
472472 expect(page).toContain("via the page");
473473
@@ -489,8 +489,8 @@ test("the page says how many are approved, names the flags, and the form and API
489489 expect(scripted.status).toBeGreaterThanOrEqual(400);
490490 expect(JSON.stringify(await scripted.json())).toContain("person_required");
491491 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"])
494494 expect(await formApprove(kind, at(kind))).toBe(303);
495495 // The seller update reports those approvals, so its words (and `updated_at`) moved with them: read it again.
496496 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
538538 const id = Number(to.split("/").at(-1));
539539 const card = (html: string) => html.slice(html.indexOf(`href="/listings/${id}"`)).split("</li>")[0]!;
540540 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");
542542 for (const path of ["flyer", "email"])
543543 expect(await (await fetch(`${base}${to}/${path}`)).text()).toContain("Draft — not approved");
544544 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
553553 for (const kind of ["flyer", "email"]) expect((await approve(kind)).status).toBe(303);
554554 for (const path of ["flyer", "email"])
555555 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");
557557
558558 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);
560560 expect(summary.approvals.filter((a) => a.approved).map((a) => a.kind).sort()).toEqual(["email", "flyer"]);
561561
562562 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", () =>
150150 const again = openDb(dir);
151151 const l = get(again, made.value.id);
152152 expect(l).toMatchObject({ status: "sold", sold_price: 700000 });
153 expect(l.assets.length).toBe(10);
153 expect(l.assets.length).toBe(12);
154154 });
155155
156156 test("a person's edited flyer, email and post lose their open house line but keep their other words", () => {
src/openhouse.test.ts +7 −5
@@ -65,7 +65,9 @@ test("every asset carries the line when set, in every tone, and none when cleare
6565 const facts = { ...SAMPLE, tone, open_houses: [sunday] };
6666 const line = openHouseLine(facts)!;
6767 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}/);
6971 expect(words.mls.split("\n").at(-1)).toBe(line);
7072 expect(words.email.split("\n\n")[1]).toBe(line);
7173 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
9496 return again.ok ? again.value.assets.filter((a) => a.approval !== null).length : -1;
9597 };
9698 expect(text("flyer")).not.toContain("Open ");
97 expect(approve()).toBe(7);
99 expect(approve()).toBe(8);
98100
99101 const set = updateFacts(db, me, { id, fields: { open_houses: [sunday] } });
100102 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
103105 if (set.ok) expect(set.value.assets.every((a) => a.approval === null)).toBe(true);
104106
105107 // A change to something else keeps it.
106 expect(approve()).toBe(10);
108 expect(approve()).toBe(11);
107109 expect(updateFacts(db, me, { id, fields: { price: 650000 } })).toMatchObject({ ok: true, value: { open_houses: [sunday] } });
108110
109111 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
111113 expect(text("mls")).toContain("Sunday, March 14, 1–3 pm");
112114 expect(text("mls")).not.toContain("March 7");
113115
114 expect(approve()).toBe(12);
116 expect(approve()).toBe(13);
115117 const cleared = updateFacts(db, me, { id, fields: { open_houses: [] } });
116118 if (!cleared.ok) throw new Error(cleared.message);
117119 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
175177 const made = (await launch.json()) as { id: number; open_houses: unknown[]; assets: { text: string }[] };
176178 expect(made.open_houses).toEqual([sunday]);
177179 // 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")];
179181 expect(rest.filter((a) => a.text !== "").every((a) => a.text.includes("Sunday, March 7, 1–3 pm"))).toBe(true);
180182 expect(thanks.length).toBe(1);
181183 expect(thanks[0]!.text).toContain(SAMPLE.address);
src/pages/listing.ts +7 −5
@@ -17,11 +17,12 @@ import {
1717 statusLine,
1818 STATUS_LABELS,
1919 STATUSES,
20 TEXT_LIMIT,
2021 TONES,
2122 } from "../generate.ts";
2223 import { asset as assetHref, escape } from "../http.ts";
2324 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";
2526 import { isSample } from "../photos.ts";
2627 import type { Who } from "../who.ts";
2728 import { dayOf } from "../showings.ts";
@@ -137,7 +138,7 @@ export function approval(l: Launched, a: Asset): string {
137138 const n = a.check.findings.length;
138139 const long = overNote(a.kind, a.text) !== "";
139140 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;
141142 return `<input type="hidden" name="seen" value="${escape(a.updated_at)}">${long ? `\n <input type="hidden" name="too_long" value="1">` : ""}
142143 <button class="button" data-approve aria-label="${words}: the ${escape(a.label)}" formaction="${to}" name="approved" value="1">${words}</button>`;
143144 }
@@ -192,8 +193,8 @@ export const mlsCopy = (text: string): string =>
192193 function copyPost(l: Launched, a: Asset, origin: string): string {
193194 if (a.approval === null) return "";
194195 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";
197198 const what = mls ? `the ${escape(a.label)} as plain text` : `the ${escape(a.label)} with its property page link`;
198199 const boxLabel = mls ? `${escape(a.label)} as plain text, ready to copy` : `${escape(a.label)} with its property page link, ready to copy`;
199200 return `<div data-copy-post>
@@ -215,7 +216,7 @@ function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Ver
215216 ${editable ? `<button class="button" aria-label="Save the ${escape(a.label)}">Save</button>` : ""}
216217 <button type="button" class="button button-quiet" data-copy data-asset="${escape(a.label)}" hidden aria-label="Copy the ${escape(a.label)}">Copy</button>
217218 ${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>
219220 </div>
220221 </form>
221222 ${copyPost(l, a, origin)}
@@ -230,6 +231,7 @@ const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] =
230231 { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price", "social_reminder"] },
231232 { id: "flyer", label: "Flyer", kinds: ["flyer"] },
232233 { id: "video", label: "Video", kinds: ["video_script"] },
234 { id: "text", label: "Text", kinds: ["text_message", "text_status"] },
233235 { id: "email", label: "Email", kinds: ["email", "email_status", "email_price", "email_followup", "email_reminder"] },
234236 { id: "update", label: "Seller update", kinds: ["email_update"] },
235237 ];
src/postlength.ts +12 −3
@@ -5,7 +5,7 @@
55 * weights (wide CJK characters, for one) are not modelled.
66 */
77
8 import { SPOKEN_MAX, spokenWords } from "./generate.ts";
8 import { SPOKEN_MAX, spokenWords, TEXT_LIMIT } from "./generate.ts";
99
1010 export const LIMITS = { x: 280, instagram: 2200, facebook: 63206 } as const;
1111 /** 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";
9595 export const overMls = (kind: string, text: string): number =>
9696 isMls(kind) ? Math.max(0, points(text) - MLS_LIMIT) : 0;
9797
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. */
99105 export function overNote(kind: string, text: string): string {
100106 if (meantForX(kind)) return overXNote(text);
107 const long = overText(kind, text);
108 if (long > 0) return `Too long for one text by ${long}`;
101109 const over = overMls(kind, text);
102110 return over > 0 ? `Too long for the MLS remarks by ${over}` : "";
103111 }
104112
105113 /** The audit outcome of an approval of words that are over a limit; "" when they fit. */
106114 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";
108116
109117 /** The count shown beside an asset's box: a post's lengths, the MLS description's `N / 1,000`, else null. */
110118 export function countLine(kind: string, text: string): string | null {
111119 if (isPost(kind)) return lengthLine(text);
112120 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`;
113122 if (kind === "video_script") return `${spokenWords(text)} / ${SPOKEN_MAX} spoken words`;
114123 return null;
115124 }
src/priceimprovement.test.ts +1 −1
@@ -124,7 +124,7 @@ test("the terminal lowers the price with r and shows the new assets", () => {
124124 expect(now.price).toBe(849000);
125125 expect(now.was_price).toBe(899000);
126126 expect(now.assets.map((a) => a.kind)).toContain("social_price");
127 expect(plain(tty.screen())).toContain("0 of 10 approved");
127 expect(plain(tty.screen())).toContain("0 of 11 approved");
128128 tty.press("r");
129129 for (let i = 0; i < 10; i++) tty.press("\x7f");
130130 for (const key of "lots") tty.press(key);
src/reminder.test.ts +2 −2
@@ -71,8 +71,8 @@ test("a listing with an open house gets both, unapproved, counted, with versions
7171 const l = read(db, id);
7272 expect(kinds(db, id)).toEqual(["social_reminder", "email_reminder"]);
7373 for (const kind of ["social_reminder", "email_reminder"]) expect(l.assets.find((a) => a.kind === kind)!.approval).toBeNull();
74 expect(l.assets.length).toBe(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");
7676 expect(kinds(...(Object.values(launch({ open_houses: [] })) as [ReturnType<typeof openDb>, number]))).toEqual([]);
7777 });
7878
src/report.test.ts +2 −2
@@ -27,8 +27,8 @@ test("the Alder Lane sample reports seven approved rows by its sample agent", as
2727 const r = await call(`/listings/${id}/report`);
2828 expect(r.status).toBe(200);
2929 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);
3232 expect(html).not.toContain("Draft — not approved yet");
3333 expect(html).toContain(`/homes/${id}`);
3434 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
6262 const db = fresh();
6363 seed(db);
6464 const alder = approvedOf(db, "412 Alder%");
65 expect(alder).toHaveLength(11);
65 expect(alder).toHaveLength(12);
6666 for (const a of alder) expect(a.approval).toMatchObject({ by_name: "Morgan Reyes (sample)", via: "sample" });
6767 expect(approvedOf(db, "27 Birchwood%")).toHaveLength(0);
6868 expect(approvedOf(db, "88 Meridian%")).toHaveLength(0);
src/sample.ts +15 −1
@@ -6,7 +6,7 @@
66 */
77
88 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";
1010 import type { ListingInput } from "./listing.ts";
1111 import { dayOf, type Interest } from "./showings.ts";
1212
@@ -197,6 +197,20 @@ export function seed(db: Database): void {
197197 redraftUpdate(db, id);
198198 }
199199 }
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 }
200214 // The sample planted before the seller update existed gets its draft, signed like the rest it reports.
201215 for (const id of draftMissingUpdates(db)) {
202216 const row = db
src/schedule.ts +1 −1
@@ -51,7 +51,7 @@ export function scheduleRows(l: Launched): ScheduleRow[] {
5151 rows.push(...row(launch, "Coming soon teaser post", "social_status"), ...row(launch, "Coming soon teaser email", "email_status"));
5252 }
5353 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)));
5555 rows.push(...PRICE_KINDS.flatMap((k) => row(launch, k === "social_price" ? "Price improvement post" : "Price improvement email", k)));
5656 for (const o of l.open_houses) {
5757 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
7777 test("setting the status adds the two assets, each needing its own approval, and leaves the rest alone", () => {
7878 const { db, id } = launch();
7979 expect(get(db, id)).toMatchObject({ status: "active", sold_price: null });
80 expect(get(db, id).assets).toHaveLength(8);
80 expect(get(db, id).assets).toHaveLength(9);
8181 const mls = get(db, id).assets.find((a) => a.kind === "mls")!;
8282 expect(editAsset(db, me, { id, kind: "mls", text: "My own words about the house." }).ok).toBe(true);
8383 const fresh = get(db, id).assets.find((a) => a.kind === "mls")!;
@@ -87,7 +87,7 @@ test("setting the status adds the two assets, each needing its own approval, and
8787 const sold = setStatus(db, me, { id, status: "sold", sold_price: "752000" });
8888 if (!sold.ok) throw new Error(sold.message);
8989 expect(sold.value).toMatchObject({ status: "sold", sold_price: 752000 });
90 expect(sold.value.assets.map((a) => a.kind)).toEqual(["mls", "social_short", "social_medium", "social_carousel", "social_status", "flyer", "email", "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"]);
9191 const mine = sold.value.assets.find((a) => a.kind === "mls")!;
9292 expect(mine.text).toBe("My own words about the house.");
9393 expect(mine.approval).not.toBeNull();
@@ -100,7 +100,7 @@ test("setting the status adds the two assets, each needing its own approval, and
100100
101101 // The count grows to include them.
102102 const summary = listListings(db, me);
103 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(10);
103 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(12);
104104 expect(summary.ok && summary.value[0]!.approvals.filter((a) => a.approved)).toHaveLength(1);
105105
106106 // Each is approved on its own.
@@ -133,7 +133,7 @@ test("setting the status adds the two assets, each needing its own approval, and
133133 const resold = setStatus(db, me, { id, status: "sold" });
134134 expect(resold.ok).toBe(true);
135135 const away = setStatus(db, me, { id, status: "active" });
136 expect(away.ok && away.value.assets).toHaveLength(8);
136 expect(away.ok && away.value.assets).toHaveLength(9);
137137 });
138138
139139 test("who may: the listing's agent or an admin, nobody else, and a bad status changes nothing", () => {
@@ -168,7 +168,7 @@ test("the pill, the flyer band and the sample", () => {
168168 const page = listingPage({ who: me, listing: sold.value, editable: true });
169169 expect(page).toContain(`action="/listings/${id}/status"`);
170170 expect(page).toContain('<option value="sold" selected>');
171 expect(page).toContain("0 of 10 approved");
171 expect(page).toContain("0 of 12 approved");
172172 expect(page).toContain('id="social_status"');
173173 expect(page).toContain('id="email_status"');
174174 expect(listingPage({ who: other, listing: sold.value, editable: false })).not.toContain("/status");
@@ -180,7 +180,7 @@ test("the pill, the flyer band and the sample", () => {
180180 const sample = listed.ok ? listed.value.filter((l) => l.status === "sold") : [];
181181 expect(sample.map((l) => l.address)).toEqual([SAMPLE_CONDO.address]);
182182 expect(sample[0]!.sold_price).toBe(752000);
183 expect(sample[0]!.approvals).toHaveLength(10);
183 expect(sample[0]!.approvals).toHaveLength(12);
184184 expect(sample[0]!.flags).toBe(0);
185185
186186 // A database seeded before statuses existed gets the sold sample once, and keeps a later change.
@@ -190,7 +190,7 @@ test("the pill, the flyer band and the sample", () => {
190190 seeded.run("DELETE FROM seeded WHERE name = 'sample-condo-sold'");
191191 seed(seeded);
192192 expect(get(seeded, meridian)).toMatchObject({ status: "sold", sold_price: 752000 });
193 expect(get(seeded, meridian).assets).toHaveLength(10);
193 expect(get(seeded, meridian).assets).toHaveLength(12);
194194 expect(setStatus(seeded, admin, { id: meridian, status: "active" }).ok).toBe(true);
195195 seed(seeded);
196196 expect(get(seeded, meridian).status).toBe("active");
@@ -241,7 +241,7 @@ test("the page and the API", async () => {
241241 expect(sold.status).toBe(200);
242242 const body = (await sold.json()) as { status: string; sold_price: number; assets: { kind: string }[] };
243243 expect(body).toMatchObject({ status: "sold", sold_price: 752000 });
244 expect(body.assets).toHaveLength(10);
244 expect(body.assets).toHaveLength(12);
245245 expect((await patch({ status: "pending" })).status).toBe(422);
246246 expect((await patch({ status: "active" }, { ...mint("someone-else"), "content-type": "application/json" })).status).toBe(403);
247247
src/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
113113 const { db } = fresh();
114114 const tty = session(db, me);
115115 tty.press("\r");
116 expect(plain(tty.screen())).toContain("0 of 8 approved");
116 expect(plain(tty.screen())).toContain("0 of 9 approved");
117117 tty.press("a");
118118 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");
120120 expect(plain(tty.screen())).toContain("approved by me");
121121 const stored = db.query<{ approved_via: string }, []>("SELECT approved_via FROM asset WHERE approved_via IS NOT NULL").all();
122122 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
125125 const theirs = session(db, other);
126126 theirs.press("\r");
127127 theirs.press("a");
128 expect(plain(theirs.screen())).toContain("0 of 8 approved");
128 expect(plain(theirs.screen())).toContain("0 of 9 approved");
129129 });
130130
131131 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", (
146146 test("the listings view says what is approved, and the email preview is a draft until approved", () => {
147147 const { db } = fresh();
148148 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");
150150 tty.press("\r");
151151 tty.press("p");
152152 expect(plain(tty.screen())).toContain("DRAFT — not approved");
153153 tty.press("\x1b");
154 for (let i = 0; i < 8; i++) {
154 for (let i = 0; i < 9; i++) {
155155 tty.press("a");
156156 tty.press("j");
157157 }
@@ -211,10 +211,10 @@ test("the terminal shows a social post's length, and a short post over X needs a
211211
212212 tty.press("a");
213213 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");
215215 tty.press("a");
216216 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");
218218 // Another key between the two presses starts the question again.
219219 tty.press("a"); // withdraw
220220 tty.press("a");
@@ -237,14 +237,31 @@ test("the terminal shows the MLS description's count, and one over 1,000 needs a
237237
238238 tty.press("a");
239239 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");
241241 tty.press("a");
242242 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");
244244 const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%'").all();
245245 expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_mls"]);
246246 });
247247
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
248265 test("f cycles the listings filter and / searches by address, as the page does", () => {
249266 const { db } = fresh();
250267 const tty = session(db, me);
@@ -312,7 +329,7 @@ test("R shows the seller report: a draft is marked and never worded, an approved
312329 seen = plain(tty.screen());
313330 expect(seen).toContain("Approved by me on");
314331 expect(seen).toContain(words);
315 expect(seen).toContain("1 of 7 assets approved");
332 expect(seen).toContain("1 of 8 assets approved");
316333 expect(seen).toContain("Draft — not approved yet");
317334 expect(seen).toContain(`/homes/${id}`);
318335 });
@@ -435,7 +452,7 @@ test("the seller update is the last asset: it reads as a draft, and a approves i
435452 const { db } = fresh();
436453 const tty = session(db, me);
437454 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");
439456 const seen = plain(tty.screen());
440457 expect(seen).toContain("▸ Seller update");
441458 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
537554 tty.press("E");
538555 expect(plain(tty.screen())).toContain("needs approving first");
539556 tty.press("\x1b");
540 for (let i = 0; i < 7; i++) {
557 for (let i = 0; i < 8; i++) {
541558 tty.press("a");
542559 tty.press("j");
543560 }
src/tty.ts +2 −2
@@ -63,7 +63,7 @@ import { reportText } from "./report.ts";
6363 import { scheduleText, weekText } from "./schedule.ts";
6464 import { now } from "./clock.ts";
6565 import { approvalLabel, propertyLine } from "./listing.ts";
66 import { countLine, isMls, overNote } from "./postlength.ts";
66 import { countLine, isMls, isText, overNote } from "./postlength.ts";
6767 import { emailParts } from "./pages/email.ts";
6868 import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts";
6969 import type { Who } from "./who.ts";
@@ -500,7 +500,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
500500 } else {
501501 const done = approveAsset(db, me, { id: found.value.id, kind: asset.kind, approved: !withdrawing, seen: asset.updated_at, door: "tty", tooLong: long });
502502 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.")
504504 : done.message;
505505 }
506506 }
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
150150 const row = db.query<{ id: number }, []>("SELECT id FROM listing WHERE address LIKE '412 Alder%'").get()!;
151151 const a = read(db, row.id).assets.find((x) => x.kind === "email_update")!;
152152 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);
154154 expect(a.text).toContain(`/homes/${row.id}`);
155155 expect(check(a.text).clear).toBe(true);
156156 });
src/walkthrough.test.ts +14 −14
@@ -123,8 +123,8 @@ test("every step of the section works, with the labels it names", async () => {
123123
124124 // 4. Approve one.
125125 const page = await (await get(mine)).text();
126 expect(page).toContain("0 of 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");
128128 expect(textareaOf(page)).toBe("My own words about 9 Walkthrough Way, written by hand.");
129129 const approveForm = new FormData();
130130 approveForm.set("approved", "1");
@@ -134,7 +134,7 @@ test("every step of the section works, with the labels it names", async () => {
134134 const approved = await post(`${mine}/assets/mls/approve`, approveForm);
135135 expect(approved.status).toBe(303);
136136 const afterApprove = await (await get(mine)).text();
137 expect(afterApprove).toContain("1 of 8 approved");
137 expect(afterApprove).toContain("1 of 9 approved");
138138
139139 // 5. Change the tone: every asset again, approval cleared.
140140 expect(afterApprove).toMatch(control("Rewrite all"));
@@ -142,13 +142,13 @@ test("every step of the section works, with the labels it names", async () => {
142142 tone.set("tone", "bold");
143143 expect((await post(`${mine}/tone`, tone)).status).toBe(303);
144144 const afterTone = await (await get(mine)).text();
145 expect(afterTone).toContain("0 of 8 approved");
145 expect(afterTone).toContain("0 of 9 approved");
146146 // The words were written again: the edit is gone and the text is the bold tone's.
147147 expect(textareaOf(afterTone)).not.toContain("written by hand");
148148 expect(textareaOf(afterTone)).not.toBe(textareaOf(afterApprove));
149149 expect(afterTone).toContain('<option value="bold" selected>');
150150 expect(textareaOf(afterApprove)).toContain("written by hand");
151 expect(section).toContain("0 of 8 approved");
151 expect(section).toContain("0 of 9 approved");
152152
153153 // 6. Put back the MLS words: Earlier versions, Restore, checked again, approval clear.
154154 expect(section).toContain("**Earlier versions (1)**");
@@ -163,7 +163,7 @@ test("every step of the section works, with the labels it names", async () => {
163163 expect((await post(restoreForm[1]!, put)).status).toBe(303);
164164 const afterRestore = await (await get(mine)).text();
165165 expect(textareaOf(afterRestore)).toBe("My own words about 9 Walkthrough Way, written by hand.");
166 expect(afterRestore).toContain("0 of 8 approved");
166 expect(afterRestore).toContain("0 of 9 approved");
167167
168168 // 7. Drop the price: Edit facts, the new price on the flyer, approvals cleared.
169169 expect(section).toContain("**Edit facts**");
@@ -178,7 +178,7 @@ test("every step of the section works, with the labels it names", async () => {
178178 approveAgain.set("seen", /name="seen" value="([^"]+)"/.exec(afterRestore)![1]!.replaceAll("&amp;", "&"));
179179 approveAgain.set("text", textareaOf(afterRestore));
180180 await post(`${mine}/assets/mls/approve`, approveAgain);
181 expect(await (await get(mine)).text()).toContain("1 of 8 approved");
181 expect(await (await get(mine)).text()).toContain("1 of 9 approved");
182182 const drop = new FormData();
183183 for (const k of ["beds", "baths", "sqft", "lot", "year", "tone", "palette",
184184 "agent_name", "brokerage", "license", "agent_phone", "agent_email"] as const)
@@ -189,7 +189,7 @@ test("every step of the section works, with the labels it names", async () => {
189189 expect((await post(`${mine}/facts`, drop)).status).toBe(303);
190190 const afterDrop = await (await get(mine)).text();
191191 expect(afterDrop).toContain("$649,000");
192 expect(afterDrop).toContain("0 of 10 approved");
192 expect(afterDrop).toContain("0 of 11 approved");
193193 expect(await (await get(`${mine}/flyer`)).text()).toContain("$649,000");
194194
195195 // 7. Add an open house: Edit facts, the band on the flyer, the line in the email, approvals cleared.
@@ -207,7 +207,7 @@ test("every step of the section works, with the labels it names", async () => {
207207 approveOpen.set("seen", /name="seen" value="([^"]+)"/.exec(beforeOpen)![1]!.replaceAll("&amp;", "&"));
208208 approveOpen.set("text", textareaOf(beforeOpen));
209209 await post(`${mine}/assets/mls/approve`, approveOpen);
210 expect(await (await get(mine)).text()).toContain("1 of 10 approved");
210 expect(await (await get(mine)).text()).toContain("1 of 11 approved");
211211 const hold = new FormData();
212212 for (const [k, v] of drop.entries()) hold.set(k, v);
213213 hold.set("tone", "bold");
@@ -216,7 +216,7 @@ test("every step of the section works, with the labels it names", async () => {
216216 hold.set("open1_end", "15:00");
217217 expect((await post(`${mine}/facts`, hold)).status).toBe(303);
218218 const afterOpen = await (await get(mine)).text();
219 expect(afterOpen).toContain("0 of 13 approved");
219 expect(afterOpen).toContain("0 of 14 approved");
220220 expect(await (await get(`${mine}/flyer`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
221221 expect(await (await get(`${mine}/email`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
222222 const bad = new FormData();
@@ -231,7 +231,7 @@ test("every step of the section works, with the labels it names", async () => {
231231
232232 // 8. Mark it sold: the status post and email, eight to approve, the band, the list.
233233 expect(section).toContain("**Mark it sold.**");
234 expect(section).toContain("0 of 10 approved");
234 expect(section).toContain("0 of 11 approved");
235235 expect(section).toContain("88 Meridian Street");
236236 expect(afterTone).toContain('name="status"');
237237 expect(afterTone).toContain('<option value="sold">');
@@ -241,20 +241,20 @@ test("every step of the section works, with the labels it names", async () => {
241241 sell.set("sold_price", "640000");
242242 expect((await post(`${mine}/status`, sell)).status).toBe(303);
243243 const afterSold = await (await get(mine)).text();
244 expect(afterSold).toContain("0 of 12 approved");
244 expect(afterSold).toContain("0 of 14 approved");
245245 expect(afterSold).toContain("Sold at $640,000");
246246 expect(afterSold).toContain("Just sold — 9 Walkthrough Way");
247247 expect(await (await get(`${mine}/flyer`)).text()).toContain('<p class="flyer-status status-sold" role="note">Sold at $640,000</p>');
248248 const homeSold = await (await get("/")).text();
249249 expect(homeSold).toContain("Sold at $640,000");
250 expect(homeSold).toContain("0 of 10 approved");
250 expect(homeSold).toContain("0 of 12 approved");
251251 expect(homeSold).toContain("Sold at $752,000");
252252 const meridian = await (await get(`/listings/${idOf(homeSold, "88 Meridian Street")}`)).text();
253253 expect(meridian).toContain("Just sold — 88 Meridian Street");
254254 const active = new FormData();
255255 active.set("status", "active");
256256 expect((await post(`${mine}/status`, active)).status).toBe(303);
257 expect(await (await get(mine)).text()).toContain("0 of 10 approved");
257 expect(await (await get(mine)).text()).toContain("0 of 11 approved");
258258
259259 // 9. Print the flyer.
260260 expect(afterTone).toMatch(control("Print flyer"));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs