From f70a4a36ed10f838507f7677247f2527b6d56474 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 11 Jul 2026 09:27:09 -0400 Subject: [PATCH] docs(13): create phase plan --- .planning/STATE.md | 12 +- .../13-scheduler-admin-toggle/13-01-PLAN.md | 6 +- .../13-scheduler-admin-toggle/13-02-PLAN.md | 2 +- .../13-scheduler-admin-toggle/13-PATTERNS.md | 275 ++++++++++++++++++ 4 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 .planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 5b2e6cf..c080b00 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,14 @@ gsd_state_version: 1.0 milestone: v2.0 milestone_name: PAX8 Integration -status: planning +status: executing stopped_at: Phase 13 context gathered -last_updated: "2026-07-11T12:20:34.720Z" -last_activity: 2026-07-11 +last_updated: "2026-07-11T13:26:58.148Z" +last_activity: 2026-07-11 -- Phase 13 planning complete progress: total_phases: 5 completed_phases: 3 - total_plans: 11 + total_plans: 14 completed_plans: 11 percent: 60 --- @@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-07-10) Phase: 13 Plan: Not started -Status: Ready to plan -Last activity: 2026-07-11 +Status: Ready to execute +Last activity: 2026-07-11 -- Phase 13 planning complete Progress: [░░░░░░░░░░] 0% diff --git a/.planning/phases/13-scheduler-admin-toggle/13-01-PLAN.md b/.planning/phases/13-scheduler-admin-toggle/13-01-PLAN.md index 82c89a9..7d18582 100644 --- a/.planning/phases/13-scheduler-admin-toggle/13-01-PLAN.md +++ b/.planning/phases/13-scheduler-admin-toggle/13-01-PLAN.md @@ -12,10 +12,12 @@ autonomous: true requirements: [PAX8-07, PAX8-09] must_haves: truths: - - "A pax8-daily row exists in sync_schedules (cron 0 4 * * *, is_enabled false by default)" + - "A pax8-daily row exists in sync_schedules (cron 0 4 * * *, is_enabled false by default) (D-04)" - "The scheduler's executeScheduledSync dispatches sync_type 'pax8-daily' to getPax8SyncService().fullSync('scheduled')" - - "When integration_settings.key='pax8' has disabled=true, the pax8-daily branch skips fullSync and logs a distinct message" + - "When integration_settings.key='pax8' has disabled=true, the pax8-daily branch skips fullSync and logs a distinct message; this is a PAX8-only inline check with no shared helper (D-01)" - "When PAX8 env credentials are absent, the pax8-daily branch skips fullSync and logs a distinct 'not configured' message" + - "A sync already in progress when PAX8 is disabled runs to completion — the disabled flag only blocks the next scheduled tick, no mid-flight cancellation is added (D-03)" + - "A failed pax8-daily run sets sync_schedules.last_status='failed'/last_error only — no new alert/notification path is added (D-05)" artifacts: - path: "migrations/096_pax8_daily_schedule.sql" provides: "Idempotent seed of the pax8-daily sync_schedules row" diff --git a/.planning/phases/13-scheduler-admin-toggle/13-02-PLAN.md b/.planning/phases/13-scheduler-admin-toggle/13-02-PLAN.md index d796b24..1afabde 100644 --- a/.planning/phases/13-scheduler-admin-toggle/13-02-PLAN.md +++ b/.planning/phases/13-scheduler-admin-toggle/13-02-PLAN.md @@ -13,7 +13,7 @@ must_haves: truths: - "PAX8 appears as a row (key 'pax8', name 'PAX8', category 'finance') in checkIntegrationHealth output, making it toggleable on /admin/integrations" - "A disabled PAX8 row flows through the existing applyDisableOverlay so its disabled state is reflected on /admin/integrations without any change to the overlay logic" - - "POST /api/pax8/sync returns HTTP 403 when integration_settings.key='pax8' has disabled=true" + - "POST /api/pax8/sync returns HTTP 403 when integration_settings.key='pax8' has disabled=true — no side door via the manual route while the toggle says off (D-02)" - "POST /api/pax8/sync behaves unchanged (starts sync / 409 if in progress) when PAX8 is not disabled" artifacts: - path: "lib/services/integration-health.ts" diff --git a/.planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md b/.planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md new file mode 100644 index 0000000..fb40059 --- /dev/null +++ b/.planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md @@ -0,0 +1,275 @@ +# 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` — 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> { + 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