wulf-pulse/.planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md

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:

  • postgresClient is already imported at the top of sync-scheduler.ts (line 8) — no new import needed for the query itself, matching how last_run/last_status updates already run inline (lines 399-402, 471-476).
  • Do NOT touch getDbDisabledKeys()/applyDisableOverlay() in integration-health.ts — those gate /admin/integrations display 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 if branch anywhere among the existing branches before the final else fallback (line 466) — ordering doesn't matter, but placing it after the appgate-* branch (after line 457) keeps related integration-toggle blocks together.
  • Pax8SyncService.fullSync() signature confirmed at lib/services/pax8-sync-service.ts line 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 rowdefaultSchedules 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:

  • postgresClient is 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.disabled to gate an action (all current uses of that table are display-only, per integration-health.ts's applyDisableOverlay) — this is the first precedent per D-01's explicit note. The query shape to copy is still getDbDisabledKeys()'s SELECT ... FROM integration_settings WHERE ... (lines 295-308 of integration-health.ts), just scoped with WHERE key = 'pax8' instead of WHERE 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);
}
  • category picked as 'finance' to match qbo (billing/subscription data) since the IntegrationHealth['category'] union (line 36) has no marketplace/vendor option: '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) or applyDisableOverlay() (lines 310-319) — adding the checkConfigOnly('pax8', ...) call site automatically makes PAX8 flow through the existing disable-overlay logic for /admin/integrations display, since applyDisableOverlay operates generically on item.key across 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 grep that no migrations/*.sql file defines CREATE TABLE sync_schedules in this repo snapshot (it predates the numbered migrations directory or lives in an earlier init script) — the INSERT column 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 NOTHING instead of WHERE NOT EXISTS) — CONTEXT.md explicitly directs following 089's WHERE NOT EXISTS style, not 090's, so use the above.
  • Next migration number confirmed as 096 — highest existing file is 095_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