> ## Documentation Index
> Fetch the complete documentation index at: https://docs.calgest.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Launch checklist

# CalGest Launch-Day Operational Checklist

Generated 2026-07-23 from a code-verified production-readiness audit. Source of
truth for each item is cited with `file:line`. Ordered by when it must happen.
"Convex" = production Convex deployment env (`npx convex env set` / dashboard).
Frontend `VITE_`/`EXPO_PUBLIC_` vars are build-time and bake into the bundle, so
they must be correct **before** each app is built.

> Status note (2026-07-23): the production Convex deployment has 30 env vars set
> (names verified via `convex env list --prod`), but it is still missing the now
> required `SENTRY_DSN` and `SENTRY_ENVIRONMENT`. The deploy workflow checks the complete contract before
> pushing code and the deployed function checks it again afterward. Items below
> remain the canonical reference for account-side and client build configuration.

## BEFORE DEPLOY

### 1. External services provisioned

* [ ] **Stripe (managed\_payments / alpha)** — create the 6 recurring prices + the annual coupon; their IDs feed the env vars below. `packages/backend/convex/plans.ts:283-296` lists the exact keys. `assertBillingConfig()` (`plans.ts:234`) hard-fails checkout if any is unset or still ends in `_TODO`, so placeholders block paid signups.
  * Coupon must be `percent_off: 20, duration: forever` → `STRIPE_COUPON_ANNUAL` (`plans.ts:68`, comment at `:60-66`).
* [ ] **Clerk** — production instance with a JWT template named exactly **`convex`** (hardcoded `applicationID: "convex"` in `auth.config.js:11`). Issuer domain → `CLERK_JWT_ISSUER_DOMAIN`.
* [ ] **Clerk → custom email delivery via webhook**: the app renders/sends Clerk transactional emails itself through the `email.created` webhook event (`http.ts:687-707`, dispatches to `clerkEmailActions.handleEmailCreated`). Clerk must be configured to emit `email.created` (i.e. "send emails via webhook" / integration enabled), otherwise verification/reset emails silently never send.
* [ ] **Resend** — verify the sending domain; the from-address is required in prod (`notificationEmail.ts:16-27` and `clerkEmailActions.ts:14-21` throw if `RESEND_FROM_EMAIL` unset outside an explicit dev env).
* [ ] **Cloudflare R2** — two EU-jurisdiction buckets already referenced: **`calgest-prod`** (and `calgest-dev`) in `apps/cdn/wrangler.jsonc:12-16`. Generate an R2 API token (S3 creds + token).
* [ ] **GatewayAPI (SMS)** — account + sender token; choose a webhook auth token for delivery callbacks.
* [ ] **OpenAI** — API key for the AI assistant agent (`agent.ts:1,85-86`).
* [ ] **Sentry** — DSN + auth token/org/project for error reporting and source-map upload. The production backend gate requires the DSN.
  * Keep the project on the free tier and create its single uptime monitor for `GET https://<convex-site-url>/healthz/ready`.
  * Configure an email alert for new/high-volume errors; do not enable tracing, replay, logs, profiling, or default PII.

### 2. Production Convex deployment env vars

Read locations in parentheses. All required unless marked optional.

**Auth / Clerk**

* [ ] `CLERK_JWT_ISSUER_DOMAIN` (`auth.config.js:10`)
* [ ] `CLERK_SECRET_KEY` — backend Clerk API for Google-calendar OAuth tokens & user locale (`calendarImport.ts:27`, `userLocale.ts:26`)
* [ ] `CLERK_WEBHOOK_SECRET` — svix verification (`http.ts:602`)

