Skip to content

Access Offers — Provisioning (Shop + Campaign)

Status: Built end-to-end (committed on staging, not pushed) as F14. Commerce & Access Offers — both triggers + the refund lifecycle. The provisioning slice that both the shop-commerce connectivity slice and marketing campaigns converge on — "one provisioning core, two triggers."

Scope. Define the shared access-offers core + the provisioner that turns a (patient, offer) into platform access, plus the two front-door flows that drive it: the shop post-checkout onboarding (paid trigger, consuming commerce.order_paid) and the marketing campaign claim (free trigger). It stops where access is minted — i.e. when a patient_content_grants row or a patient_subscriptions row exists. Playback gating is unchanged: the existing play gate (sessions/playgate.go, access/access.go) already honors grants + subscriptions, so nothing new is built at the access-check layer.

The convergence

  FREE trigger                                              PAID trigger
  ────────────                                              ────────────
  campaign page (public)                       commerce.order_paid (shop slice)
  → pick offers                                 → match line-item SKUs → offers
  → Clerk signup + consents (portalonboarding)  → redirect link → onboarding page
  → POST /me/offers/{id}/claim                     → Clerk signup + consents
        │                                            → POST /me/orders/{ref}/fulfill
        │                                                   │
        └──────────────┬─────────────────────────────────────┘

         ┌─────────────────────────────┐
         │  ProvisionOffer(patient,     │   ← the shared core. Trigger-agnostic.
         │   offer, trigger, source)    │     Idempotent via access_offer_fulfillments.
         └─────────────────────────────┘

         ┌─────────────┴──────────────┐
         ▼                            ▼
  patient_content_grants      patient_subscriptions     ← existing primitives. No new
  (000034)                    (000007, SetSubscription)    access-check path.


              play gate (already honors both)

Locked decisions (carried from the design conversation)

  1. One core, two bindings, one provisioner. An access_offers row (target content/tier + terms) is the convergence point. A campaign binding and a shop-SKU binding both point at it; the provisioner is trigger-agnostic.
  2. The provisioner mints existing primitivespatient_content_grants (per-content) and patient_subscriptions (tier). It adds no new access-check path; the play gate already consults both.
  3. The confidential content link never egresses. The public campaign face (access_offer_campaigns) carries only clinic-authored presentation; offer_id / content_id stay org_internal and are read server-side only. (Resolves the earlier marketing data-classification concern.)
  4. Buyer-present provisioning. Even for a paid order, the buyer must complete Clerk signup + GDPR consents (via portalonboarding) before access is minted. The order key is ownership proof (they hold the order URL); the billing email is a profile-prefill hint only, never auth and never an auto-link to a stranger's existing account.
  5. Idempotent via a fulfillment ledger. access_offer_fulfillments keyed by (org, offer, patient, trigger_ref) makes re-claims / webhook replays / double-clicks no-ops, and is the forensic record (grants have no unique constraint of their own — see 000034).
  6. Content-only. Programs / sessions / bundles + tier subscriptions. The "buy an appointment" path stays deferred until a booking domain exists.

Data model (new, all per-org RLS-scoped)

  • access_offersid, organization_id, name (internal), status (draft|active|archived), created_by_principal_id, timestamps. Permission offers.manage.
  • access_offer_items — the grant set (a bundle = one offer, N items): offer_id, organization_id, grant_kind (content_grant | tier_subscription), content_type (program|session) + content_id or tier_id (FK patient_tiers), term_kind (duration | lifetime), duration_interval. CHECK: exactly one target per grant_kind.
  • access_offer_campaigns (public binding) — id, organization_id, offer_id, headline, blurb, image_url, published, published_at, sort_order, featured. Public SELECT when published (AdminPool read path); authored via offers.manage. Classification: headline/blurb/image_url → public-egress target; offer_idorg_internal (never egressed).
  • access_offer_sku_bindings (shop binding) — id, organization_id, integration_id (FK organization_integrations — the connected shop), external_product_ref, external_variation_ref, offer_id. UNIQUE (organization_id, integration_id, external_product_ref, external_variation_ref).
  • access_offer_fulfillments (ledger / idempotency / audit) — id, organization_id, patient_id, offer_id, trigger_type (campaign | shop_order), trigger_ref, source (promo | purchase), provisioned_at, result (grant/subscription ids). UNIQUE (organization_id, offer_id, patient_id, trigger_ref). It records occurrences — evaluate monthly partitioning (P41) vs flat-with-unique-index given idempotency-by-entity lookups + modest volume; decide at migration.
  • Migration edit: add 'purchase' to patient_content_grants.source CHECK (early-dev, edit the original migration). 'promotion' already covers campaigns. (source values: purchase / promotion / complimentary / migration.)

