Deployment and scheduled jobs

Build

pnpm build:prod   # prisma generate && next build

prisma generate must run before next build, because the client is generated into prisma/generated and imported as @/prisma/generated/client. A build without it fails on missing types.

Related configuration:

  • next.config.mjs — MDX support, the Sentry plugin, image and build settings

  • source.config.ts — Fumadocs MDX pipeline for the in-app docs

  • postcss.config.mjs — Tailwind 4 via @tailwindcss/postcss

  • tsconfig.json — path aliases (@/*) and strict mode

  • instrumentation.ts, instrumentation-client.ts — Sentry instrumentation hooks

Vercel

The application deploys to Vercel. Environment variables are configured per environment there; nothing sensitive is committed.

lib/constants.ts and lib/ai/config.ts read process.env at module load, so changing a variable requires a redeploy — a running instance keeps its original value.

Scheduled jobs

The app has no Vercel crons and no vercel.json. Global scheduled jobs are Inngest cron functions, served through /api/inngest like every other function (see [_inngest]):

Function Schedule Effect

phoneNumberDailyResetCron

Daily 00:00 UTC

resetDailyCounts() — zeroes PhoneNumbers.dailyCallCount

sendingDomainDailyResetCron

Daily 00:00 UTC

Zeroes EmailSendingDomain.dailySentCount and clears dailySentDate. Lifetime totalSentCount is untouched

phoneNumberOptimizerCron

Monday 11:15 UTC

findReleasableNumbers(3, 10), then one releasePhoneNumberComplete step per number unused for 3+ days with fewer than 10 lifetime calls

Both resets run at UTC midnight, which is not midnight Eastern. Daily caps therefore reset mid-evening local time. This is intentional for consistency across regions, but it means a cap can be consumed twice within one Eastern-time day.

The email daily cap does not depend on the reset running: each account’s counter is stamped with its UTC day (dailySentDate), and a counter from an earlier day counts as zero. The reset is a backstop. To run any of these jobs now, invoke the function from the Inngest dashboard.

Firewall

The Vercel WAF custom rules are defined in vercel.firewall.json and applied from a developer machine with pnpm firewall:apply, not edited in the dashboard. There is no CI job: merging a change to the file does not apply it. The app is internal-only, so everything except machine-called routes must come from the company VPN.

Rules are evaluated in order; a bypass skips the remaining custom rules.

Rule Matches Action

Allow Inngest

Path starts with /api/inngest and the client IP is one of Inngest’s published egress IPs

bypass

Allow machine routes

/api/webhooks/, /.well-known/workflow/ (Vercel Workflow callbacks), /api/unsubscribe, /api/mcp, and /api/{campaigns,email-campaigns}/{id}/broadcast

bypass

VPN only

Client IP is not a VPN exit IP: 52.70.159.172 (the OpenVPN networking-application connector, confirmed from Vercel traffic) and the AWS NAT gateways 52.70.240.125, 3.228.233.93

log during rollout, then deny

The machine routes rely on their own auth (INTERNAL_API_SECRET, MCP_API_SECRET, webhook signatures, the Inngest signing key). A request to /api/inngest from any other IP falls through to VPN only.

The VPN only routes the app’s domains through the AWS connector (CloudConnexa domain routing), so the NAT gateways' public IPs are what Vercel sees. The VPN tunnel addresses (10.100.60.x) and the domain-routing overlay addresses (100.80.0.0/12) never reach Vercel. The two NAT gateway IPs are also in the API Gateway resource policy; change both together. The connector IP must be an Elastic IP: an auto-assigned public IP changes when the instance restarts and locks everyone out. Preview URLs (*.vercel.app) are covered by the same rules and need to be in the VPN’s domain routing too.

Applying changes

export VERCEL_FIREWALL_TOKEN=...   # Vercel token with access to the project
export VERCEL_PROJECT_ID=...
export VERCEL_TEAM_ID=...
pnpm firewall:plan    # validate vercel.firewall.json and diff it against the live config
pnpm firewall:apply   # same, then PUT the config and verify Vercel published it

Review the change in a pull request first, then run firewall:plan and firewall:apply from the merged main, so the live config always matches the committed file. Without a token, firewall:plan only validates the file. pnpm test covers the validation and sync logic.

The script refuses to apply a file that would lock everyone out: private, CGNAT and documentation addresses, an empty VPN list, or a blocking rule ahead of a bypass rule. The PUT endpoint overwrites the whole config, so the script copies IP blocks, managed rulesets, OWASP rules, BotID and log headers from the live config. Custom rules added in the dashboard are removed on the next apply; the plan lists them as removals.

Break-glass

If the firewall blocks legitimate traffic, disable the VPN rule from the CLI or restore an earlier config from the dashboard (Firewall > ⋯ > View Audit Log):

vercel firewall rules disable "VPN only" --yes
vercel firewall publish --yes

Then fix vercel.firewall.json: the next firewall:apply restores whatever the file says.

pg_cron schedulers

Campaign loops are scheduled in Supabase, not Vercel, because they are per-campaign rather than global.

Job Calls

Per call campaign

POST /api/campaigns/{id}/broadcast

Per email campaign

POST /api/email-campaigns/{id}/broadcast, at Campaign.schedulerIntervalSeconds (default 30 s)

Both require Authorization: Bearer <INTERNAL_API_SECRET>.

pg_cron also maintains the daily report rollups. These call SQL functions directly rather than HTTP endpoints, so they need no secret:

Job Schedule Calls

refresh-call-daily-stats

every 10 min

refresh_call_daily_stats(today - 3, today)

resync-call-daily-stats

04:20 daily

refresh_call_daily_stats(today - 30, today)

refresh-agent-daily-performance-stats

every 10 min

refresh_agent_daily_performance_stats(today - 3, today)

resync-agent-daily-performance-stats

04:35 daily

refresh_agent_daily_performance_stats(today - 30, today)

refresh-v_email_campaign_stats

hourly, at minute 0

REFRESH MATERIALIZED VIEW CONCURRENTLY v_email_campaign_stats

refresh-v_email_campaign_stats re-aggregates every email campaign target. Its cost grows with that table, not with call history. Email campaign list and detail counts can lag until the next run; campaign settings on those APIs still come from the live Campaign row. If counts look frozen, check cron.job_run_details for a statement_timeout on that job.

The daily rollup jobs recompute a trailing window rather than the whole history, so runtime stays flat as call volume grows. See Daily report rollups.

pg_cron job definitions live in the Supabase project, not in this repository. Rotating INTERNAL_API_SECRET requires updating both the Vercel environment and every job definition in the same window — broadcasts return 401 until they match.

If a report looks stale, check cron.job_run_details before suspecting the route. The predecessors of these jobs (refresh-main-campaign-report and refresh-vm_main_daily_agent_performance_report) ran a full REFRESH MATERIALIZED VIEW whose cost grew with history, and silently failed on the 120 s statement_timeout for a month.

Inngest

Functions are served from /api/inngest and registered by being listed in the serve() call. Inngest is configured to point at the deployment URL and authenticates with INNGEST_SIGNING_KEY.

After adding a function, verify it appears in the Inngest dashboard. A function missing from the functions array is silently never invoked — its events are accepted and dropped.

Supabase Edge Functions

lib/supabase/functions/ holds Deno functions deployed to Supabase separately from the Vercel build: event-logger, campaign-scheduler, hello-world. They are not part of pnpm build.

Migrations

pnpm prisma migrate deploy   # apply pending migrations

Run against DIRECT_URL, not the pooled connection. Never edit an applied migration; add a new one.

Objects managed in Supabase rather than by Prisma — and therefore not applied by migrate deploy:

  • the get_and_lease_due_targets RPC

  • reporting views (vm_main_campaign_reports, main_required_summaries, main_sankey_chart_view, vm_main_daily_agent_performance_report, phone_number_pickup_rate, v_email_campaign_daily_engagement, v_email_campaign_stats)

  • v_email_campaign_stats is a materialized view, scripted in lib/supabase/views/v_email_campaign_stats.sql, refreshed hourly by refresh-v_email_campaign_stats

  • the daily report rollup tables and their refresh functions — call_daily_stats and agent_daily_performance_stats, scripted in lib/supabase/sql/call_daily_stats.sql and lib/supabase/sql/agent_daily_performance_stats.sql

  • the handle_justcall_call_initiated() trigger function

  • the auth_hook_before_user_created_payability auth hook

  • pg_cron job definitions

A new environment needs these created by hand. prisma/sql/ holds the auth hook and the historical backfills.

Deployment checklist

  1. Environment variables set for the target environment, including INTERNAL_API_SECRET, MCP_API_SECRET, RESEND_WEBHOOK_SECRET, HUBSPOT_WEBHOOK_CLIENT_SECRET, TELNYX_PUBLIC_KEY, and ELEVENLABS_WEBHOOK_SECRET.

  2. Firewall applied (pnpm firewall:plan shows no changes) and a dashboard page loads over the VPN.

  3. NEXT_PUBLIC_APP_URL correct — it appears in unsubscribe links.

  4. Migrations applied.

  5. Supabase-managed objects present (RPC, views, trigger, auth hook).

  6. pg_cron jobs pointing at the correct host with the correct secret.

  7. Inngest pointing at <host>/api/inngest, with every function visible in its dashboard.

  8. Provider webhooks pointing at the correct host: Retell → /api/webhooks/event-logger, Resend → /api/webhooks/resend, Nooks → /api/webhooks/nooks, HubSpot → /api/webhooks/hubspot, Telnyx → /api/webhooks/telnyx.

  9. Sentry receiving events — verify with /api/sentry-example-api.