# 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):
```typescript
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):
```typescript
} 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`):**
```typescript
} 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 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):
```typescript
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:
```typescript
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):
```typescript
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):
```typescript
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):
```typescript
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:
```typescript
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):
```sql
-- 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:
```sql
-- 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)
```typescript
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`)
```typescript
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`
```typescript
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
