Public: anyone can read this; the crew can change it.
Get a 30-second video tour script for a Reel or TikTok to approve, shot by shot from the listing's photos and features #118
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/116-get-a-30-second-video-tour-script-for-a → main 3 commits fork 1 thread · 1 unresolved
24 files
changed +344
−72
README.md +10 −7
| @@ -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 7 approved". | |
| 26 | something to say). The top of the page now says "1 of 8 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 7 approved" again, because you have not read the new words yet. | |
| 30 | "0 of 8 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 7 approved". The photos stay. | |
| 47 | approval is cleared, so it says "0 of 8 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 9 approved", keep versions, are Fair Housing checked, are rewritten | |
| 82 | in "0 of 10 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 9 approved". The other assets and your approvals stay. The | |
| 101 | says "0 of 10 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 |
| @@ -239,7 +239,10 @@ list cannot drift from the app. | ||
| 239 | 239 | 3. **Read every word.** The listing opens at its own address |
| 240 | 240 | (`/listings/<number>`, bookmark it) with the assets in tabs: the **MLS |
| 241 | 241 | description**, **three social posts** (short, medium, carousel |
| 242 | captions), the **flyer** and the **email blast**. Each has an edit box, a | |
| 242 | captions), the **flyer**, the **email blast** and a **30-second video tour | |
| 243 | script** (6–8 numbered shots, each naming the photo to show, the words to | |
| 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 | |
| 243 | 246 | **Copy** button, and the **Fair Housing check** beside it: green when |
| 244 | 247 | clear, amber with the risky words underlined and a one-line reason — |
| 245 | 248 | *"'family-friendly' implies familial status; say 'near parks and |
| @@ -451,7 +454,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 451 | 454 | | ---------------------------------- | -------------------------------------------------------------- | |
| 452 | 455 | | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail | |
| 453 | 456 | | `src/listing.ts` | what a listing is, and the check at the boundary that one arrived | |
| 454 | | `src/generate.ts` | `generate(listing)`: the six assets from the facts (more once it is under contract, sold or lowered in price), in three tones | | |
| 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 | | |
| 455 | 458 | | `src/postlength.ts` | a social post's length by what X, Instagram and Facebook count | |
| 456 | 459 | | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite | |
| 457 | 460 | | `src/photos.ts` | photos as files beside the database, known by their bytes | |
contract/openapi.json +3
| @@ -648,6 +648,7 @@ | ||
| 648 | 648 | "social_price", |
| 649 | 649 | "flyer", |
| 650 | 650 | "email", |
| 651 | "video_script", | |
| 651 | 652 | "email_status", |
| 652 | 653 | "email_price", |
| 653 | 654 | "email_followup", |
| @@ -1539,6 +1540,7 @@ | ||
| 1539 | 1540 | "social_price", |
| 1540 | 1541 | "flyer", |
| 1541 | 1542 | "email", |
| 1543 | "video_script", | |
| 1542 | 1544 | "email_status", |
| 1543 | 1545 | "email_price", |
| 1544 | 1546 | "email_followup", |
| @@ -1892,6 +1894,7 @@ | ||
| 1892 | 1894 | "social_price", |
| 1893 | 1895 | "flyer", |
| 1894 | 1896 | "email", |
| 1897 | "video_script", | |
| 1895 | 1898 | "email_status", |
| 1896 | 1899 | "email_price", |
| 1897 | 1900 | "email_followup", |
src/actions.ts +42
| @@ -445,6 +445,22 @@ export function draftMissingUpdates(db: Database): number[] { | ||
| 445 | 445 | return ids; |
| 446 | 446 | } |
| 447 | 447 | |
| 448 | /** A listing launched before the video tour script existed gets its draft, once, and keeps every other approval; the ids drafted. */ | |
| 449 | export function draftMissingVideoScripts(db: Database): number[] { | |
| 450 | const ids = db | |
| 451 | .query<{ id: number }, []>( | |
| 452 | "SELECT id FROM listing WHERE id NOT IN (SELECT listing_id FROM asset WHERE kind = 'video_script')", | |
| 453 | ) | |
| 454 | .all() | |
| 455 | .map((r) => r.id); | |
| 456 | for (const id of ids) { | |
| 457 | const l = launched(db, id)!; | |
| 458 | const words = generate({ facts: { ...l, back_on_market: false }, agent: agentOf(l), link: propertyPath(l, publicOrigin) }); | |
| 459 | db.query("INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, 'video_script', ?2, 0)").run(id, words.video_script); | |
| 460 | } | |
| 461 | return ids; | |
| 462 | } | |
| 463 | ||
| 448 | 464 | /** How many earlier versions of one asset's words are kept. */ |
| 449 | 465 | export const VERSIONS_KEPT = 5; |
| 450 | 466 | |
| @@ -1206,6 +1222,31 @@ function changeStatus( | ||
| 1206 | 1222 | /** The assets that show the photos: what they show changed, so their approval goes. */ |
| 1207 | 1223 | const PHOTO_ASSETS = ["flyer", "email"] as const; |
| 1208 | 1224 | |
| 1225 | /** | |
| 1226 | * The video script names photos by number and description, so it follows them: a script nobody | |
| 1227 | * edited is written again from the new photos, and loses its approval if its words changed or the photos were reordered or removed. One a | |
| 1228 | * person edited keeps their words, and loses its approval when the photos were reordered or removed. | |
| 1229 | */ | |
| 1230 | function redraftVideoScript(db: Database, listing: Listing, reshown: boolean): void { | |
| 1231 | const row = db | |
| 1232 | .query<{ body: string; edited: number }, [number]>("SELECT body, edited FROM asset WHERE listing_id = ?1 AND kind = 'video_script'") | |
| 1233 | .get(listing.id); | |
| 1234 | if (row === null) return; | |
| 1235 | const clear = "approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL"; | |
| 1236 | if (row.edited === 1) { | |
| 1237 | if (reshown) db.query(`UPDATE asset SET ${clear} WHERE listing_id = ?1 AND kind = 'video_script'`).run(listing.id); | |
| 1238 | return; | |
| 1239 | } | |
| 1240 | const body = generate({ facts: { ...listing, back_on_market: false }, agent: agentOf(listing), link: propertyPath(listing, publicOrigin) }).video_script; | |
| 1241 | if (body === row.body) { | |
| 1242 | if (reshown) db.query(`UPDATE asset SET ${clear} WHERE listing_id = ?1 AND kind = 'video_script'`).run(listing.id); | |
| 1243 | return; | |
| 1244 | } | |
| 1245 | db.query( | |
| 1246 | `UPDATE asset SET body = ?2, ${clear}, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?1 AND kind = 'video_script'`, | |
| 1247 | ).run(listing.id, body); | |
| 1248 | } | |
| 1249 | ||
| 1209 | 1250 | /** |
| 1210 | 1251 | * Add, remove and reorder a launched listing's photos; the first is the hero. |
| 1211 | 1252 | * `order` names the photos that exist now, first to last (any it leaves out |
| @@ -1300,6 +1341,7 @@ function changePhotos( | ||
| 1300 | 1341 | `UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL |
| 1301 | 1342 | WHERE listing_id = ?1 AND kind = ?2`, |
| 1302 | 1343 | ).run(now.id, kind); |
| 1344 | redraftVideoScript(db, { ...now, photos, photo_alt: alt }, reshown); | |
| 1303 | 1345 | if (reshown) redraftUpdate(db, now.id); |
| 1304 | 1346 | for (const f of files) writePhoto(db, f.name, f.bytes); |
| 1305 | 1347 | })(); |
src/backonmarket.test.ts +1 −1
| @@ -86,7 +86,7 @@ test("sold → active, and coming soon → active, take the status words away", | ||
| 86 | 86 | const { db, id } = launch(); |
| 87 | 87 | setStatus(db, me, { id, status: from }); |
| 88 | 88 | setStatus(db, me, { id, status: "active" }); |
| 89 | expect(get(db, id).assets).toHaveLength(7); | |
| 89 | expect(get(db, id).assets).toHaveLength(8); | |
| 90 | 90 | } |
| 91 | 91 | }); |
| 92 | 92 |
src/comingsoon.test.ts +4 −4
| @@ -46,7 +46,7 @@ test("coming soon is offered first, and the teaser says the facts only, in each | ||
| 46 | 46 | const kinds = soon.value.assets.map((a) => a.kind); |
| 47 | 47 | expect(kinds).toContain("social_status"); |
| 48 | 48 | expect(kinds).toContain("email_status"); |
| 49 | expect(soon.value.assets).toHaveLength(9); | |
| 49 | expect(soon.value.assets).toHaveLength(10); | |
| 50 | 50 | for (const a of soon.value.assets.filter((a) => a.kind.endsWith("_status"))) { |
| 51 | 51 | expect(a.text).toMatch(/coming soon/i); |
| 52 | 52 | expect(a.text).toContain("412 Alder Lane"); |
| @@ -66,14 +66,14 @@ test("each is approved on its own, core approvals stay, and Active or another st | ||
| 66 | 66 | const soon = setStatus(db, me, { id, status: "coming_soon" }); |
| 67 | 67 | expect(soon.ok && soon.value.assets.filter((a) => a.approval !== null).map((a) => a.kind)).toEqual(["mls"]); |
| 68 | 68 | const summary = listListings(db, me); |
| 69 | expect(summary.ok && summary.value[0]!.approvals).toHaveLength(9); | |
| 69 | expect(summary.ok && summary.value[0]!.approvals).toHaveLength(10); | |
| 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(7); | |
| 76 | expect(back.ok && back.value.assets).toHaveLength(8); | |
| 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(9); | |
| 114 | expect(((await ok.json()) as { assets: unknown[] }).assets).toHaveLength(10); | |
| 115 | 115 | expect((await patch({ status: "pending" })).status).toBe(422); |
| 116 | 116 | |
| 117 | 117 | const post = (status: string) => { |
src/generate.test.ts +51 −2
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * Generation: all five assets from the facts alone, in each tone, carrying | |
| 2 | * Generation: all the core assets from the facts alone, in each tone, carrying | |
| 3 | 3 | * what advertising rules ask for (the brokerage, the license number) and |
| 4 | 4 | * nothing the Fair Housing check would flag. |
| 5 | 5 | */ |
| @@ -11,6 +11,8 @@ import { | ||
| 11 | 11 | flyerParts, |
| 12 | 12 | generate, |
| 13 | 13 | CORE_KINDS, |
| 14 | SPOKEN_MAX, | |
| 15 | spokenWords, | |
| 14 | 16 | lotOf, |
| 15 | 17 | street, |
| 16 | 18 | TONES, |
| @@ -22,7 +24,7 @@ import { SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE } from "./sample.ts"; | ||
| 22 | 24 | const agent = agentOf(SAMPLE); |
| 23 | 25 | const facts = (tone: Facts["tone"]): Facts => ({ ...SAMPLE, tone }); |
| 24 | 26 | |
| 25 | test("all five assets, every text filled, in every tone", () => { | |
| 27 | test("all the core assets, every text filled, in every tone", () => { | |
| 26 | 28 | for (const tone of TONES) { |
| 27 | 29 | const words = generate({ facts: facts(tone), agent }); |
| 28 | 30 | expect(Object.keys(words).sort()).toEqual([...CORE_KINDS].sort()); |
| @@ -125,3 +127,50 @@ test("singular for one, the article fits, a lot that is not a measurement is lef | ||
| 125 | 127 | } |
| 126 | 128 | expect(factItems({ ...condo, lot: "0.18 acre" }).map((i) => i.label)).toContain("lot"); |
| 127 | 129 | }); |
| 130 | ||
| 131 | const script = (tone: Facts["tone"], extra: Partial<Facts> = {}): string => | |
| 132 | generate({ facts: { ...facts(tone), photos: ["a.jpg", "b.jpg", "c.jpg"], photo_alt: { "a.jpg": "Front porch", "b.jpg": "Kitchen island" }, ...extra }, agent, link: "https://x.test/homes/1" }).video_script; | |
| 133 | ||
| 134 | test("the video script is 6 to 8 shots in every tone, with a hook first and a call to act last", () => { | |
| 135 | for (const tone of TONES) { | |
| 136 | const text = script(tone, { open_houses: [{ date: "2027-03-07", start: "13:00", end: "15:00" }] }); | |
| 137 | const shots = text.split("\n\n"); | |
| 138 | expect(shots.length).toBeGreaterThanOrEqual(6); | |
| 139 | expect(shots.length).toBeLessThanOrEqual(8); | |
| 140 | shots.forEach((s, i) => { | |
| 141 | expect(s).toStartWith(`Shot ${i + 1}/${shots.length} · `); | |
| 142 | expect(s).toMatch(/\nSay: ".+"\nCaption: .+/); | |
| 143 | }); | |
| 144 | expect(shots[0]).toContain("412 Alder Lane"); | |
| 145 | expect(shots.at(-1)).toContain("Property page: https://x.test/homes/1"); | |
| 146 | expect(shots.at(-1)).toContain("Sunday, March 7, 1–3 pm"); | |
| 147 | expect(check(text).clear).toBe(true); | |
| 148 | } | |
| 149 | }); | |
| 150 | ||
| 151 | test("the spoken words fit 30 seconds, even for long features", () => { | |
| 152 | for (const tone of TONES) { | |
| 153 | expect(spokenWords(script(tone))).toBeLessThanOrEqual(SPOKEN_MAX); | |
| 154 | const long = SAMPLE.features.map((f) => `${f} and then a great many more words to say about it, which goes on and on`); | |
| 155 | const text = script(tone, { features: long }); | |
| 156 | expect(spokenWords(text)).toBeLessThanOrEqual(SPOKEN_MAX); | |
| 157 | expect(text.split("\n\n").length).toBeGreaterThanOrEqual(6); | |
| 158 | } | |
| 159 | }); | |
| 160 | ||
| 161 | test("the video script names only photos the listing has, by description where there is one", () => { | |
| 162 | const text = script("classic"); | |
| 163 | const named = [...text.matchAll(/Show photo (\d+)/g)].map((m) => Number(m[1])); | |
| 164 | expect(named.length).toBeGreaterThan(0); | |
| 165 | for (const n of named) expect(n).toBeGreaterThanOrEqual(1), expect(n).toBeLessThanOrEqual(3); | |
| 166 | expect(text).toContain("Show photo 1 (Front porch)"); | |
| 167 | expect(text).toContain("Show photo 2 (Kitchen island)"); | |
| 168 | expect(text).toContain("Show photo 3\n"); | |
| 169 | const none = script("classic", { photos: [] }); | |
| 170 | expect(none).not.toContain("Show photo"); | |
| 171 | expect(none).toContain("Film on location"); | |
| 172 | }); | |
| 173 | ||
| 174 | test("a flagged phrase in the video script is caught", () => { | |
| 175 | expect(check(`${script("warm")}\nSay: "Perfect for young families."`).clear).toBe(false); | |
| 176 | }); |
src/generate.ts +77 −1
| @@ -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"] as const; | |
| 17 | export const CORE_KINDS = ["mls", "social_short", "social_medium", "social_carousel", "flyer", "email", "video_script"] as const; | |
| 18 | 18 | |
| 19 | 19 | export const KINDS = [ |
| 20 | 20 | "mls", |
| @@ -25,6 +25,7 @@ export const KINDS = [ | ||
| 25 | 25 | "social_price", |
| 26 | 26 | "flyer", |
| 27 | 27 | "email", |
| 28 | "video_script", | |
| 28 | 29 | "email_status", |
| 29 | 30 | "email_price", |
| 30 | 31 | "email_followup", |
| @@ -43,6 +44,7 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 43 | 44 | social_status: "Status post", |
| 44 | 45 | flyer: "Flyer copy", |
| 45 | 46 | email: "Email blast", |
| 47 | video_script: "Video tour script", | |
| 46 | 48 | email_status: "Status email", |
| 47 | 49 | social_price: "Price improvement post", |
| 48 | 50 | email_price: "Price improvement email", |
| @@ -114,6 +116,10 @@ export interface Facts { | ||
| 114 | 116 | readonly back_on_market?: boolean; |
| 115 | 117 | /** Whole dollars: the price before it was lowered; absent or null when it has not been. */ |
| 116 | 118 | readonly was_price?: number | null; |
| 119 | /** The listing's photo names, in order: the video script says which to film or show. */ | |
| 120 | readonly photos?: readonly string[]; | |
| 121 | /** What each photo shows, by photo name, where the agent described it. */ | |
| 122 | readonly photo_alt?: Readonly<Record<string, string>>; | |
| 117 | 123 | } |
| 118 | 124 | |
| 119 | 125 | export interface Agent { |
| @@ -370,6 +376,75 @@ function email(f: Facts, a: Agent): string { | ||
| 370 | 376 | return `Subject: ${subject}\n\n${when === null ? "" : `${when}\n\n`}${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet${lotPhrase(f, " on ")}, built in ${f.year}.\n\nHighlights:\n${list}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`; |
| 371 | 377 | } |
| 372 | 378 | |
| 379 | /** The most words the video script has the agent say: about 30 seconds on camera. */ | |
| 380 | export const SPOKEN_MAX = 80; | |
| 381 | ||
| 382 | /** The words said on camera in a video script: what is inside its `Say: "…"` lines. */ | |
| 383 | export const spokenWords = (text: string): number => | |
| 384 | text | |
| 385 | .split("\n") | |
| 386 | .filter((l) => l.startsWith("Say: ")) | |
| 387 | .reduce((n, l) => n + l.slice(5).replace(/["“”]/g, "").split(/\s+/).filter(Boolean).length, 0); | |
| 388 | ||
| 389 | /** A feature as one short phrase to say aloud: its first clause, at most `max` words. */ | |
| 390 | const spoken = (line: string, max: number): string => | |
| 391 | bare(line).split(/,| with | and | featuring /)[0]!.trim().split(" ").slice(0, max).join(" "); | |
| 392 | ||
| 393 | interface Shot { | |
| 394 | readonly say: string; | |
| 395 | readonly caption: string; | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| 399 | * The 30-second video tour: numbered shots, each naming the photo to film or | |
| 400 | * show, the words to say and the caption on screen. A hook opens it and a call | |
| 401 | * to act closes it; the spoken words stay within SPOKEN_MAX. | |
| 402 | */ | |
| 403 | function videoScript(f: Facts, link?: string): string { | |
| 404 | const where = street(f.address); | |
| 405 | const open = openHouseLine(f); | |
| 406 | const hook: Shot = { | |
| 407 | say: { | |
| 408 | classic: `Welcome to ${where}, offered at ${money(f.price)}.`, | |
| 409 | warm: `Come inside ${where} with me.`, | |
| 410 | bold: `${where}. ${money(f.price)}. Wait until you see inside.`, | |
| 411 | }[f.tone], | |
| 412 | caption: `Just listed: ${where}`, | |
| 413 | }; | |
| 414 | const facts: Shot = { | |
| 415 | say: `${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet, built in ${f.year}.`, | |
| 416 | caption: factsLine(f), | |
| 417 | }; | |
| 418 | const act: Shot = { | |
| 419 | say: `${{ | |
| 420 | classic: "Message me to book a private showing.", | |
| 421 | warm: "Message me and I will show you around.", | |
| 422 | bold: "Book your showing today.", | |
| 423 | }[f.tone]}${open === null ? "" : ` ${open}`}`, | |
| 424 | caption: `${link === undefined ? "Link in bio" : `Property page: ${link}`}${open === null ? "" : ` · ${open}`}`, | |
| 425 | }; | |
| 426 | const build = (features: number, words: number): Shot[] => [ | |
| 427 | hook, | |
| 428 | facts, | |
| 429 | ...f.features.slice(0, features).map((line) => ({ say: `${sentence(spoken(line, words))}`, caption: bare(line) })), | |
| 430 | act, | |
| 431 | ]; | |
| 432 | let shots = build(4, 7); | |
| 433 | for (const [n, w] of [[4, 5], [3, 5], [3, 3], [2, 3]] as const) { | |
| 434 | if (shots.length >= 6 && shots.reduce((t, s) => t + spokenWords(`Say: ${s.say}`), 0) <= SPOKEN_MAX) break; | |
| 435 | shots = build(n, w); | |
| 436 | } | |
| 437 | const photos = f.photos ?? []; | |
| 438 | return shots | |
| 439 | .map((s, i) => { | |
| 440 | const name = photos.length === 0 ? undefined : photos[i % photos.length]!; | |
| 441 | const alt = name === undefined ? undefined : f.photo_alt?.[name]; | |
| 442 | const film = name === undefined ? "Film on location" : `Show photo ${(i % photos.length) + 1}${alt === undefined || alt === "" ? "" : ` (${alt})`}`; | |
| 443 | return `Shot ${i + 1}/${shots.length} · ${film}\nSay: "${s.say}"\nCaption: ${s.caption}`; | |
| 444 | }) | |
| 445 | .join("\n\n"); | |
| 446 | } | |
| 447 | ||
| 373 | 448 | /** What a teaser says of the home: the price, then beds · baths · square feet. Never a date. */ |
| 374 | 449 | const teaserFacts = (f: Facts): string => |
| 375 | 450 | `${money(f.price)} · ${plural(f.beds, "bedroom")} · ${plural(f.baths, "bath")} · ${count(f.sqft)} square feet`; |
| @@ -605,6 +680,7 @@ export function generate(listing: { | ||
| 605 | 680 | social_carousel: socialCarousel(f, a), |
| 606 | 681 | flyer: flyer(f), |
| 607 | 682 | email: email(f, a), |
| 683 | video_script: videoScript(f, link), | |
| 608 | 684 | ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}), |
| 609 | 685 | ...(cut ? { social_price: pricePost(f, a), email_price: priceEmail(f, a) } : {}), |
| 610 | 686 | ...(openHouseLine(f) === null ? {} : { email_followup: followUpEmail(f, a) }), |
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 7 approved"); | |
| 447 | expect(page).toContain("0 of 8 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 7 approved"); | |
| 465 | expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("0 of 8 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 7 approved"); | |
| 470 | expect(page).toContain("1 of 8 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 7 approved"); | |
| 493 | for (const kind of ["social_short", "social_medium", "social_carousel", "flyer", "email"]) | |
| 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"]) | |
| 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 7 approved"); | |
| 541 | expect(await home()).toContain("0 of 8 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 7 approved"); | |
| 556 | expect(await home()).toContain("2 of 8 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(7); | |
| 559 | expect(summary.approvals).toHaveLength(8); | |
| 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(9); | |
| 153 | expect(l.assets.length).toBe(10); | |
| 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 +3 −3
| @@ -94,7 +94,7 @@ test("adding, changing and clearing it writes the assets again and clears approv | ||
| 94 | 94 | return again.ok ? again.value.assets.filter((a) => a.approval !== null).length : -1; |
| 95 | 95 | }; |
| 96 | 96 | expect(text("flyer")).not.toContain("Open "); |
| 97 | expect(approve()).toBe(6); | |
| 97 | expect(approve()).toBe(7); | |
| 98 | 98 | |
| 99 | 99 | const set = updateFacts(db, me, { id, fields: { open_houses: [sunday] } }); |
| 100 | 100 | expect(set).toMatchObject({ ok: true, value: { open_houses: [sunday] } }); |
| @@ -103,7 +103,7 @@ test("adding, changing and clearing it writes the assets again and clears approv | ||
| 103 | 103 | if (set.ok) expect(set.value.assets.every((a) => a.approval === null)).toBe(true); |
| 104 | 104 | |
| 105 | 105 | // A change to something else keeps it. |
| 106 | expect(approve()).toBe(9); | |
| 106 | expect(approve()).toBe(10); | |
| 107 | 107 | expect(updateFacts(db, me, { id, fields: { price: 650000 } })).toMatchObject({ ok: true, value: { open_houses: [sunday] } }); |
| 108 | 108 | |
| 109 | 109 | const moved = updateFacts(db, me, { id, fields: { open_houses: [{ ...sunday, date: "2027-03-14" }] } }); |
| @@ -111,7 +111,7 @@ test("adding, changing and clearing it writes the assets again and clears approv | ||
| 111 | 111 | expect(text("mls")).toContain("Sunday, March 14, 1–3 pm"); |
| 112 | 112 | expect(text("mls")).not.toContain("March 7"); |
| 113 | 113 | |
| 114 | expect(approve()).toBe(11); | |
| 114 | expect(approve()).toBe(12); | |
| 115 | 115 | const cleared = updateFacts(db, me, { id, fields: { open_houses: [] } }); |
| 116 | 116 | if (!cleared.ok) throw new Error(cleared.message); |
| 117 | 117 | expect(cleared.value.open_houses).toEqual([]); |
src/pages/listing.ts +1
| @@ -229,6 +229,7 @@ const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = | ||
| 229 | 229 | { id: "mls", label: "MLS", kinds: ["mls"] }, |
| 230 | 230 | { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price", "social_reminder"] }, |
| 231 | 231 | { id: "flyer", label: "Flyer", kinds: ["flyer"] }, |
| 232 | { id: "video", label: "Video", kinds: ["video_script"] }, | |
| 232 | 233 | { id: "email", label: "Email", kinds: ["email", "email_status", "email_price", "email_followup", "email_reminder"] }, |
| 233 | 234 | { id: "update", label: "Seller update", kinds: ["email_update"] }, |
| 234 | 235 | ]; |
src/photoalt.test.ts +3 −3
| @@ -89,7 +89,7 @@ test("a flagged description shows its finding beside the box", () => { | ||
| 89 | 89 | expect(factsPage({ who: pat, listing: load() })).not.toContain("Phrases to review in this description"); |
| 90 | 90 | }); |
| 91 | 91 | |
| 92 | test("a description clears no approval; a new order does", () => { | |
| 92 | test("a description clears only the video script's approval, which names it; a new order clears the flyer and the email too", () => { | |
| 93 | 93 | const approve = () => |
| 94 | 94 | db.query("UPDATE asset SET approved_by_id = 'x', approved_by_name = 'x', approved_at = 'now', approved_via = 'x' WHERE listing_id = ?1").run(id); |
| 95 | 95 | const approved = () => |
| @@ -97,9 +97,9 @@ test("a description clears no approval; a new order does", () => { | ||
| 97 | 97 | approve(); |
| 98 | 98 | const all = approved(); |
| 99 | 99 | updatePhotos(db, pat, { id, alt: { "sample-garden.svg": "Garden at dusk" } }); |
| 100 | expect(approved()).toBe(all); | |
| 100 | expect(approved()).toBe(all - 1); | |
| 101 | 101 | updatePhotos(db, pat, { id, order: ["sample-garden.svg"] }); |
| 102 | expect(approved()).toBe(all - 2); | |
| 102 | expect(approved()).toBe(all - 3); | |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | 105 | test("removing a photo removes its description; empty text takes one away", () => { |
src/postlength.test.ts +5
| @@ -71,3 +71,8 @@ test("the three sample listings' MLS descriptions stay under the limit in every | ||
| 71 | 71 | expect([sample.address, tone, overMls("mls", mls)]).toEqual([sample.address, tone, 0]); |
| 72 | 72 | } |
| 73 | 73 | }); |
| 74 | ||
| 75 | test("the video script's count is its spoken words out of 80", async () => { | |
| 76 | const { countLine } = await import("./postlength.ts"); | |
| 77 | expect(countLine("video_script", 'Shot 1/6 · Film on location\nSay: "One two three."\nCaption: ignored words here')).toBe("3 / 80 spoken words"); | |
| 78 | }); |
src/postlength.ts +3
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | * weights (wide CJK characters, for one) are not modelled. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { SPOKEN_MAX, spokenWords } from "./generate.ts"; | |
| 9 | ||
| 8 | 10 | export const LIMITS = { x: 280, instagram: 2200, facebook: 63206 } as const; |
| 9 | 11 | /** The common MLS public-remarks cap: what an MLS keeps of the description, counted in code points. */ |
| 10 | 12 | export const MLS_LIMIT = 1000; |
| @@ -108,5 +110,6 @@ export const overOutcome = (kind: string, text: string): string => | ||
| 108 | 110 | export function countLine(kind: string, text: string): string | null { |
| 109 | 111 | if (isPost(kind)) return lengthLine(text); |
| 110 | 112 | if (isMls(kind)) return `${points(text).toLocaleString("en-US")} / ${MLS_LIMIT.toLocaleString("en-US")} characters`; |
| 113 | if (kind === "video_script") return `${spokenWords(text)} / ${SPOKEN_MAX} spoken words`; | |
| 111 | 114 | return null; |
| 112 | 115 | } |
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 9 approved"); | |
| 127 | expect(plain(tty.screen())).toContain("0 of 10 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(10); | |
| 75 | expect(kit(l)).toContain("0 of 10 assets approved"); | |
| 74 | expect(l.assets.length).toBe(11); | |
| 75 | expect(kit(l)).toContain("0 of 11 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(9); | |
| 31 | expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(9); | |
| 30 | expect(rows(html)).toBe(10); | |
| 31 | expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(10); | |
| 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 +17 −2
| @@ -58,11 +58,11 @@ const approvedOf = (db: ReturnType<typeof fresh>, like: string) => { | ||
| 58 | 58 | return r.value.assets.filter((a) => a.approval !== null); |
| 59 | 59 | }; |
| 60 | 60 | |
| 61 | test("Alder Lane opens with all ten assets approved by its sample agent; Birchwood has none", () => { | |
| 61 | test("Alder Lane opens with all eleven assets approved by its sample agent; Birchwood has none", () => { | |
| 62 | 62 | const db = fresh(); |
| 63 | 63 | seed(db); |
| 64 | 64 | const alder = approvedOf(db, "412 Alder%"); |
| 65 | expect(alder).toHaveLength(10); | |
| 65 | expect(alder).toHaveLength(11); | |
| 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); |
| @@ -103,3 +103,18 @@ test("a copy started from the sample starts at 0 approved", () => { | ||
| 103 | 103 | expect(made.ok).toBe(true); |
| 104 | 104 | expect(made.ok && made.value.assets.filter((a) => a.approval !== null)).toHaveLength(0); |
| 105 | 105 | }); |
| 106 | ||
| 107 | test("listings that predate the video script get it on boot, and keep every other approval", () => { | |
| 108 | const db = fresh(); | |
| 109 | seed(db); | |
| 110 | const approved = () => (db.query("SELECT count(*) AS n FROM asset WHERE approved_at IS NOT NULL AND kind != 'video_script'").get() as { n: number }).n; | |
| 111 | const before = approved(); | |
| 112 | db.run("DELETE FROM asset WHERE kind = 'video_script'"); | |
| 113 | expect((db.query("SELECT count(*) AS n FROM asset WHERE kind = 'video_script'").get() as { n: number }).n).toBe(0); | |
| 114 | seed(db); | |
| 115 | const rows = db.query("SELECT a.approved_via AS via, l.address AS address FROM asset a JOIN listing l ON l.id = a.listing_id WHERE a.kind = 'video_script'").all() as { via: string | null; address: string }[]; | |
| 116 | expect(rows).toHaveLength(3); | |
| 117 | expect(rows.find((r) => r.address.startsWith("412 Alder"))!.via).toBe("sample"); | |
| 118 | expect(rows.filter((r) => r.via === null)).toHaveLength(2); | |
| 119 | expect(approved()).toBe(before); | |
| 120 | }); |
src/sample.ts +15 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | import { draftMissingUpdates, insertListing, markStatus, redraftUpdate } from "./actions.ts"; | |
| 9 | import { 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 | |
| @@ -183,6 +183,20 @@ export function seed(db: Database): void { | ||
| 183 | 183 | // Alder Lane opens finished: every asset signed off by its sample agent, never by a person's door. |
| 184 | 184 | if (s.name === "sample") signOff(db, made.id, `${s.listing.agent_name} (sample)`); |
| 185 | 185 | } |
| 186 | // A sample planted before the video script existed gets it, signed like the rest it was planted with. | |
| 187 | for (const id of draftMissingVideoScripts(db)) { | |
| 188 | const row = db | |
| 189 | .query<{ name: string | null; via: string | null }, [number]>( | |
| 190 | "SELECT approved_by_name AS name, approved_via AS via FROM asset WHERE listing_id = ?1 AND kind = 'mls'", | |
| 191 | ) | |
| 192 | .get(id); | |
| 193 | if (row?.via === "sample" && row.name !== null) { | |
| 194 | db.query( | |
| 195 | "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 = 'video_script'", | |
| 196 | ).run(id, row.name); | |
| 197 | redraftUpdate(db, id); | |
| 198 | } | |
| 199 | } | |
| 186 | 200 | // The sample planted before the seller update existed gets its draft, signed like the rest it reports. |
| 187 | 201 | for (const id of draftMissingUpdates(db)) { |
| 188 | 202 | const row = db |
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(7); | |
| 80 | expect(get(db, id).assets).toHaveLength(8); | |
| 81 | 81 | const mls = get(db, id).assets.find((a) => a.kind === "mls")!; |
| 82 | 82 | expect(editAsset(db, me, { id, kind: "mls", text: "My own words about the house." }).ok).toBe(true); |
| 83 | 83 | const fresh = get(db, id).assets.find((a) => a.kind === "mls")!; |
| @@ -87,7 +87,7 @@ test("setting the status adds the two assets, each needing its own approval, and | ||
| 87 | 87 | const sold = setStatus(db, me, { id, status: "sold", sold_price: "752000" }); |
| 88 | 88 | if (!sold.ok) throw new Error(sold.message); |
| 89 | 89 | expect(sold.value).toMatchObject({ status: "sold", sold_price: 752000 }); |
| 90 | expect(sold.value.assets.map((a) => a.kind)).toEqual(["mls", "social_short", "social_medium", "social_carousel", "social_status", "flyer", "email", "email_status", "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", "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(9); | |
| 103 | expect(summary.ok && summary.value[0]!.approvals).toHaveLength(10); | |
| 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(7); | |
| 136 | expect(away.ok && away.value.assets).toHaveLength(8); | |
| 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 9 approved"); | |
| 171 | expect(page).toContain("0 of 10 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(9); | |
| 183 | expect(sample[0]!.approvals).toHaveLength(10); | |
| 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(9); | |
| 193 | expect(get(seeded, meridian).assets).toHaveLength(10); | |
| 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(9); | |
| 244 | expect(body.assets).toHaveLength(10); | |
| 245 | 245 | expect((await patch({ status: "pending" })).status).toBe(422); |
| 246 | 246 | expect((await patch({ status: "active" }, { ...mint("someone-else"), "content-type": "application/json" })).status).toBe(403); |
| 247 | 247 |
src/tty.test.ts +11 −11
| @@ -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 7 approved"); | |
| 116 | expect(plain(tty.screen())).toContain("0 of 8 approved"); | |
| 117 | 117 | tty.press("a"); |
| 118 | 118 | expect(plain(tty.screen())).toContain("Approved."); |
| 119 | expect(plain(tty.screen())).toContain("1 of 7 approved"); | |
| 119 | expect(plain(tty.screen())).toContain("1 of 8 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 7 approved"); | |
| 128 | expect(plain(theirs.screen())).toContain("0 of 8 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 7 approved"); | |
| 149 | expect(plain(tty.screen())).toContain("0 of 8 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 < 7; i++) { | |
| 154 | for (let i = 0; i < 8; 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 7 approved"); | |
| 214 | expect(plain(tty.screen())).toContain("0 of 8 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 7 approved"); | |
| 217 | expect(plain(tty.screen())).toContain("1 of 8 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,10 +237,10 @@ 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 7 approved"); | |
| 240 | expect(plain(tty.screen())).toContain("0 of 8 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 7 approved"); | |
| 243 | expect(plain(tty.screen())).toContain("1 of 8 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 | }); |
| @@ -312,7 +312,7 @@ test("R shows the seller report: a draft is marked and never worded, an approved | ||
| 312 | 312 | seen = plain(tty.screen()); |
| 313 | 313 | expect(seen).toContain("Approved by me on"); |
| 314 | 314 | expect(seen).toContain(words); |
| 315 | expect(seen).toContain("1 of 6 assets approved"); | |
| 315 | expect(seen).toContain("1 of 7 assets approved"); | |
| 316 | 316 | expect(seen).toContain("Draft — not approved yet"); |
| 317 | 317 | expect(seen).toContain(`/homes/${id}`); |
| 318 | 318 | }); |
| @@ -435,7 +435,7 @@ test("the seller update is the last asset: it reads as a draft, and a approves i | ||
| 435 | 435 | const { db } = fresh(); |
| 436 | 436 | const tty = session(db, me); |
| 437 | 437 | tty.press("\r"); |
| 438 | for (let i = 0; i < 6; i++) tty.press("j"); | |
| 438 | for (let i = 0; i < 7; i++) tty.press("j"); | |
| 439 | 439 | const seen = plain(tty.screen()); |
| 440 | 440 | expect(seen).toContain("▸ Seller update"); |
| 441 | 441 | expect(seen).toContain("Subject: Your weekly update — 412 Alder Lane"); |
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(9); | |
| 153 | expect(a.text.match(/Approved by Morgan Reyes \(sample\) on /g)).toHaveLength(10); | |
| 154 | 154 | expect(a.text).toContain(`/homes/${row.id}`); |
| 155 | 155 | expect(check(a.text).clear).toBe(true); |
| 156 | 156 | }); |
src/videophotos.test.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** The video script follows the photos it names: removing, reordering or describing one writes it again. */ | |
| 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, getListing, insertListing, updatePhotos } from "./actions.ts"; | |
| 7 | import { openDb } from "./db.ts"; | |
| 8 | import { SAMPLE } from "./sample.ts"; | |
| 9 | import type { Who } from "./who.ts"; | |
| 10 | ||
| 11 | const pat: Who = { role: "user", id: "pat", name: "Pat" }; | |
| 12 | const setup = () => { | |
| 13 | const db = openDb(mkdtempSync(join(tmpdir(), "videophotos-"))); | |
| 14 | const { id } = insertListing(db, pat, SAMPLE, ["sample-front.svg", "sample-garden.svg", "sample-condo-front.svg"], null); | |
| 15 | const script = () => { | |
| 16 | const r = getListing(db, pat, { id }); | |
| 17 | if (!r.ok) throw new Error(r.message); | |
| 18 | return r.value.assets.find((a) => a.kind === "video_script")!; | |
| 19 | }; | |
| 20 | const approve = () => { | |
| 21 | const a = script(); | |
| 22 | const done = approveAsset(db, pat, { id, kind: "video_script", door: "page", seen: a.updated_at }); | |
| 23 | expect(done.ok).toBe(true); | |
| 24 | }; | |
| 25 | return { db, id, script, approve }; | |
| 26 | }; | |
| 27 | const shown = (text: string) => [...text.matchAll(/Show photo (\d+)/g)].map((m) => Number(m[1])); | |
| 28 | ||
| 29 | test("removing a photo writes the script again, naming only photos left, and clears its approval", () => { | |
| 30 | const { db, id, script, approve } = setup(); | |
| 31 | expect(Math.max(...shown(script().text))).toBe(3); | |
| 32 | approve(); | |
| 33 | expect(script().approval).not.toBeNull(); | |
| 34 | expect(updatePhotos(db, pat, { id, remove: ["sample-condo-front.svg"] }).ok).toBe(true); | |
| 35 | expect(Math.max(...shown(script().text))).toBe(2); | |
| 36 | expect(script().approval).toBeNull(); | |
| 37 | }); | |
| 38 | ||
| 39 | test("reordering clears the approval, and describing a photo shows in the script", () => { | |
| 40 | const { db, id, script, approve } = setup(); | |
| 41 | approve(); | |
| 42 | expect(updatePhotos(db, pat, { id, order: ["sample-garden.svg", "sample-front.svg", "sample-condo-front.svg"] }).ok).toBe(true); | |
| 43 | expect(script().approval).toBeNull(); | |
| 44 | approve(); | |
| 45 | expect(updatePhotos(db, pat, { id, alt: { "sample-garden.svg": "Back garden" } }).ok).toBe(true); | |
| 46 | expect(script().text).toContain("Show photo 1 (Back garden)"); | |
| 47 | expect(script().approval).toBeNull(); | |
| 48 | }); | |
| 49 | ||
| 50 | test("an edited script keeps the agent's words but loses its approval when photos are removed", async () => { | |
| 51 | const { db, id, script } = setup(); | |
| 52 | const { editAsset } = await import("./actions.ts"); | |
| 53 | const edit = editAsset(db, pat, { id, kind: "video_script", text: 'Shot 1/1 · Show photo 3\nSay: "Mine."\nCaption: Mine' }); | |
| 54 | expect(edit.ok).toBe(true); | |
| 55 | const a = script(); | |
| 56 | approveAsset(db, pat, { id, kind: "video_script", door: "page", seen: a.updated_at }); | |
| 57 | expect(script().approval).not.toBeNull(); | |
| 58 | updatePhotos(db, pat, { id, remove: ["sample-condo-front.svg"] }); | |
| 59 | expect(script().text).toContain('Say: "Mine."'); | |
| 60 | expect(script().approval).toBeNull(); | |
| 61 | }); |
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 7 approved"); | |
| 127 | expect(section).toContain("1 of 7 approved"); | |
| 126 | expect(page).toContain("0 of 8 approved"); | |
| 127 | expect(section).toContain("1 of 8 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 7 approved"); | |
| 137 | expect(afterApprove).toContain("1 of 8 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 7 approved"); | |
| 145 | expect(afterTone).toContain("0 of 8 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 7 approved"); | |
| 151 | expect(section).toContain("0 of 8 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 7 approved"); | |
| 166 | expect(afterRestore).toContain("0 of 8 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 7 approved"); | |
| 181 | expect(await (await get(mine)).text()).toContain("1 of 8 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 9 approved"); | |
| 192 | expect(afterDrop).toContain("0 of 10 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 9 approved"); | |
| 210 | expect(await (await get(mine)).text()).toContain("1 of 10 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 12 approved"); | |
| 219 | expect(afterOpen).toContain("0 of 13 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 9 approved"); | |
| 234 | expect(section).toContain("0 of 10 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 11 approved"); | |
| 244 | expect(afterSold).toContain("0 of 12 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 9 approved"); | |
| 250 | expect(homeSold).toContain("0 of 10 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 9 approved"); | |
| 257 | expect(await (await get(mine)).text()).toContain("0 of 10 approved"); | |
| 258 | 258 | |
| 259 | 259 | // 9. Print the flyer. |
| 260 | 260 | expect(afterTone).toMatch(control("Print flyer")); |
Related: the photo-edit path (PHOTO_ASSETS ~line 1207) never touches video_script. Redraft it there and clear its approval when photos or their order change.