CalGest Launch-Day Operational Checklist
Generated 2026-07-23 from a code-verified production-readiness audit. Source of truth for each item is cited withfile: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 (verified 2026-09-03 against live Convex, GitHub, EAS, and the public hosts). Local gates pass on the current branch: typecheck, lint, and 1288 vitest tests. The blockers are all environment and release plumbing.Housekeeping: Release Please PR #32 has been open since July with its checks stuck at
- Prod Convex runs a Stripe test key.
STRIPE_SECRET_KEYonsleek-chipmunk-87starts withsk_test_. The six price IDs and the coupon are set and none end in_TODO, but a test key only sees test-mode prices, so live checkout cannot work until the key and the prices are recreated in live mode and the webhook secret is regenerated for the live endpoint. Nothing in the deploy gate catches this.- Prod Convex still lacks
SENTRY_DSNandSENTRY_ENVIRONMENT(30 env vars, same set as in August). Both the pre-deploy gate indeploy.ymlandproductionConfig:validaterequire them, so a backend deploy fails before it pushes code.- Prod is running stale code.
GET /healthzandGET /healthz/readyon the prod site both return 404. The last automated deploy (2026-07-10) failed schema validation because anorganizationMembershipsrow has nouserId, somigrations:runBackfillOrganizationMembershipUserIdmust run against prod before the next deploy can succeed.- Every GitHub repository variable is unset, including
SENTRY_ORGandSENTRY_PROJECT. The dashboard and web deploys fail atvalidate:env.- The
EXPO_TOKENrepo secret does not exist. The mobile OTA deploy and the Mobile Build workflow have never run. The only EAS build on record is apreviewiOS build from 2026-05-09.https://cdn.calgest.comreturns 404. The CDN worker has not been deployed to production.dashboard.calgest.comandcalgest.comboth serve.- The iOS permission blocker from July is unfixed.
app.jsondeclares microphone and speech recognition only, andapps/mobile/iosis gitignored, so EAS builds get noNSCameraUsageDescriptionorNSPhotoLibraryUsageDescription. Android declares no camera permission. Profile photo capture (settings/profile.tsxusesexpo-image-picker) will crash on iOS.action_required, so no release tag covers the hardening work.VITE_POSTHOG_KEYand the prodPOSTHOG_API_KEY/POSTHOG_HOSTvars are still set and still unused. Verified correct: all 12 expected repo secrets exist, the EASproductionenvironment has its 9 required variables,CLERK_JWT_ISSUER_DOMAINishttps://clerk.calgest.com,ALLOWED_REDIRECT_DOMAINScovers the dashboard and marketing hosts,CDN_URLishttps://cdn.calgest.com, and the web app ships privacy and terms pages in PT and EN.
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-296lists 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).
- Coupon must be
- Clerk — production instance with a JWT template named exactly
convex(hardcodedapplicationID: "convex"inauth.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.createdwebhook event (http.ts:687-707, dispatches toclerkEmailActions.handleEmailCreated). Clerk must be configured to emitemail.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-27andclerkEmailActions.ts:14-21throw ifRESEND_FROM_EMAILunset outside an explicit dev env). - Cloudflare R2 — two EU-jurisdiction buckets already referenced:
calgest-prod(andcalgest-dev) inapps/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.
- Keep the project on the free tier and create its single uptime monitor for
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_SECRET_KEY read in http.ts:265 and by @convex-dev/stripe; STRIPE_WEBHOOK_SECRET by the component’s registerRoutes)
-
STRIPE_SECRET_KEY— must be a live key (sk_live_). As of 2026-09-03 prod holds ansk_test_key. Price and coupon IDs are mode-specific, so recreate all seven in live mode at the same time. -
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-85rejects 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).
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/r2component) -
ASSET_TOKEN_SECRET— HMAC secret for signed download URLs (security.ts:325). Must be byte-identical to the CDN worker’sASSET_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 behttps://cdn.calgest.com.
-
RESEND_API_KEY(Resend component) -
RESEND_FROM_EMAIL(notificationEmail.ts:16,clerkEmailActions.ts:14) -
CONTACT_EMAIL— optional, defaults togeneral@calgest.com(contactRequestsActions.ts:27) -
RESEND_WEBHOOK_SECRET— not required: no Resend delivery-event route is registered inhttp.ts. Skip unless you add one.
-
GATEWAYAPI_TOKEN— send auth (sms.ts:340) -
GATEWAYAPI_WEBHOOK_SECRET— delivery-callback JWT verification; handler returns 503 without it (http.ts:903-907)
-
OPENAI_API_KEY(AI agent) -
SENTRY_DSNandSENTRY_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)
mobileVersion.ts:22-27) — optional but recommended so you can force-update later:
-
MIN_SUPPORTED_MOBILE_VERSION(defaults0.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 inapps/dashboard/.env.example; set it explicitly so the public-booking URL shown in settings targets production. - Optional:
VITE_APP_VERSION; build-onlySENTRY_AUTH_TOKEN/SENTRY_ORG/SENTRY_PROJECTare required by the production deploy for source maps.
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.
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 inapp.json:65and must match) -
SENTRY_AUTH_TOKEN/ORG/PROJECTfor source maps. SetSENTRY_AUTH_TOKENto EAS Sensitive visibility, not Secret, because theeas env:execpreflight cannot read Secret-visibility variables. - EAS project owner
adamtrip-solutions, bundle/packagecom.adamtrip.calgest(app.json:63-82). Confirm the store submission profile (submit.production) has credentials.
3a. GitHub Actions configuration
deploy.yml and mobile-build.yml read these. pnpm validate:env fails the
job when a required name is empty, so an unset value stops the deploy rather
than shipping a broken bundle.
Repository secrets (verified present 2026-08-22 unless marked):
-
CONVEX_DEPLOY_KEY(backend deploy) -
CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID(dashboard, web, CDN) -
ASSET_TOKEN_SECRET(pushed to the CDN worker bywrangler secret put; must equal the Convex value) -
SENTRY_AUTH_TOKEN(source-map upload) -
VITE_CONVEX_URL,VITE_CLERK_PUBLISHABLE_KEY,VITE_CDN_URL,VITE_DASHBOARD_URL,VITE_SENTRY_DSN,VITE_COMING_SOON -
EXPO_TOKENis MISSING. Required by the mobile OTA deploy and bymobile-build.yml. Create an Expo access token and add it, or neither mobile job can run. -
VITE_POSTHOG_KEYexists but nothing reads it. No PostHog reference survives anywhere in the repo. Delete it, along with thePOSTHOG_API_KEYandPOSTHOG_HOSTvars on the prod Convex deployment.
VITE_BOOKING_URL and VITE_APP_VERSION are deliberately not secrets. The
workflow hardcodes the booking URL to https://calgest.com and derives the app
version from the release tag.
Repository variables (gh variable list returns nothing today, so all four
are unset):
-
SENTRY_ORGandSENTRY_PROJECTare required. The dashboard and web jobs pass both tovalidate:env, so both deploys fail until these exist. Set them as repo variables, not secrets. -
CONVEX_SITE_URLis optional. When unset the backend job derives the readiness URL fromVITE_CONVEX_URLby swapping.convex.cloudfor.convex.site. -
WEB_URLis optional and defaults tohttps://calgest.com. -
AUTO_DEPLOY_RELEASESshould stay unset. Leaving it off means Release Please never dispatches a deploy on its own and every production rollout is a manual, confirmedworkflow_dispatch.
production exists and every deploy job targets it, but it has
no protection rules. Required reviewers need GitHub Pro on a private repo, which
is why release-please.yml defaults to manual dispatch instead. The
confirm_production input on deploy.yml is the only human gate.
4. CDN worker (Cloudflare — apps/cdn)
-
wrangler secret put ASSET_TOKEN_SECRETfor production (must equal the ConvexASSET_TOKEN_SECRET). - Confirm
vars.ALLOWED_ORIGINScovers the real dashboard/app origins — currentlyhttps://dashboard.calgest.com,https://app.calgest.com(wrangler.jsonc:9-11); CORS atindex.ts:8rejects anything else. - Custom domain
cdn.calgest.combound (wrangler.jsoncroutes), R2 bindingR2_BUCKET→calgest-prod. As of 2026-09-03 the host returns 404, so the worker has not been deployed yet.
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 fromhttp.ts:177). Subscribe exactly these events (the only ones handled —http.ts):checkout.session.completedcheckout.session.async_payment_succeededcheckout.session.async_payment_failedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failedinvoice.paidpayment_intent.succeededcustomer.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 intoCLERK_WEBHOOK_SECRET. - Register GatewayAPI delivery webhook →
https://<convex-site-url>/gatewayapi/webhook(http.ts:906) with the chosen token asGATEWAYAPI_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 and
runDropInAppNotificationFields — 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 theactivationCompletedForSubscriptionIdguard and the bundled-credit cron. -
npx convex run migrations:runBackfillAiUsageCosts --prod— re-prices rows whose model had no rate before (embeddings, gpt-4o-mini, gpt-4.1-nano); rows already carrying a cost are left as they were -
npx convex run migrations:runRepriceAiUsageCosts --prod— re-prices every row with the current rate table (the gpt-5.6-luna rates were 5× too high before 2026-09-03); skip if the deployment never ran the old table -
npx convex run aiUsage:rebuildAiMonthlyTotalsAll --prodandnpx convex run aiUsage:rebuildAiMonthlyTotalsAll '{"monthKey": "YYYY-MM"}' --prodfor the previous month — after the backfill and reprice finish; rebuilds the monthly counters that the admin AI usage page (current and previous month) and the fair-use gate read -
npx convex run migrations:runBackfillAppointmentSeriesProgress --prod -
npx convex run migrations:runDropInAppNotificationFields --prod(automated in deploy.yml) — unsets the retiredinAppEnabled/inAppTemplatecolumns so the schema can drop them in a later release
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 explicitcustomers.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:discoverCustomerRolesand runcustomerIdentityMigration:preflightfor every returned role. - If Customer-role memberships now exist, use the bounded
customerIdentityMigration:backfillBatchmutation first withdryRun: true, then withdryRun: falseonly for unambiguouslinkableIDs. 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_DOMAINSlets the redirect through and the webhook activates the org), upload an attachment then download it (verifiesASSET_TOKEN_SECRETparity 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_URLbuild).