# Plan: client advocacy program + shareable folders

**Status:** Not started. Written 2026-09-03.
**Goal:** Turn "ask clients for reviews, testimonials and referrals" from a thing that
never happens into one automatic email + text, sent once per client at the right
moment, pointing at a page that has all three asks pre-written for them.
**Decided:** One send, not a 90-day drip. A menu of three stackable asks, not a
ladder. Everything pre-written. The Google review is never incentivized. The
testimonial upload rides on a general-purpose shareable-folder feature, built first.

---

## Why it is shaped this way

The failure mode is not labour, it is the blank page on both sides. Tristan does not
send these because remembering to is friction; clients do not respond because writing
a review is friction. So the system removes both: it decides when the moment has
arrived, and the page it sends writes the review, the testimonial script and the
referral opener on the client's behalf.

The moment is the first payment clearing — the client has just decided to continue,
which is an implicit endorsement. But the message leads with their month-one numbers,
not with the ask, so it reads as a report rather than an extraction. This is the
whole trick for asking a paying client for a favour without it feeling like one.

Three asks go out together, and the rewards stack, because waiting 60 and 90 days for
the second and third asks means they never get sent.

### The one constraint that is not negotiable

**The Google review carries no reward.** Google's policy prohibits incentivized
reviews and they are removed when detected, which would cost the review and risk the
profile. It sits first on the page as the 60-second free one; reciprocity after a good
month is what carries it. Rewards attach only to the testimonial and the referral,
which are our own assets and ours to pay for.

---

## Grounding (verified in code, 2026-09-03)

**A public token-linked page already exists and works.** `dashboard/server.js:66`
mounts `app.use('/h', require('./routes/schedule-link'))` *above* `app.use(requireAuth)`
at `dashboard/server.js:82`. `dashboard/routes/schedule-link.js` is the working model
for an unauthenticated surface: token lookup through `engine/scripts/lib/db/schedule-links-dal.js`,
`noStore()` setting `Cache-Control: no-store` + `X-Robots-Tag: noindex, nofollow`,
per-IP rate limiting from `SCHEDULE_LINK` in `lib/config.js`, a hashed `tokenTag()` so
raw tokens never reach logs, and a rate-limited unknown-token log. Its page assets live
beside it at `dashboard/server/schedule-link/page.html` + `page.js`.

**The files system already has folders with roles.** `engine/scripts/lib/files-store.js`
`createStore({ rootDir, scope })` where scope is `client:<slug>` or `agency`; folders
are `fld_`-prefixed, files `fl_`, both in SQLite via `engine/scripts/lib/db/folders-dal.js`.
`isProtectedFolder(folder)` is simply `!!folder.role`, and `setFolderRole()` exists —
so a per-client folder that must not be deleted is already a supported concept.
The HTTP surface is `dashboard/routes/files-router.js` (721 lines).

**Large uploads already work.** `dashboard/server/lib/resumable-upload.js` does
multipart straight to R2: `MAX_TOTAL_SIZE` 500 MB, 8 MB parts (5–16 MB range), resumable
across a dropped connection. A phone video fits comfortably. (Note the unrelated
`SCHEDULE_LINK.maxUploadBytes` of 12 MB in `lib/config.js` — that cap is for schedule
images and must not be reused here.)

**Scheduled work does not need cron.** `dashboard/server/sweepers/schedule-sweeper.js`
exports `scheduleSweeper(name, fn, { startupMs, intervalMs })`; four sweepers are
started at `dashboard/server.js:192-195`. A fifth is a normal PR — no crontab, which
matters because the agent cannot edit cron.

**The reminder surface exists.** `dashboard/server/stores/notification-store.js` has
dedup keys, TTL/retention sweeps, and an `ACTIONABLE_TYPES` set that drives the
Approvals & Alerts board. `dashboard/server/whatsapp/wa-notify.js` `sendToAdmin()`
writes an outbox file that the bridge turns into a WhatsApp message.
`dashboard/server/whatsapp/admin-wa-handler.js` implements the `RESOLVE_APPROVAL:`
verb pattern to copy.

**Escalation cadence needs a parameter.** `dashboard/server/lib/reminder-cadence.js`
hardcodes `REMINDER_CADENCE_MS = [1h, 4h]`, shared by the approval and notification
stores. Correct for a gated action, far too aggressive for "send your client an
email". It has to become a per-caller argument (a plain default keeps both existing
callers unchanged).

**Email to clients is a solved path.** Master templates plus per-client overrides in
SQLite via `engine/scripts/lib/db/email-templates-dal.js`, rendered by
`engine/scripts/lib/render-email-template.js`, merged through
`dashboard/server/lib/merge-context.js`, sent by `dashboard/routes/email-templates.js`
through `dashboard/server/google-service-client.js` → `POST /gmail/send` on the
google-service (which holds the only `gmail.send` token). Existing kinds:
`launch-call`, `post-call`, `specialist-assignment`, `website-preview`. Merge keys are
catalogued in `dashboard/public/js/merge-fields.js` and `buildMergeContext` warns on
any catalogue key it cannot resolve — so both sides get extended together.

