Cron jobs
The IBA API ships a set of scheduled background jobs: currency-expiry
alerts, payment reminders, and a data-integrity safety net. Each job is a
standalone Node script under api/src/crons/, registered as a row in the
op_cron_configs table. This page covers what runs, why, and how it’s
triggered.
This is the conceptual reference. The auto-generated endpoint schema for the manual trigger lives at API → IBA API → Crons; don’t hand-edit that page — it’s regenerated from the Bruno collection.
How crons run
Scheduling and execution are two decoupled halves:
- Scheduler — GitHub Actions.
.github/workflows/crons.ymlruns on aschedule:(cron) andcurls the API’s trigger endpoint for the due job, authenticating with theCRONS_API_KEYrepo secret. The schedule is in version control — there’s no scheduler box to SSH into. It can also be fired on demand from the Actions tab (Run workflow → pick a job). - Executor — the API.
GET /crons/run/:cron_namelooks the job up inop_cron_configs, runsnode <cron_path>as a child process viaexec()inside the API App Platform container, and writes a row toop_cron_logswhen it finishes.
A third mechanism exists but is dormant: the in-process node-cron
scheduler (api/src/crons/index.js) that cronInit() would start is commented
out in api/index.js. So schedule_pattern in op_cron_configs is
documentation only — the workflow’s cron: lines are the real cadence.
Rollout status (2026-06-01).
reconcile_parent_skillsruns from the workflow. The currency/payment jobs are mid-cutover from the legacy droplet crontab — each moves to the workflow as its droplet line is removed, so until that finishes some still fire from the droplet. Tracking: TUN-783.
History. The scheduler used to be a hand-maintained
crontabon theDev-DB-RedisDigitalOcean droplet thatcurled the same endpoint, with the token in cleartext on the box. The GitHub Actions workflow replaces it: version-controlled, secret-managed, no SSH.
Because each job is launched as node <script>, every handler bootstraps
its own environment (pulling secrets from Infisical when not in production),
guards on IS_PRODUCTION === 'Yes' before it mutates anything, and exits
0 on success / 1 on failure.
At a glance
Cadences below are the workflow’s cron: schedules (UTC). The name is the
value passed to /crons/run/<name>; the currency names carry a _currency
suffix.
Job (name) | What it does | Cadence (GitHub Actions, UTC) | Source |
|---|---|---|---|
reconcile_parent_skills | Safety net that heals parent-skill auto-approval inconsistencies (TNBUGS-1728). | Hourly — 0 * * * * | data-integrity/reconcile-parent-skills |
flyer_currency | Currency-expiry alerts for flyers (regular flying members). Wired to flyer.service.js, not the shared CurrencyCronClass engine. | Daily 08:30 | currency/flyer |
trainer_currency | Currency-expiry alerts for trainers; can disable lapsed accounts. | 07:45 on the 16th of Jan & Dec | currency/trainer |
coach_currency | Same, for coaches. | 08:55 on the 16th of Jan/Jun/Jul/Dec | currency/coach |
instructor_currency | Same, for instructors (extra last-safety-period guard before revoking). | 08:15 on the 16th of Jan/Jun/Jul/Dec | currency/instructors |
military_currency | Same, for military personnel (no manager emails). | 09:55 on the 16th of Jan/Jun/Jul/Dec | currency/military |
payment_reminder | Tiered payment-deadline notices to members. | Daily 06:00 | payment/reminder |
test_cron | No-op fixture for validating the cron harness. | No schedule — manual (workflow_dispatch) only. | test-cron |
Only reconcile_parent_skills is registered via a committed migration
(0003).
The currency and payment rows pre-date the migration system and live
directly in the production op_cron_configs table.
reconcile_parent_skillswas previously dormant. Under the old droplet crontab it had no trigger line (andcronInit()is off), so until the GitHub Actions workflow only the one-shot back-fill in migration0001had ever run. The workflow now schedules it hourly (0 * * * *), closing that gap.
Currency-expiry alerts
The four currency jobs (trainer, coach, instructor, military) share
one engine — CurrencyCronClass in api/src/crons/currency/index.js — and
differ only in the role they target. A fifth currency job, flyer (regular
flying members), is wired separately to flyer.service.js under the newer
api/src/features/shared/currency/ implementation rather than
CurrencyCronClass. For members whose currency is expiring
or has expired, the job sends localised notifications (Mandrill email + app
push) to a fan-out of recipients:
- the member themselves, in their own language;
- their manager(s), grouped by tunnel (military skips this — it has a different approval structure);
- the regional ops team (USA / EU / AU split);
- admin (
info@tunnelflight.com).
It runs in two modes driven by how far out the expiry is: a renewal
reminder ahead of the deadline, and an expired notice on/after it.
When configured to, it also disables the lapsed account. The instructor
variant adds a lastSafetyPeriodActive() check so a still-valid safety
period doesn’t get revoked by accident.
Payment reminders
payment_reminder queries members with upcoming payment deadlines (joining
fees where status is succeeded against fees_mapping where the mapping
is Active), computes days remaining, and sends one of three tiered
notices: 31 days (last month), 7 days (last week), and overdue.
A single run handles all three tiers — the tier is chosen per member from
the days-remaining figure, not from separate schedules.
reconcile_parent_skills
The hourly safety net behind the parent-skill auto-approval work
(TNBUGS-1728). It delegates to ReconciliationService.run(), which heals any
member-upgrade rows left inconsistent, and logs the count it fixed. Like the
others, it only mutates data in production. This is the one cron registered
through a database migration, so it travels
with the code.
Configuration & logging
op_cron_configs— one row per job:name(unique),cron_path(relative toapi/src/crons/),schedule_pattern,is_active.op_cron_logs— one row per run:cron_config_id,start_at,end_at,duration_seconds. Written by the HTTP trigger after the child process exits.- Admin view — run history is surfaced in the admin app’s logs section,
backed by
GET /admin/logs/cron-logs(filterable bycron_name).
To activate or pause a job without a deploy, flip is_active (and ensure the
workflow’s cron: schedule is or isn’t enabled for it).
Triggering a cron manually
curl --request GET \
--url 'https://api.tunnelflight.com/api/crons/run/test_cron' \
--header 'token: <CRONS_API_KEY>'The endpoint authenticates on a token header matched against the
CRONS_API_KEY environment variable. It returns immediately (success: true)
after spawning the child process — it does not wait for the job to
finish, so check op_cron_logs (or the admin view) for the outcome. Full
request/response schema: API → IBA API → Crons.
You can also trigger a job from the crons workflow
with Run workflow (workflow_dispatch) and pick the cron name — handy for
testing without waiting for the schedule.
Gotchas
- The DB schedule isn’t the trigger. While
cronInit()is commented out,schedule_patternis documentation only. The cadence that actually fires a job lives in the workflow’scron:lines (see How crons run), not inop_cron_configs. The two can and do disagree. - Most currency jobs run only twice a year. Per the workflow schedule, the
trainer/coach/instructor/military currency jobs fire on the 16th of a few
specific months (e.g. Jan & Dec for trainers), not continuously. Only
flyer_currencyandpayment_reminderrun daily. If you expected rolling daily currency checks, that is not what’s scheduled. - The trigger token is a secret. The workflow passes
CRONS_API_KEY(a repo secret) in thetoken:header. Rotate it in Infisical and update the GitHub Actions secret together. (The legacy droplet crontab held this token in cleartext — rotate it if that box is still reachable.) - Nothing runs outside production. Every handler exits early unless
IS_PRODUCTION === 'Yes', so triggering one locally or in a preview is a safe no-op (handy for testing the harness withtest_cron). - Fire-and-forget. The HTTP trigger returns before the job completes.
A
success: trueresponse means “started”, not “succeeded”. - Most configs aren’t in the repo. Only
reconcile_parent_skillshas a migration. The currency and payment rows exist only in the production DB, so they won’t appear in a fresh local database.
Assistant crons (separate)
The Assistant app has its own, unrelated cron infrastructure
(a NodeCronScheduler, a JobRegistry, and a token-guarded
POST /api/cron/run/[name] endpoint that logs to its own MySQL table). It
ships with no jobs registered yet — the scheduling machinery is in place
for future phases. It does not share op_cron_configs or the IBA API harness
described above.
See also
- API → IBA API → Crons — generated endpoint schema for the manual trigger.
- Database migrations — how
reconcile_parent_skillsis registered. - GitHub Actions workflows — the other scheduled/automated automation in the platform.