Skip to content

Promotion Campaigns — Detailed Changes (2026-04-27 → 2026-04-29)

Summary

Introduced a tenant-scoped promotion / promo-code system across OktaPT-API (16 files) and OktaPT-FE (8 files). Tenant admins create branded campaigns with one or many promo codes; therapists redeem codes during signup, on a partner landing page, or from their profile after registration. Each campaign has a built-in analytics dashboard (signups, patient invites/consents, workout activity).

Three commits make up the rollout:

Commit Date Scope
73b5289 (API) / 5739e87 (FE, on main since merged PR) 2026-04-27 Backend service + admin/public/user endpoints + landing page + admin CRUD UI
306c6e0 (FE) 2026-04-28 Adds the "Already a registered user? Log in to add this promotion in your profile" CTA on the landing page; new therapistPromo.hasAccount / signIn translation keys
579ba38 (API) + e9d7452 (FE) 2026-04-29 Campaign analytics: getCampaignStats service, GET /v2/admin/promotions/:id/stats endpoint, daily-signup chart + sortable therapist table on the admin page

For the architectural breakdown (data model, JWT flow, serializable transactions, analytics computation), see internal/promotion-campaigns.md.

Data Flow — Three Redemption Paths

                    ┌──────────────────────────────────┐
                    │     Therapist enters code        │
                    └──┬─────────────┬─────────────┬───┘
                       │             │             │
            signup form │   landing page slug │   /profile (auth)
                       ▼             ▼             ▼
            ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
            │ resolveSignup│ │ validate-    │ │ redeemPromotion- │
            │ PromotionInput│ │ Campaign-    │ │ FromProfile      │
            │ (in user TX) │ │ CodeForPreview│ │ (serial. TX)    │
            └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘
                   │                │                  │
                   │       JWT (validated_promo, 24h)  │
                   │                ▼                  │
                   │         signup form + token       │
                   │                │                  │
                   ▼                ▼                  ▼
            ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
            │ source =     │ │ source =     │ │ source = PROFILE │
            │ SIGNUP       │ │ CAMPAIGN_PAGE│ │ (allowProfile-   │
            │              │ │              │ │  Redemption gate)│
            └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘
                   └────────────────┼──────────────────┘
                  ┌────────────────────────────────────┐
                  │ createPromotionRedemptionIn-       │
                  │ Transaction()                      │
                  │  → assertPromotionCodeRedeemable   │
                  │  → unique(userId, promotionCodeId) │
                  └────────────────┬───────────────────┘
                       UserPromotionRedemption row

Schema Diff

Migration: prisma/migrations/20260426191350_added_promo_codes/migration.sql.

Two enums:

CREATE TYPE "PromotionRewardType" AS ENUM ('FREE_MONTHS');
CREATE TYPE "PromotionRedemptionSource" AS ENUM ('SIGNUP', 'CAMPAIGN_PAGE', 'PROFILE');

Three tables with their key constraints:

-- PromotionCampaign — tenant-scoped, slug-unique
CREATE UNIQUE INDEX "PromotionCampaign_tenantId_slug_key"
  ON "PromotionCampaign"("tenantId", "slug");
CREATE INDEX "PromotionCampaign_tenantId_isActive_idx"
  ON "PromotionCampaign"("tenantId", "isActive");

-- PromotionCode — codeNormalized unique per tenant
CREATE UNIQUE INDEX "PromotionCode_tenantId_codeNormalized_key"
  ON "PromotionCode"("tenantId", "codeNormalized");
CREATE INDEX "PromotionCode_campaignId_idx" ON "PromotionCode"("campaignId");
CREATE INDEX "PromotionCode_tenantId_isActive_idx"
  ON "PromotionCode"("tenantId", "isActive");

-- UserPromotionRedemption — user can redeem a code at most once
CREATE UNIQUE INDEX "UserPromotionRedemption_userId_promotionCodeId_key"
  ON "UserPromotionRedemption"("userId", "promotionCodeId");
CREATE INDEX "UserPromotionRedemption_tenantId_campaignId_idx"
  ON "UserPromotionRedemption"("tenantId", "campaignId");
CREATE INDEX "UserPromotionRedemption_userId_createdAt_idx"
  ON "UserPromotionRedemption"("userId", "createdAt");
CREATE INDEX "UserPromotionRedemption_promotionCodeId_createdAt_idx"
  ON "UserPromotionRedemption"("promotionCodeId", "createdAt");

All foreign keys cascade on delete. Removing a tenant therefore removes its campaigns, codes, and redemption history.

OktaPT-API Changes (16 files)

New Files