**The numbers for the recap are reachable.** Meta spend/reach/leads via
`services/meta-ads-service/server.js:160` (`reads.get('/insights')`); opted-in
contacts and booked appointments via the GHL service, which holds
`contacts.readonly` and `opportunities.readonly`
(`services/ghl-service/lib/ghl-oauth.js:351,359`).

**Stripe is read-only and barely linked.** `dashboard/server/stripe-client.js` exposes
only GET helpers and `devBlock`s any non-GET. `dashboard/routes/billing.js` reads
`entry.ghl.stripeCustomerId` — populated on **1 of 49** manifest entries. There is no
Stripe webhook endpoint. Manifest fields that do exist across clients: `launchedAt`
(14), `contactEmail` (41), `coldAt` (14), `deactivatedAt` (3), `pendingDeleteAt` (1).

**A launch event already fires into the agency's own GHL.**
`dashboard/server/launch-webhook.js` posts `client.launched` with `ownerEmail` and
`ownerPhone` through the durable `services/webhook-delivery` queue. That is the pipe
for outbound SMS if we want GHL to deliver rather than building a second sender.

**Reviews are already pulled from Google.** `engine/scripts/lib/reviews-cache.js`
fetches through SerpAPI, but it is slug-bound — `placeIdFor(slug)` reads
`profile.placeId`, and the cache is keyed per client. Pointing it at Veuze Media's own
profile needs the place id lifted into a parameter.

Latest migration is `dashboard/server/migrations/015_recording_transcripts.sql`.

---

## Part 1 — Shareable folders

Build first: it is independently useful, it is the prerequisite for the testimonial
upload, and it is the piece with real security surface, so it should not be rushed
alongside the rest.

Link-level access only, like a Google Drive link. No per-recipient email invites, no
accounts for clients.

1. **Migration `016_share_links.sql`** — `share_links`: `token` (unique, hashed at
   rest; the raw token is shown once at creation), `scope`, `folder_id`, `permission`,
   `created_by`, `created_at`, `expires_at`, `revoked_at`, `last_viewed_at`.

2. **Three permissions.**
   - `view` — list and download.
   - `drop` — upload only; the visitor cannot see what is already in the folder. This
     is the default for testimonials: a client uploading their video has no business
     seeing another client's.
   - `edit` — view plus document editing through the collab service. Last, and only if
     wanted; the first two carry the feature.

3. **Public route `/f/:token`**, mounted above `requireAuth` next to `/h`, with page
   assets under `dashboard/server/share-link/`. Copy the schedule-link posture
   wholesale: `no-store`, `noindex`, per-IP rate limit, hashed token in logs,
   rate-limited unknown-token logging, 404 on revoked/expired.

4. **Upload path** reuses `resumable-upload.js` so large video works, with a MIME
   allowlist, a size cap, and a per-token upload rate limit. Uploads land in the
   target folder and nowhere else — the token resolves to exactly one `fld_`, and the
   folder id never comes from the request body.

5. **Mobile first.** The page is a list plus one large upload control that opens the
   camera roll on a phone. That is the whole UI.

6. **Dashboard side** — "Share" on the folder kebab: pick permission and expiry, copy
   link, see active links, revoke.

7. **Per-client `Testimonials` folder** provisioned at onboarding with a folder `role`
   so it is protected from deletion, alongside the existing folder seeding.

Tests: token resolution and revocation, `drop` cannot list, upload cannot escape its
folder, expired and unknown tokens 404 identically, rate limits hold.

## Part 2 — The offer page and the send

8. **Migration `017_client_asks.sql`** — `client_asks`: one row per (slug, kind) for
   `google-review` / `testimonial` / `referral`, with `status`
   (`due` / `sent` / `claimed` / `declined` / `snoozed` / `suppressed`), `due_at`,
   `sent_at`, `claimed_at`, and the outcome (review detected, uploaded file id,
   referred school). This is what makes the flywheel queryable instead of vibes.

