Appearance
Share flows (ENG-339)
Two distinct share user stories, shipped together on top of the ENG-322 session substrate. Source: ENG-339 (follow-on to ENG-322). Linear: ENG-339.
Why two flavors, not one
A single "share" button conflates two different intents:
- Flavor A — Save & share my plans: full plan details with personal pricing context. The recipient (a partner — or the sharer later) sees exactly what the sharer saw, frozen.
- Flavor B — Tell a friend about AskFlorence: marketing share, no personal data. The recipient lands on AskFlorence to find their own subsidized price.
Two buttons, two flows = clean UX.
Gating
Both buttons render in a page-level <ShareButtons enabled=…> row in the /plans + plan-detail context strips. enabled is the server-read SESSION_FLOW_ENABLED, prop-drilled by the /plans + /plans/p/* server pages (those surfaces use the server prop, not the calculator's SessionFlagAutoProvider context — that distinction matters; wiring it to the context would have made it permanently false there). Flavor A snapshots the server session, so the whole row is hidden when the session flow is off (legacy/staging — no af_session cookie, nothing to snapshot) and on saved-share views (you're viewing someone's snapshot, not creating one). No new flag — reuses ENG-322's SESSION_FLOW_ENABLED.
Flavor A — Save & share (backend)
POST /api/share/create (guarded, write-class cap 20 — doubles as ENG-321 anti-abuse on share creation):
- Resolves the session id (cookie or
Authorization: Bearer— native ready) and loads it server-side (household / aptc / csr / coverage /planIdMapare server truth). - The client sends the full
PlanDisplay[]it rendered — the ENG-322 session deliberately stores only plan{id}stubs, so the snapshot takes the full list from the client (same "client computes, server persists" contract as ENG-322; the pricing pipeline is never touched). - Writes a
marketing_sharesrow: a frozen snapshot + 30-day TTL. Returns{ shareUrl: "/plans/saved/<shareId>", expiresAt }.
marketing_shares schema
_id: "share_<hex>"
createdAt, expiresAt (TTL index, +30 days — Mongo reaps, no cron)
snapshot: {
zip, countyFips, countyName, state, household, people,
aptc, csr, isMedicaid,
selectedDrugs[], selectedDoctors[], // server truth (session)
plans: PlanDisplay[], // client-supplied (faithful)
planIdMap: { opaque -> real }, // copied from session
}
label: string | null
createdFromSession: "af_sess_<id>" // abuse tracing
visitorId: "af_v_<id>"No new infra/secret — app_write already has DB-wide readWrite (ADR 0006); the TTL index is created by the existing deploy-time ensure-indexes ECS RunTask (flows through the ENG-325 bundle).
Saved-view pages
/plans/saved/<shareId>— server fetches the share (404-card with a "Find your price →" CTA if missing/expired), renders read-only from the snapshot viaSavedShareView(reusesMarketplacePlanCard; no fetch, no filters/sort, no recompute — exactly what the sharer saw). Banner: Saved search · created … · expires … · Start your own search →.noindex./plans/saved/<shareId>/p/<opaqueId>— resolves the opaque token against the frozensnapshot.planIdMap, finds the plan insnapshot.plans, rendersPlanDetailwithpresetPlan(skips the fetch entirely — faithful) andsavedShareId(back-link returns to the saved list). Unresolvable / expired → back to the saved list.
Opaque tokens are still per-snapshot (copied from the session that minted them) — they do not replay across users or shares.
Flavor B — Tell a friend (pure frontend)
No backend state. TellFriendModal has an editable pre-filled message and:
- Mobile: Web Share API (
navigator.share) → native sheet. - Desktop: clipboard copy +
mailto:/sms:intents. - Link is the bare
https://askflorence.health(no?refin v1). The future referral program layers?ref=user-<visitorId>on later (ENG-322 parking lot) — backward-compatible.
Analytics
trackEvent("share_created", { flavor: "save_share" | "tell_a_friend" }) fires from both flows via src/lib/analytics.ts — a deliberate no-op until the self-hosted first-party stack lands (OpenPanel + GlitchTip, ADR 0009 / ENG-347); that file is the single server-side callsite where the OpenPanel ingest plugs in. Keeping the callsites live now = zero retro-fit later (the server-side spine is tool-portable by design).
Rollback
Pure additive. The collection + TTL index are inert until a share is created; the buttons are enabled-gated off the existing SESSION_FLOW_ENABLED. Revert the PR (or flip the session flag off and the row disappears). No pipeline / no infra / no secret change.