File Purpose
prisma/migrations/20260426191350_added_promo_codes/migration.sql The schema migration above.
src/routes/promotions/index.ts Public router — GET /public/:slug, POST /public/:slug/validate. Mounted at /v2/promotions.
src/routes/promotions/controllers.ts getPublicPromotionCampaign, validatePublicPromotionCode. No auth.
src/routes/user/promotion.controllers.ts Doctor-only getUserPromotionRedemptions, createUserPromotionRedemption. Mounted at /v2/user/promotion-redemptions.
src/routes/admin/promotions.controllers.ts Admin CRUD: getAdminPromotions, createAdminPromotion, updateAdminPromotion, getAdminPromotionCodes, createAdminPromotionCode, importAdminPromotionCodes, updateAdminPromotionCode, getAdminPromotionStats.
src/services/promotionService.ts ~933 lines covering code normalization, JWT issue/parse, redeemability checks (assertPromotionCodeRedeemable), signup integration (resolveSignupPromotionInput), profile redemption with serializable retry (redeemPromotionFromProfile + runSerializableTransaction), and analytics (getCampaignStats).
src/routes/__tests__/promotions.test.ts 221 lines — code normalization, redeemability windows, max-redemption caps, JWT validation, signup integration, profile-redemption gating.

Modified Files

File Change
prisma/schema.prisma Two enums + three models + four reverse relations on Tenant and User.
src/routes/index.ts Mounts /promotions router.
src/routes/user/index.ts Adds GET and POST /promotion-redemptions (authenticated) routes wired to the new doctor-only controllers.
src/routes/user/controllers.ts createUser now resolves an optional promoCode or validatedPromoToken from the body, opens a serializable transaction with up to 3 P2034 retries, creates the user, then writes the UserPromotionRedemption inside the same transaction. The eb52baa follow-up commit also flips isApproved to true for all user types — see the separate "Doctor Auto-Approval" entry.
src/routes/admin/index.ts Adds 8 admin promotion routes after requireAdmin.
src/routes/tenant/controller.ts, tenant/index.ts Adds a small helper used by the public landing flow to resolve the calling tenant.
src/routes/__tests__/user-auth.test.ts Updated to cover the new signup body fields.
test/helpers/jest-setup.ts Adds the new Prisma models to the mock surface.

OktaPT-FE Changes (8 files)

File Change
pages/admin/promotions.tsx New 372-line admin page: tenant selector → campaign list/form → code list / bulk import → analytics dashboard with stat cards, daily-signup bar chart, sortable therapist table.
pages/therapist-promo/[slug].tsx Branded landing page. Server-side fetches the campaign; client-side validates the code, animates in the success state, and reveals the signup section. The 2026-04-28 update added an "Already a registered user? Log in to add this promotion in your profile" CTA below the success block.
lib/types/promotion.ts Shared types: AdminPromotionCampaign, AdminPromotionCode, AdminCampaignStats, PublicPromotionCampaign, ValidatedPromoTokenPayload, etc.
components/admin/TenantMultiSelect.tsx Reusable multi-tenant picker. Currently consumed by the new UserExportModal; the promotions page uses a single-select <select> for now.
components/admin/UserExportModal.tsx Companion to the unrelated CSV-export change shipped on 2026-04-29 — see the CHANGELOG entry for "Admin User CSV Export".
pages/admin/user-management.tsx Adds the "Export to CSV" button + modal trigger.
public/locales/en/common.json New therapistPromo.hasAccount, therapistPromo.signIn keys.
public/locales/ru/common.json Russian equivalents.

Design Decisions

  1. Three explicit redemption sources, not derived from context. PromotionRedemptionSource is pinned at insert time so analytics can split "new acquisition" (SIGNUP + CAMPAIGN_PAGE) from "existing therapist applies code" (PROFILE) without re-deriving intent later.
  2. 24-hour validatedPromoToken JWT. A short-lived token bridges the public validate call and the signup call without re-sending the raw code. The purpose: "validated_promo" field is checked on parse to ensure the token can't be replayed against any other endpoint that happens to use the same JWT_SECRET.
  3. Serializable isolation, not optimistic locking, for maxRedemptions enforcement. A row-level maxRedemptions check followed by an insert is racy under default isolation. runSerializableTransaction retries on Prisma P2034 up to three times, after which it surfaces PROMOTION_RETRY_FAILED (500). This favors strict cap enforcement over throughput; campaign caps are typically small.
  4. (userId, promotionCodeId) is the last line of defense, not the only one. The application also explicitly checks for an existing redemption inside the transaction so the therapist gets a clean PROMOTION_ALREADY_REDEEMED error rather than a Prisma constraint-violation 500.
  5. Public endpoints return 404 when the feature flag is off. Returning a clear "feature disabled" error would let attackers fingerprint which tenants have the flag enabled. 404 keeps the surface flat.
  6. Admin endpoints are not gated by promoCodeCapture. This lets a tenant admin pre-build campaigns and codes, then flip the flag on. Without this, a bootstrap problem would force the flag on before the campaign existed.
  7. Analytics excludes PROFILE from totalTherapistSignups. A profile-redemption is by definition not a new signup; counting it would inflate the acquisition metric. Profile redemptions still appear in the per-therapist table (with source: PROFILE), just not in the headline number.

Rollout Notes

  • Migration is additive only — no destructive operations on existing tables. Safe to apply in production before code deploy.
  • promoCodeCapture defaults to absent (falsy) for every existing tenant. The system is fully off until a tenant admin sets the flag.
  • Existing therapists who already signed up will not see promo prompts unless they visit a partner landing page or the profile redemption UI. No backfill is needed.
  • The first campaign for a tenant should be created before the flag is flipped on — admin endpoints work regardless of the flag, but the public landing page returns 404 until the flag is on.