docs(13): create phase plan

This commit is contained in:
lorentz 2026-07-11 09:27:09 -04:00
parent 424c7c85fb
commit f70a4a36ed
4 changed files with 286 additions and 9 deletions

View file

@ -2,14 +2,14 @@
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v2.0 milestone: v2.0
milestone_name: PAX8 Integration milestone_name: PAX8 Integration
status: planning status: executing
stopped_at: Phase 13 context gathered stopped_at: Phase 13 context gathered
last_updated: "2026-07-11T12:20:34.720Z" last_updated: "2026-07-11T13:26:58.148Z"
last_activity: 2026-07-11 last_activity: 2026-07-11 -- Phase 13 planning complete
progress: progress:
total_phases: 5 total_phases: 5
completed_phases: 3 completed_phases: 3
total_plans: 11 total_plans: 14
completed_plans: 11 completed_plans: 11
percent: 60 percent: 60
--- ---
@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-07-10)
Phase: 13 Phase: 13
Plan: Not started Plan: Not started
Status: Ready to plan Status: Ready to execute
Last activity: 2026-07-11 Last activity: 2026-07-11 -- Phase 13 planning complete
Progress: [░░░░░░░░░░] 0% Progress: [░░░░░░░░░░] 0%

View file

@ -12,10 +12,12 @@ autonomous: true
requirements: [PAX8-07, PAX8-09] requirements: [PAX8-07, PAX8-09]
must_haves: must_haves:
truths: 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')" - "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" - "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: artifacts:
- path: "migrations/096_pax8_daily_schedule.sql" - path: "migrations/096_pax8_daily_schedule.sql"
provides: "Idempotent seed of the pax8-daily sync_schedules row" provides: "Idempotent seed of the pax8-daily sync_schedules row"

View file

@ -13,7 +13,7 @@ must_haves:
truths: truths:
- "PAX8 appears as a row (key 'pax8', name 'PAX8', category 'finance') in checkIntegrationHealth output, making it toggleable on /admin/integrations" - "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" - "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" - "POST /api/pax8/sync behaves unchanged (starts sync / 409 if in progress) when PAX8 is not disabled"
artifacts: artifacts:
- path: "lib/services/integration-health.ts" - path: "lib/services/integration-health.ts"

View file

@ -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<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