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 |
|---|---|---|
|
Daily 00:00 UTC |
|
|
Daily 00:00 UTC |
Zeroes |
|
Monday 11:15 UTC |
|
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 |
|
Allow machine routes |
|
|
VPN only |
Client IP is not a VPN exit IP: |
|
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 |
|
Per email campaign |
|
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 |
|---|---|---|
|
every 10 min |
|
|
04:20 daily |
|
|
every 10 min |
|
|
04:35 daily |
|
|
hourly, at minute 0 |
|
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.
|
|
|
If a report looks stale, check |
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_targetsRPC -
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_statsis a materialized view, scripted inlib/supabase/views/v_email_campaign_stats.sql, refreshed hourly byrefresh-v_email_campaign_stats -
the daily report rollup tables and their refresh functions —
call_daily_statsandagent_daily_performance_stats, scripted inlib/supabase/sql/call_daily_stats.sqlandlib/supabase/sql/agent_daily_performance_stats.sql -
the
handle_justcall_call_initiated()trigger function -
the
auth_hook_before_user_created_payabilityauth hook -
pg_cronjob definitions
A new environment needs these created by hand. prisma/sql/ holds the auth hook and the historical
backfills.
Deployment checklist
-
Environment variables set for the target environment, including
INTERNAL_API_SECRET,MCP_API_SECRET,RESEND_WEBHOOK_SECRET,HUBSPOT_WEBHOOK_CLIENT_SECRET,TELNYX_PUBLIC_KEY, andELEVENLABS_WEBHOOK_SECRET. -
Firewall applied (
pnpm firewall:planshows no changes) and a dashboard page loads over the VPN. -
NEXT_PUBLIC_APP_URLcorrect — it appears in unsubscribe links. -
Migrations applied.
-
Supabase-managed objects present (RPC, views, trigger, auth hook).
-
pg_cronjobs pointing at the correct host with the correct secret. -
Inngest pointing at
<host>/api/inngest, with every function visible in its dashboard. -
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. -
Sentry receiving events — verify with
/api/sentry-example-api.