The provisioner (shared core)

ProvisionOffer(ctx, patient, offerID, trigger{type, ref}, source) → fulfillment:

  1. Insert the access_offer_fulfillments row first (ON CONFLICT DO NOTHING on the unique key) → on conflict, return the existing fulfillment (idempotent no-op).
  2. For each access_offer_items:
    • content_grantgranted_until = lifetime ? NULL : now + duration; INSERT patient_content_grants with source.
    • tier_subscription → compute the window here (SetSubscription replaces the one live row and returns the previous — see patientsubscriptions/service.go:135), so the extend-vs-replace policy for repeat purchases lives in the provisioner, not the subscription service. periodEndsAt = lifetime ? nil : window; call SetSubscription.
  3. Audit each mint. Emit offer.provisioned (Cat E) → the notify primitive sends the welcome/credentials email (replacing legacy Make.com).

Validation at provision time (the "validate the link, not just at authoring" rule): offer status='active', each item's target still published-and-grantable. A stale campaign card or SKU binding cannot mint access to archived/unpublished content.

Trigger A — shop post-checkout (paid)

  1. Match. Consume commerce.order_paid → for each line item, resolve access_offer_sku_bindings (integration_id, external_product_ref, external_variation_ref) → the set of offers. Record a pending fulfillment intent (order known, awaiting the buyer).
  2. Resume. Buyer clicks the redirect link {order_ref, key, shop} → Portal onboarding page → a Portal endpoint resolves the verified order (org via the shop's organization_integrations connection) + the matched offers for display.
  3. Onboard. Buyer signs up (Clerk) + completes portalonboarding (portable profile + per-clinic patients row + platform/org consents). Idempotent: an existing patient just signs in.
  4. Fulfill. POST /v1/me/orders/{order_ref}/fulfill (key as ownership proof) → ProvisionOffer(..., source='purchase') per matched offer, idempotent by order_ref.
  5. Access is live; the play gate already honors it. (The legacy "appointment step" is deferred — content-only.)

Trigger B — marketing campaign (free)

  1. Browse. Public campaign page on {slug}.portal.restartix.proGET /v1/public/campaigns/... reads published access_offer_campaigns (AdminPool, per-IP rate-limited, org-scoped, presentation columns only). Org resolved from hostname.
  2. Pick + onboard. Visitor selects offers → Clerk signup + portalonboarding.
  3. Claim. POST /v1/me/offers/{offerId}/claim → validate (campaign active, items grantable) → ProvisionOffer(..., source='promotion'), idempotent by (offer, patient).
  4. Access is live.

Clerk identity linking (the crux)

The buyer arrives from a cold shop checkout with a billing email but no Clerk account. Decision (v1): standard Clerk signup/sign-in on the onboarding/campaign page; provisioning binds to the authenticated patient; the order key proves order ownership; the billing email only prefills the profile. This is the GDPR-correct, simplest, most secure path — and it is literally the "onboarding link" legacy already sent, now landing on Clerk instead of a generated-password autologin. Enhancement (deferred): pre-provision a Clerk invitation / sign-in token from commerce.order_paid (Clerk Backend API) for a one-click experience closer to legacy autologin — adds a Clerk Backend API dependency (SOUP row). Designed to bolt onto the proven spine without rework.

Onboarding reuse

Both flows funnel through the existing portalonboarding (idempotent). The shop flow adds an order-resolve step in front; the campaign flow adds an offer-claim step after. Existing patient → access extends on their account; new user → profile + patient created. No second onboarding stack.

Clinic authoring UI (net-new)

Mirror the shipped patient-tiers-editor.tsx SWR pattern:

  • Offer builder — create an offer; add items (pick program/session or tier; set duration / lifetime).
  • Shop bindings — per connected shop (from the connectivity slice), map a product/variation SKU → an offer.
  • Campaign authoring — write the public access_offer_campaigns (headline/blurb/image), set publish + featured + order.

Compliance & security

  • No content-link egress — the public campaign endpoint uses classification.Filter and returns presentation columns only; offer_id/content_id classified org_internal. Every new column gets a data-classification.md entry in the same PR; access_offer_campaigns presentation columns get the public/marketing egress target (the one egress decision flagged in the marketing design).
  • Consents enforced before provisioningportalonboarding gates platform + org consents; a paid order does not bypass consent.
  • Audit — grants, subscriptions, and fulfillments are state transitions, audited normally.
  • RLS — all new tables per-org; the public campaign read is the only AdminPool-bypass path, secured by published-only filtering + rate limit.
  • PII minimization — billing email prefills profile only; no broad order-PII retention beyond the connectivity slice's forensic record.
  • SOUP — any new dep (e.g. Clerk Backend API SDK for the invitation enhancement) → SOUP row same PR.

Build order — risk-first

Both triggers ship for launch. Build order is hardest/riskiest first, as vertical slices that de-risk the shared spine before either trigger's surrounding machinery exists.

  • Step 0 — spine spike (the hardest part). The riskiest, most cross-system path: a cold entry point (stub offer) → Clerk signup/sign-in → portalonboarding (consents) → ProvisionOffer mints a grant → access live (play gate honors it). Proves the Clerk ↔ onboarding ↔ provisioner round-trip, new-vs-existing, and idempotency before any tables/UI/connectivity. This is where the Clerk identity model gets settled in code.
  • Step 1 — offers core. Harden the spike into real schema + service: access_offers / access_offer_items / access_offer_fulfillments + the ProvisionOffer service + Clinic offer authoring.
  • Step 2 — shop trigger (the harder adapter). Cat B/D connectivity (the connectivity slice, first framework consumers, per-connection token) + access_offer_sku_bindings + order→offer matching + the post-checkout onboarding page feeding the proven spine.
  • Step 3 — campaign trigger (the lighter adapter). access_offer_campaigns + the public campaign read endpoint (needs the egress-classification decision) + the claim flow + Portal campaign page + Clinic campaign authoring.

The two triggers are deliberately last because by then the spine is proven; each is a thin front door onto it.

Dependencies & open decisions

Depends on: the connectivity slice (commerce.order_paid); existing portalonboarding, patient_content_grants, patient_subscriptions, patient_tiers, and the play gate (all shipped). The public campaign endpoint depends on the egress-classification decision.

Open decisions:

  • Clerk identity model — standard signup (recommended v1) vs pre-provisioned invitation (UX enhancement).
  • Repeat-purchase tier policy — extend vs replace vs stack the window (legacy extended; the provisioner owns this).
  • Refund → revoke — SHIPPED (F14.5): content grants lapse precisely via patient_content_grants.fulfillment_id. Auto tier-revoke deferred (replace-semantics ambiguous).
  • Repeat-purchase tier policy — the provisioner uses SetSubscription (replace); extend/stack stays unbuilt.
  • access_offer_fulfillments partitioning — P41 event-log treatment vs flat. Kept flat (idempotency-by-entity lookups + modest volume).
  • Share-link unificationshare_links already pin a tier + drive onboarding; consider folding them into campaign offers later (they're the thin cousin of access_offer_campaigns).
  • F-number — registered as F14. Commerce & Access Offers (2026-06-01).