17 KiB
Phase 13: Scheduler & Admin Toggle - Pattern Map
Mapped: 2026-07-11 Files analyzed: 4 modified + 1 new Analogs found: 5 / 5
File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
lib/services/sync-scheduler.ts (edit: executeScheduledSync) |
service (scheduler branch) | event-driven (cron dispatch) | same file, appgate-daily/appgate-sessions branch (lines 445-457) |
exact |
app/api/pax8/sync/route.ts (edit: POST) |
route (controller) | request-response | same file's own GET disabled-agnostic pattern + integration-health.ts's DB-toggle query shape |
role-match (no existing route in this codebase currently gates on the DB toggle — first precedent) |
lib/services/integration-health.ts (edit: checkIntegrationHealth) |
service (config/health check) | CRUD (read-only) | same file, checkConfigOnly('qbo', ...) / checkConfigOnly('appgate', ...) call sites (lines 344-347) |
exact |
migrations/096_pax8_daily_schedule.sql (new) |
migration | batch (idempotent seed) | migrations/089_appgate_tables.sql lines 136-150 |
exact |
(reference only) lib/services/pax8-factory.ts |
service (factory) | config check | already exists — no changes needed, just cite isPax8Configured() |
n/a — read for exact env var names |
Pattern Assignments
lib/services/sync-scheduler.ts (service, event-driven cron dispatch)
Analog: same file, appgate-sessions/appgate-daily branch and engagement-daily branch, inside executeScheduledSync (private method starting line 385).
Sync-type union to extend (line 25):
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary' | 'ticket-digest-daily' | 'ticket-digest-weekly' | 'ticket-digest-monthly' | 'device-link-reconcile' | 'integration-health' | 'qbo' | 'appgate-sessions' | 'appgate-daily' | 'tickets-reconcile';
Add 'pax8-daily' to this union (this is the ONLY place the type needs extending before the branch below will type-check).
Core pattern to copy — dual-guard branch (lines 445-457, the appgate-sessions/appgate-daily branch is the direct template for guard style; combine with the integration-health.ts DB toggle query shape from getDbDisabledKeys, lines 295-308):
} else if (config.sync_type === 'appgate-sessions' || config.sync_type === 'appgate-daily') {
const { isAppgateConfigured } = await import('@/lib/services/appgate-factory');
if (!isAppgateConfigured()) {
console.log(`[SCHEDULER] Skipping ${config.sync_type} — AppGate not configured`);
} else {
const { getAppgateSyncService } = await import('@/lib/services/appgate-sync-service');
const svc = getAppgateSyncService();
if (config.sync_type === 'appgate-daily') {
await svc.dailySync('scheduled');
} else {
await svc.sessionsSync('scheduled');
}
}
}
New pax8-daily branch — write it as (per D-01, D-03, using the lazy-import style of the appgate branch plus an inline DB toggle check modeled on getDbDisabledKeys's query, lines 295-308 of integration-health.ts):
} else if (config.sync_type === 'pax8-daily') {
const { isPax8Configured } = await import('@/lib/services/pax8-factory');
if (!isPax8Configured()) {
console.log('[SCHEDULER] Skipping pax8-daily — PAX8 not configured');
} else {
const disabledRes = await postgresClient.query<{ disabled: boolean }>(
`SELECT disabled FROM integration_settings WHERE key = 'pax8'`
);
const isDisabled = disabledRes.rows[0]?.disabled === true;
if (isDisabled) {
console.log('[SCHEDULER] Skipping pax8-daily — PAX8 disabled via /admin/integrations');
} else {
const { getPax8SyncService } = await import('@/lib/services/pax8-sync-service');
await getPax8SyncService().fullSync('scheduled');
}
}
}
Notes:
postgresClientis already imported at the top ofsync-scheduler.ts(line 8) — no new import needed for the query itself, matching howlast_run/last_statusupdates already run inline (lines 399-402, 471-476).- Do NOT touch
getDbDisabledKeys()/applyDisableOverlay()inintegration-health.ts— those gate/admin/integrationsdisplay only. D-01 is explicit that this is a separate, PAX8-only inline check; don't try to share a helper across both files for this phase. - Insert this new
else ifbranch anywhere among the existing branches before the finalelsefallback (line 466) — ordering doesn't matter, but placing it after theappgate-*branch (after line 457) keeps related integration-toggle blocks together. Pax8SyncService.fullSync()signature confirmed atlib/services/pax8-sync-service.tsline 74:async fullSync(triggeredBy = 'manual'): Promise<Pax8SyncResult>— call with'scheduled'exactly like every other scheduler branch does (svc.dailySync('scheduled'),this.syncService.incrementalSync('scheduled'), etc).
Where NOT to seed the schedule row — defaultSchedules array (lines ~180-310, e.g. the engagement-daily entry at lines 231-237) only fires via createDefaultSchedules() on a virgin table (comment context: "already exist, skipping defaults"). Do not add a pax8-daily entry to this in-code array — the seed belongs solely in the new migration (see below), per CONTEXT.md's explicit precedent note and the same reasoning documented in migrations/090_ticket_reconcile_schedule.sql's own header comment ("only seeds defaults on a virgin sync_schedules table; this migration covers existing installs").
app/api/pax8/sync/route.ts (route/controller, request-response)
Analog: same file's existing POST handler (full file is only 54 lines — read in full above) + the DB-toggle query shape from integration-health.ts's getDbDisabledKeys() (lines 295-308).
Current POST handler (lines 5-20):
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const triggeredBy = body.triggeredBy || 'manual';
const svc = getPax8SyncService();
if (svc.isSyncInProgress()) {
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 });
}
// Fire and forget — return immediately, sync runs in background
svc.fullSync(triggeredBy).catch(err =>
console.error('[Pax8Sync] Background sync error:', err.message)
);
return NextResponse.json({ ok: true, message: 'PAX8 sync started' });
}
Required addition (D-02) — insert a disabled-check before the isSyncInProgress() check, returning 403:
export async function POST(req: NextRequest) {
const disabledRes = await postgresClient.query<{ disabled: boolean }>(
`SELECT disabled FROM integration_settings WHERE key = 'pax8'`
);
if (disabledRes.rows[0]?.disabled === true) {
return NextResponse.json(
{ error: 'PAX8 is disabled', message: 'PAX8 sync is disabled via /admin/integrations' },
{ status: 403 }
);
}
const body = await req.json().catch(() => ({}));
const triggeredBy = body.triggeredBy || 'manual';
const svc = getPax8SyncService();
if (svc.isSyncInProgress()) {
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 });
}
// Fire and forget — return immediately, sync runs in background
svc.fullSync(triggeredBy).catch(err =>
console.error('[Pax8Sync] Background sync error:', err.message)
);
return NextResponse.json({ ok: true, message: 'PAX8 sync started' });
}
Notes:
postgresClientis already imported in this file (line 3) as a default export — reuse it, no new import.- Error/status conventions match project-wide
NextResponse.json({ error, message }, { status })shape (CLAUDE.md "API routes" section, and mirrored by every other route in this file). - No existing route in the codebase currently checks
integration_settings.disabledto gate an action (all current uses of that table are display-only, perintegration-health.ts'sapplyDisableOverlay) — this is the first precedent per D-01's explicit note. The query shape to copy is stillgetDbDisabledKeys()'sSELECT ... FROM integration_settings WHERE ...(lines 295-308 ofintegration-health.ts), just scoped withWHERE key = 'pax8'instead ofWHERE disabled = true.
lib/services/integration-health.ts (service, CRUD/read-only health check)
Analog: same file — checkConfigOnly('qbo', ...) and checkConfigOnly('appgate', ...) call sites.
checkConfigOnly helper (lines 238-252, unchanged, just being called with new args):
function checkConfigOnly(
key: string,
name: string,
category: IntegrationHealth['category'],
envVars: string[]
): IntegrationHealth {
const checkedAt = new Date().toISOString();
const allSet = envVars.every((v) => !!process.env[v]);
return {
key, name, category,
status: allSet ? 'unknown' : 'not_configured',
configured: allSet,
checkedAt,
};
}
Direct template call sites to copy (lines 344-347):
Promise.resolve(checkConfigOnly('qbo', 'QuickBooks Online', 'finance',
['QBO_CLIENT_ID', 'QBO_CLIENT_SECRET'])),
Promise.resolve(checkConfigOnly('appgate', 'AppGate SDP', 'security',
['APPGATE_URL', 'APPGATE_USERNAME', 'APPGATE_PASSWORD', 'APPGATE_DEVICE_ID'])),
New call site to add inside the Promise.all([...]) array in checkIntegrationHealth() (anywhere among the existing entries, e.g. immediately after the appgate line):
Promise.resolve(checkConfigOnly('pax8', 'PAX8', 'finance',
['PAX8_CLIENT_ID', 'PAX8_CLIENT_SECRET'])),
Env var names confirmed exact from lib/services/pax8-factory.ts lines 5-7:
export function isPax8Configured(): boolean {
return Boolean(process.env.PAX8_CLIENT_ID && process.env.PAX8_CLIENT_SECRET);
}
categorypicked as'finance'to matchqbo(billing/subscription data) since theIntegrationHealth['category']union (line 36) has nomarketplace/vendoroption:'psa' | 'rmm' | 'docs' | 'security' | 'backup' | 'network' | 'identity' | 'mdm' | 'mail' | 'finance' | 'productivity' | 'llm'. If the planner wants a different category, it must be added to this union first — flagging as a discretion point, not a hard requirement.- No changes needed to
getDbDisabledKeys()(lines 295-308) orapplyDisableOverlay()(lines 310-319) — adding thecheckConfigOnly('pax8', ...)call site automatically makes PAX8 flow through the existing disable-overlay logic for/admin/integrationsdisplay, sinceapplyDisableOverlayoperates generically onitem.keyacross all results.
migrations/096_pax8_daily_schedule.sql (migration, batch/idempotent seed)
Analog: migrations/089_appgate_tables.sql lines 136-150 (exact precedent named by CONTEXT.md).
Exact precedent to mirror (089_appgate_tables.sql lines 136-150):
-- Scheduler entries — disabled by default until credentials configured.
-- sync_schedules has no unique constraint on name, so guard with NOT EXISTS.
INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, is_enabled)
SELECT 'appgate-sessions',
'AppGate Sessions',
'Active session snapshot every 5 minutes during business hours.',
'*/5 11-23 * * 1-5', 'appgate-sessions', false
WHERE NOT EXISTS (SELECT 1 FROM sync_schedules WHERE name = 'AppGate Sessions');
INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, is_enabled)
SELECT 'appgate-daily',
'AppGate Daily',
'Full AppGate sync — devices, appliances, license, login totals.',
'15 6 * * *', 'appgate-daily', false
WHERE NOT EXISTS (SELECT 1 FROM sync_schedules WHERE name = 'AppGate Daily');
New migration content (migrations/096_pax8_daily_schedule.sql), applying D-04's cron (0 4 * * *) and is_enabled: false per the Claude's Discretion note:
-- Migration 096: Seed the pax8-daily sync schedule.
--
-- The sync_scheduler.createDefaultSchedules() path only seeds defaults on a
-- virgin sync_schedules table; this migration covers existing installs.
-- sync_schedules has no unique constraint on name, so guard with NOT EXISTS
-- (same pattern as migration 089's appgate-sessions/appgate-daily seeds).
INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, is_enabled)
SELECT 'pax8-daily',
'PAX8 Daily Sync',
'Full PAX8 sync — companies, subscriptions, products, orders, and company matching, daily at 4 AM.',
'0 4 * * *', 'pax8-daily', false
WHERE NOT EXISTS (SELECT 1 FROM sync_schedules WHERE name = 'PAX8 Daily Sync');
Notes:
- Confirmed via
grepthat nomigrations/*.sqlfile definesCREATE TABLE sync_schedulesin this repo snapshot (it predates the numbered migrations directory or lives in an earlier init script) — theINSERTcolumn list (id, name, description, cron_expression, sync_type, is_enabled) is taken directly from both the 089 and 090 precedents, which is sufficient; do not attempt to re-derive the table schema. - An alternate style exists in
migrations/090_ticket_reconcile_schedule.sql(ON CONFLICT (id) DO NOTHINGinstead ofWHERE NOT EXISTS) — CONTEXT.md explicitly directs following 089'sWHERE NOT EXISTSstyle, not 090's, so use the above. - Next migration number confirmed as
096— highest existing file is095_pax8_order_items_partner_cost_numeric.sql.
Shared Patterns
DB-backed disable-toggle query shape
Source: lib/services/integration-health.ts, getDbDisabledKeys() (lines 295-308)
async function getDbDisabledKeys(): Promise<Set<string>> {
const { default: postgresClient } = await import('@/lib/services/postgres-client');
try {
const res = await postgresClient.query<{ key: string }>(
`SELECT key FROM integration_settings WHERE disabled = true`,
);
return new Set(res.rows.map((r) => r.key));
} catch {
return new Set();
}
}
Apply to: both the new sync-scheduler.ts pax8-daily branch and the new app/api/pax8/sync/route.ts POST disabled-check — both need SELECT disabled FROM integration_settings WHERE key = 'pax8' (single-row form of this same query), scoped to one key rather than aggregating all disabled keys, since both call sites only care about PAX8.
Lazy dynamic import for integration modules inside executeScheduledSync
Source: lib/services/sync-scheduler.ts lines 434 (device-link-reconciler), 440 (integration-health-alerts), 446/450 (appgate-factory/appgate-sync-service), 459 (ticket-reconciliation-service)
const { isAppgateConfigured } = await import('@/lib/services/appgate-factory');
Apply to: the new pax8-daily branch — import both isPax8Configured from pax8-factory and getPax8SyncService from pax8-sync-service lazily inside the branch, matching every other recently-added branch in this switch (not the older top-of-file static imports like isMsgraphConfigured/isZoomConfigured, which predate this convention).
API route error/status conventions
Source: CLAUDE.md "API routes" section + every existing route in app/api/pax8/sync/route.ts
return NextResponse.json({ error, message }, { status });
Apply to: the new 403 response in app/api/pax8/sync/route.ts's POST handler — use { error: 'PAX8 is disabled', message: '...' } with status: 403, matching the existing 409 response's shape ({ error: 'Sync already in progress' }) in the same file.
Idempotent migration seeding for sync_schedules
Source: migrations/089_appgate_tables.sql (lines 136-150), reinforced by migrations/090_ticket_reconcile_schedule.sql
Apply to: migrations/096_pax8_daily_schedule.sql — always seed new integration schedules is_enabled: false, guarded by WHERE NOT EXISTS (SELECT 1 FROM sync_schedules WHERE name = '...') (089's style, the one CONTEXT.md calls out explicitly), never relying on the in-code defaultSchedules array for existing installs.
No Analog Found
None — all four in-scope files have a direct or near-direct analog in the current codebase (see table above). The one true novelty is that D-01 makes PAX8 the first integration where a DB toggle gates a scheduler/route action rather than just health-check display; there is no prior code to copy for that specific gating behavior, only the query shape (getDbDisabledKeys()) to adapt.
Metadata
Analog search scope: lib/services/sync-scheduler.ts, lib/services/integration-health.ts, lib/services/pax8-factory.ts, lib/services/pax8-sync-service.ts, app/api/pax8/sync/route.ts, migrations/089_appgate_tables.sql, migrations/090_ticket_reconcile_schedule.sql
Files scanned: 7
Pattern extraction date: 2026-07-11