9. **The offer page** — another `/f`-style public token page: month-one numbers at the
   top, then three cards.
   - *Google review* — a review drafted from their actual numbers, a Copy button, and
     the link straight into the Veuze Media GBP review form.
   - *Video testimonial* — four questions on screen, and the `drop` upload control for
     their Testimonials folder. One free week.
   - *Referral* — a pre-written opener ("these guys are good, $485/mo, first month free,
     here's the link") plus their `?ref=<slug>` booking link, with a Copy button. One
     free month per school that sticks, no cap.

   Claiming a card writes `client_asks` — so the page is the tracking, and nobody has
   to mark anything by hand.

10. **The send** — a short email (numbers + one button) and a matching text, both
    pointing at the same link. New email template kind `advocacy-offer` plus the merge
    fields it needs (`OFFER_LINK`, the month-one stats), added to the catalogue in
    `merge-fields.js` and resolved in `buildMergeContext`.

11. **`advocacy-sweeper.js`** — hourly. Finds clients whose first payment has cleared
    and who have no offer sent, applies the suppression rules, and files an actionable
    notification with the draft ready to send. One SMS nudge if nothing is claimed
    after 7 days, then stop; no second nudge.

    Trigger, phased: `launchedAt + 30d` to start, since only one client is linked to
    Stripe. Then a one-off script matching `contactEmail` against Stripe customers to
    backfill `ghl.stripeCustomerId` (reviewed by hand — a wrong match bills the wrong
    story), after which the sweeper polls for the first paid invoice. Polling 49
    customers beats a webhook: no new endpoint, no new signing secret, no `.env` work
    for the admin.

12. **Suppression** — never send to a client who is cold, deactivated, pending-delete,
    behind on an invoice, launched under 30 days, or mid-complaint. This is what makes
    an automatic send safe at all, so it is a tested pure function, not scattered ifs.

13. **`RESOLVE_ASK: <id> sent|skip|snooze <n>d`** in the admin WhatsApp handler, same
    shape as `RESOLVE_APPROVAL:`, so the whole thing can be run from a phone.

## Part 3 — Closing the loops

14. **Auto-close the review ask.** Lift the place id in `reviews-cache.js` out of the
    slug binding so it can watch the Veuze Media profile; when a new review appears the
    sweeper closes the ask and can match the reviewer to the client by name.

15. **Referral attribution and credit.** `?ref=<slug>` on the booking link →
    `referredBy` on the new client's manifest entry, set at Initial Setup. Credit is
    then *derived* — "clients with `referredBy = X` whose first invoice cleared" —
    rather than mirrored into a ledger. When one clears, file an actionable
    notification to apply the free month, with a link to the referrer's Stripe
    customer.

    **No automatic Stripe writes.** Applying a credit is a financial action; the
    system reminds and links, a human clicks. If it is ever automated it goes through
    the gated-action kernel in `lib/service/`, not a bare API call.

    Forgetting to honour a referral is the single fastest way to kill a referral
    programme, which is why the reminder is part of the plan and not an afterthought.

---

## Open questions

- The Veuze Media GBP review link — confirmed the profile exists, need the URL.
- Is `drop` (blind upload) the right default for shared folders generally, or should
  the default be `view`? Chosen `drop` here for testimonials specifically.
- Whether the SMS goes out through the existing agency-GHL pipe
  (`launch-webhook.js`) or a direct sender. GHL avoids building a second sender.

---

## Implementation notes (build pass)

- **No backfill is enforced by an enrolment stamp, not by `launchedAt`.** `launchedAt`
  is a lazily-written marker: `launch-webhook.js` stamps it `= now` on any clients-list
  read for a live client that lacks one, so a year-old client can acquire a
  post-deploy `launchedAt` at any moment. Eligibility therefore requires
  `entry.advocacyEnrolledAt`, written once by `provisionLocal` only for a slug with no
  prior manifest entry (`!isReonboard && !priorEntry`). It is never in
  `REONBOARD_STRIP_KEYS` and is never rewritten, so a reonboarded or reactivated old
  client cannot mint one. `ADVOCACY_EPOCH` remains as a second floor: the stamp and
  `launchedAt` must both be at or after it, and `launchedAt` must not precede the stamp.
- **Sends are claimed before they are sent.** `advocacyDal.claimSend(slug)` is a
  conditional upsert on `claimed_at`; only a rowcount of 1 authorises the Gmail call,
  and `advocacyState` reads a claimed row as `done`. A crash, a `SQLITE_BUSY`, or a
  client-side timeout therefore under-sends rather than sending twice, and a send that
  fails after the claim raises a WhatsApp + notification alert instead of retrying.
- **`advocacy-offer` is an `INTERNAL_KIND`.** That hides it from the per-client Email
  Client composer (`routes/email-templates.js` `listKinds()`) and from
  `snapshotForClient`, so no client gets a frozen copy and no one can send it by hand.
  The agency template library is a different listing — `agency-files.js`
  `emailTemplateFiles()` uses the unfiltered `listMaster()`, and
  `routes/master-emails.js` reads and writes the master directly — so it stays visible
  and editable there, as required.
- **Known limitation:** `ensureTestimonialsFolder` adopts a pre-existing folder literally
  named "Testimonials" (by name, when no folder carries the `testimonials` role) and
  publishes an anonymous drop link into it. A drop share never lists contents, so
  nothing leaks, but a client who already had a "Testimonials" folder will have their
  testimonial uploads land there rather than in a fresh folder.