**Stripe** (`STRIPE_SECRET_KEY` read in `http.ts:265` and by `@convex-dev/stripe`; `STRIPE_WEBHOOK_SECRET` by the component's `registerRoutes`)

* [ ] `STRIPE_SECRET_KEY`
* [ ] `STRIPE_WEBHOOK_SECRET`
* [ ] `STRIPE_PRICE_SOLO_MONTHLY`, `STRIPE_PRICE_SOLO_YEARLY`
* [ ] `STRIPE_PRICE_TEAM_MONTHLY`, `STRIPE_PRICE_TEAM_YEARLY`
* [ ] `STRIPE_PRICE_TEAM_SEAT_MONTHLY`, `STRIPE_PRICE_TEAM_SEAT_YEARLY`
* [ ] `STRIPE_COUPON_ANNUAL`
* [ ] `ALLOWED_REDIRECT_DOMAINS` — comma-separated host allowlist. **Fails closed**: `stripe.ts:77-85` rejects every non-localhost checkout success/cancel redirect when empty, so an unset value silently breaks checkout in production. Must contain the dashboard host (e.g. `dashboard.calgest.com`).

**R2 / attachments / CDN** (`internalAttachments.ts:19-22`, `r2Deletion.ts:52-55`; R2 component README lists all 5)

* [ ] `R2_BUCKET` (= `calgest-prod`)
* [ ] `R2_ENDPOINT`
* [ ] `R2_ACCESS_KEY_ID`
* [ ] `R2_SECRET_ACCESS_KEY`
* [ ] `R2_TOKEN` (required by `@convex-dev/r2` component)
* [ ] `ASSET_TOKEN_SECRET` — HMAC secret for signed download URLs (`security.ts:325`). **Must be byte-identical to the CDN worker's `ASSET_TOKEN_SECRET`** (`apps/cdn/src/index.ts:157`) or every attachment/photo download 403s.
* [ ] `CDN_URL` — base for signed URLs (`security.ts:308`, throws if unset when issuing a download). Must be `https://cdn.calgest.com`.

**Email**

* [ ] `RESEND_API_KEY` (Resend component)
* [ ] `RESEND_FROM_EMAIL` (`notificationEmail.ts:16`, `clerkEmailActions.ts:14`)
* [ ] `CONTACT_EMAIL` — optional, defaults to `general@calgest.com` (`contactRequestsActions.ts:27`)
* [ ] `RESEND_WEBHOOK_SECRET` — **not required**: no Resend delivery-event route is registered in `http.ts`. Skip unless you add one.

**SMS (GatewayAPI)**

* [ ] `GATEWAYAPI_TOKEN` — send auth (`sms.ts:340`)
* [ ] `GATEWAYAPI_WEBHOOK_SECRET` — delivery-callback JWT verification; handler returns 503 without it (`http.ts:903-907`)

**AI / observability**

* [ ] `OPENAI_API_KEY` (AI agent)
* [ ] `SENTRY_DSN` and `SENTRY_ENVIRONMENT=production` — required by the production configuration gate; backend reporting no-ops outside that explicit environment and schedules scrubbed error delivery through an internal action (`lib/observability.ts`)

**Mobile force-update gate** (`mobileVersion.ts:22-27`) — optional but recommended so you *can* force-update later:

* [ ] `MIN_SUPPORTED_MOBILE_VERSION` (defaults `0.0.0` = no floor)
* [ ] `IOS_APP_STORE_URL`, `ANDROID_PLAY_STORE_URL` (store-redirect for forced updates)

### 3. Frontend build-time env vars (set in host/CI before building each bundle)

**Dashboard host** (Vite SPA — `apps/dashboard`):

* [ ] `VITE_CONVEX_URL`, `VITE_CLERK_PUBLISHABLE_KEY`, `VITE_CDN_URL`, `VITE_SENTRY_DSN`
* [ ] `VITE_BOOKING_URL` — documented in `apps/dashboard/.env.example`; set it explicitly so the public-booking URL shown in settings targets production.
* [ ] Optional: `VITE_APP_VERSION`; build-only `SENTRY_AUTH_TOKEN`/`SENTRY_ORG`/`SENTRY_PROJECT` are required by the production deploy for source maps.

**Web / marketing host** (Vite SPA — `apps/web`):

* [ ] `VITE_CONVEX_URL`, `VITE_CLERK_PUBLISHABLE_KEY`, `VITE_SENTRY_DSN`, `VITE_COMING_SOON` (set `"true"` only if you want the splash)
* [ ] `VITE_DASHBOARD_URL` — documented and production-validated by the web app. An empty value is rejected before build so sign-in/pricing CTAs cannot silently ship broken.
* [ ] Build-only Sentry source-map vars as above.

**EAS / mobile build profiles** (`apps/mobile/eas.json` → `production` profile uses `environment: "production"`, channel `production`; set these as EAS env/secrets for that environment):

* [ ] `EXPO_PUBLIC_CONVEX_URL`, `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`, `EXPO_PUBLIC_SENTRY_DSN`
* [ ] `EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID`, `EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID`, `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME` (Google sign-in; the URL scheme is also hardcoded in `app.json:65` and must match)
* [ ] `EXPO_PUBLIC_DASHBOARD_URL` — documented and production-validated by the mobile app.
* [ ] `SENTRY_AUTH_TOKEN`/`ORG`/`PROJECT` for source maps. Set `SENTRY_AUTH_TOKEN` to EAS **Sensitive** visibility, not Secret, because the `eas env:exec` preflight cannot read Secret-visibility variables.
* [ ] EAS project owner `adamtrip-solutions`, bundle/package `com.adamtrip.calgest` (`app.json:63-82`). Confirm the store submission profile (`submit.production`) has credentials.

### 4. CDN worker (Cloudflare — `apps/cdn`)

* [ ] `wrangler secret put ASSET_TOKEN_SECRET` for **production** (must equal the Convex `ASSET_TOKEN_SECRET`).
* [ ] Confirm `vars.ALLOWED_ORIGINS` covers the real dashboard/app origins — currently `https://dashboard.calgest.com,https://app.calgest.com` (`wrangler.jsonc:9-11`); CORS at `index.ts:8` rejects anything else.
* [ ] Custom domain `cdn.calgest.com` bound (`wrangler.jsonc` routes), R2 binding `R2_BUCKET` → `calgest-prod`.

## AT DEPLOY

* [ ] Deploy backend: `npx convex deploy` (registers all HTTP routes + crons).
* [ ] Deploy CDN worker: `wrangler deploy` (prod env).
* [ ] Build/deploy dashboard + web; build/submit mobile via EAS production profile.
* [ ] **Register Stripe webhook endpoint** → `https://<convex-site-url>/stripe/webhook` (path from `http.ts:177`). Subscribe exactly these events (the only ones handled — `http.ts`):
  * `checkout.session.completed`
  * `checkout.session.async_payment_succeeded`
  * `checkout.session.async_payment_failed`
  * `customer.subscription.updated`
  * `customer.subscription.deleted`
  * `invoice.payment_failed`
  * `invoice.paid`
  * `payment_intent.succeeded`
  * `customer.updated`
  * Copy its signing secret into `STRIPE_WEBHOOK_SECRET`.
* [ ] **Register Clerk webhook endpoint** → `https://<convex-site-url>/clerk-webhook` (`http.ts:592`). Subscribe: `user.created`, `user.updated`, `user.deleted`, `email.created`, `session.created` (`http.ts:653-717`). Copy signing secret into `CLERK_WEBHOOK_SECRET`.
* [ ] **Register GatewayAPI delivery webhook** → `https://<convex-site-url>/gatewayapi/webhook` (`http.ts:906`) with the chosen token as `GATEWAYAPI_WEBHOOK_SECRET`.

## AFTER DEPLOY

### 5. One-off backfills / migrations

Run once. On a truly empty production DB these are no-ops, but run them anyway
(idempotent) — and they are **mandatory if any data was imported/migrated** from
a prior deployment. `@convex-dev/migrations` runners
(`packages/backend/convex/migrations.ts`); note the deploy workflow only
automates `runBackfillOrganizationMembershipUserId` — the rest are manual:

* [ ] `npx convex run migrations:runBackfillAttachmentEntityType --prod`
* [ ] `npx convex run migrations:runBackfillOrganizationMembershipUserId --prod` (automated in deploy.yml)
* [ ] `npx convex run migrations:runBackfillActivationCompleted --prod` — billing correctness; feeds the `activationCompletedForSubscriptionId` guard and the bundled-credit cron.
* [ ] `npx convex run migrations:runBackfillAiUsageCosts --prod`
* [ ] `npx convex run migrations:runBackfillAppointmentSeriesProgress --prod`

Batched internal mutations in `backfillSearchTerms.ts` — **re-run each until
`done: true`** (500/batch). The search query relies on `searchTerms`
(`search.ts:32-50`), so skipping them makes pre-existing
customers/employees/appointments unsearchable:

* [ ] `backfillSearchTerms:backfillCustomers`
* [ ] `backfillSearchTerms:backfillEmployees`
* [ ] `backfillSearchTerms:splitCustomerPhones`
* [ ] `backfillSearchTerms:splitEmployeePhones`
* [ ] `backfillSearchTerms:backfillAppointments`
* [ ] `backfillSearchTerms:backfillAppointmentDenormalization`

(`seed.ts` is dev/demo seeding — do **not** run in production.)

### 5a. Customer identity linkage preflight

Customer portal access now uses the explicit `customers.userId` link and fails
closed when it is absent. A read-only production check on 2026-07-23 found one
Customer role but **zero Customer-role memberships**, so the current production
dataset has no customer user to interrupt. Re-check immediately before launch.

* [ ] Page through `customerIdentityMigration:discoverCustomerRoles` and run
  `customerIdentityMigration:preflight` for every returned role.
* [ ] If Customer-role memberships now exist, use the bounded
  `customerIdentityMigration:backfillBatch` mutation first with `dryRun: true`,
  then with `dryRun: false` only for unambiguous `linkable` IDs. It matches exact
  normalized email addresses and never auto-links ambiguous/no-match rows.
* [ ] Resolve every remaining status explicitly before enabling customer portal
  access. If production has acquired memberships since the verified snapshot,
  use a two-phase release or a planned maintenance window so fail-closed access
  does not become an unexpected outage.

### 6. Verify crons registered

After deploy, confirm these appear in the Convex dashboard crons list (`crons.ts`):

* [ ] roll forward recurrence series (daily 02:00)
* [ ] purge old contact requests (daily 03:30)
* [ ] auto-complete finished appointments (every 1 min)
* [ ] cleanup expired asset upload intents (every 15 min)
* [ ] process bundled credit anniversaries (daily 03:00)
* [ ] cleanup processed stripe events (daily 04:00)
* [ ] reconcile subscriptions from stripe (daily 05:00) — nightly Stripe-truth drift correction
* [ ] purge expired message logs (daily 06:00) — GDPR retention
* [ ] sweep stalled organization deletions (hourly) — GDPR erasure resilience
* [ ] sweep orphaned r2 objects (daily 06:30) — R2 deletion compensation queue

### 7. Smoke tests

* [ ] `GET https://<convex-site-url>/healthz` → 200 `{"status":"ok"}` (`http.ts:1048`)
* [ ] `GET https://<convex-site-url>/healthz/ready` → 200 (does a real DB probe; `http.ts:1049`). The production workflow retries this database-backed route and fails the deploy if it never becomes ready.
* [ ] End-to-end: sign up (Clerk email arrives via Resend), start a subscription checkout (verify `ALLOWED_REDIRECT_DOMAINS` lets the redirect through and the webhook activates the org), upload an attachment then download it (verifies `ASSET_TOKEN_SECRET` parity backend↔CDN), send one SMS + confirm delivery webhook flips status.
* [ ] Marketing site: click Sign in / Try for free / a pricing CTA and confirm they land on the dashboard (guards against an empty `VITE_DASHBOARD_URL` build).
