docs(10): record planning completion in STATE.md, add pattern map
This commit is contained in:
parent
40e17aa4b6
commit
8b975be700
2 changed files with 431 additions and 6 deletions
|
|
@ -2,14 +2,14 @@
|
|||
gsd_state_version: 1.0
|
||||
milestone: v2.0
|
||||
milestone_name: PAX8 Integration
|
||||
status: planning
|
||||
status: executing
|
||||
stopped_at: Phase 10 context gathered
|
||||
last_updated: "2026-07-10T16:18:11.364Z"
|
||||
last_activity: 2026-07-10 — v2.0 ROADMAP.md created, Phases 10-14 defined
|
||||
last_updated: "2026-07-10T17:27:42.850Z"
|
||||
last_activity: 2026-07-10 -- Phase 10 planning complete
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 0
|
||||
total_plans: 0
|
||||
total_plans: 3
|
||||
completed_plans: 0
|
||||
percent: 0
|
||||
---
|
||||
|
|
@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-07-10)
|
|||
|
||||
Phase: 10 of 14 (PAX8 Client & Auth Foundation)
|
||||
Plan: — (not yet planned)
|
||||
Status: Roadmap approved, ready to plan Phase 10
|
||||
Last activity: 2026-07-10 — v2.0 ROADMAP.md created, Phases 10-14 defined
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-07-10 -- Phase 10 planning complete
|
||||
|
||||
Progress: [░░░░░░░░░░] 0%
|
||||
|
||||
|
|
|
|||
425
.planning/phases/10-pax8-client-auth-foundation/10-PATTERNS.md
Normal file
425
.planning/phases/10-pax8-client-auth-foundation/10-PATTERNS.md
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
# Phase 10: PAX8 Client & Auth Foundation - Pattern Map
|
||||
|
||||
**Mapped:** 2026-07-10
|
||||
**Files analyzed:** 4 (2 optional test files also flagged, see below)
|
||||
**Analogs found:** 4 / 4
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|--------------------|------|-----------|-----------------|----------------|
|
||||
| `lib/services/pax8-client.ts` | service (integration client) | request-response (OAuth2 client-credentials + REST) | `lib/services/msgraph-client.ts` | exact |
|
||||
| `lib/services/pax8-factory.ts` | service (factory/singleton) | request-response (config check + lazy init) | `lib/services/msgraph-factory.ts` (secondary: `lib/services/appgate-factory.ts`) | exact |
|
||||
| `lib/types/pax8.ts` | model (types barrel) | transform (API shape → typed interfaces) | `lib/types/appgate.ts` | exact |
|
||||
| `migrations/091_pax8_tables.sql` | migration | CRUD (schema DDL) | `migrations/089_appgate_tables.sql` (table style) + `migrations/080_device_xref_company_id.sql` (review-queue shape) | exact |
|
||||
| `lib/services/pax8-client.test.ts` (optional, RESEARCH-recommended) | test | request-response (mocked `fetch`) | `lib/services/llm/call.test.ts` (mocking pattern only — no existing `*-client.test.ts` precedent in this codebase) | role-match (no exact precedent exists) |
|
||||
| `lib/services/pax8-factory.test.ts` (optional, RESEARCH-recommended) | test | request-response (env-var config check) | none — no `*-factory.test.ts` exists anywhere in the codebase | no analog |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `lib/services/pax8-client.ts` (service, request-response)
|
||||
|
||||
**Analog:** `lib/services/msgraph-client.ts` (OAuth2 client-credentials shape), with two required deviations from RESEARCH.md's Pitfall 2/3 — PAX8's token body is JSON (not form-encoded) and requires an `audience` field.
|
||||
|
||||
**Imports/class shape pattern** (`lib/services/msgraph-client.ts` lines 55-68):
|
||||
```typescript
|
||||
export interface MsGraphClientConfig {
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export class MsGraphClient {
|
||||
private config: MsGraphClientConfig;
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor(config: MsGraphClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
```
|
||||
For PAX8: `Pax8ClientConfig { clientId: string; clientSecret: string }` (no tenant concept). Type import convention should follow `appgate-client.ts` line 24-33 (`import type { ... } from '@/lib/types/appgate'`) — i.e. `pax8-client.ts` should `import type { Pax8Company, Pax8Subscription, ... } from '@/lib/types/pax8'` rather than declaring response interfaces inline, since a dedicated `lib/types/pax8.ts` file is already planned.
|
||||
|
||||
**Token exchange + expiry-aware cache pattern** (`lib/services/msgraph-client.ts` lines 70-98):
|
||||
```typescript
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
scope: 'https://graph.microsoft.com/.default',
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Graph token request failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.accessToken = data.access_token;
|
||||
this.tokenExpiry = Date.now() + data.expires_in * 1000;
|
||||
return this.accessToken!;
|
||||
}
|
||||
```
|
||||
**Copy the structure (cache check, expiry math, error-on-!res.ok) but change the body encoding and add `audience`** per RESEARCH.md Pattern 1 / Pitfall 3:
|
||||
```typescript
|
||||
// PAX8 deviation — JSON body + audience field, NOT URLSearchParams
|
||||
const res = await fetch('https://api.pax8.com/v1/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
audience: 'https://api.pax8.com', // NOT api://provisioning — see Pitfall 2
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
**Authenticated fetch + retry-on-429 pattern** (`lib/services/msgraph-client.ts` lines 100-122):
|
||||
```typescript
|
||||
private async fetchJson<T>(path: string, retryCount = 0): Promise<T> {
|
||||
const token = await this.getToken();
|
||||
const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (res.status === 429 && retryCount < 4) {
|
||||
const retryAfter = Math.max(30, parseInt(res.headers.get('Retry-After') || '30', 10));
|
||||
await new Promise(r => setTimeout(r, retryAfter * 1000));
|
||||
return this.fetchJson(path, retryCount + 1);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
```
|
||||
Copy this shape verbatim for `pax8-client.ts`'s internal `fetchJson<T>()`, swapping the base URL to `https://api.pax8.com/v1` — the 429/`Retry-After` handling is directly reusable and matches RESEARCH.md's Pitfall 4 note (PAX8 rate limit is 1000/min account-wide; not exercised by this phase's single auth-proof call, but the retry scaffold costs nothing to include now).
|
||||
|
||||
**Paginated list-endpoint pattern** — no `msgraph-client.ts` equivalent uses PAX8's exact `{ content, page }` envelope (Graph uses `@odata.nextLink`), so this piece has no direct in-repo analog. Use RESEARCH.md's Pattern 3 code example directly:
|
||||
```typescript
|
||||
async listCompanies(page = 0, size = 200): Promise<{ content: Pax8Company[]; totalPages: number }> {
|
||||
const token = await this.getToken();
|
||||
const res = await fetch(`https://api.pax8.com/v1/companies?page=${page}&size=${size}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`PAX8 API error ${res.status} for /companies: ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return { content: data.content, totalPages: data.page.totalPages };
|
||||
}
|
||||
```
|
||||
This phase only needs one auth-proof call (`listCompanies`) — do not build a generic pagination-looping utility (RESEARCH.md's "Don't Hand-Roll" table explicitly rejects this).
|
||||
|
||||
**Error handling pattern:** Every method in `msgraph-client.ts` follows `if (!res.ok) { const text = await res.text(); throw new Error(...) }` — no custom error classes, no try/catch wrapper inside the client itself (callers catch). Apply identically in `pax8-client.ts`. Never interpolate `config.clientSecret` into any thrown message or `console.log` (RESEARCH.md Security Domain — Information Disclosure row).
|
||||
|
||||
---
|
||||
|
||||
### `lib/services/pax8-factory.ts` (service, request-response)
|
||||
|
||||
**Analog:** `lib/services/msgraph-factory.ts` (primary — same 3-var config shape) and `lib/services/appgate-factory.ts` (secondary — most recently added integration, same JSDoc-header + reset-seam convention).
|
||||
|
||||
**Full pattern** (`lib/services/msgraph-factory.ts` lines 1-43, adapt var names/count):
|
||||
```typescript
|
||||
import { MsGraphClient, MsGraphClientConfig } from './msgraph-client';
|
||||
|
||||
let msGraphClientInstance: MsGraphClient | null = null;
|
||||
|
||||
export function isMsgraphConfigured(): boolean {
|
||||
return !!(
|
||||
process.env.MSGRAPH_CLIENT_ID &&
|
||||
process.env.MSGRAPH_CLIENT_SECRET &&
|
||||
process.env.MSGRAPH_TENANT_ID
|
||||
);
|
||||
}
|
||||
|
||||
export function getMsgraphClient(): MsGraphClient {
|
||||
if (!msGraphClientInstance) {
|
||||
const config: MsGraphClientConfig = {
|
||||
tenantId: process.env.MSGRAPH_TENANT_ID || '',
|
||||
clientId: process.env.MSGRAPH_CLIENT_ID || '',
|
||||
clientSecret: process.env.MSGRAPH_CLIENT_SECRET || '',
|
||||
};
|
||||
|
||||
if (!config.tenantId || !config.clientId || !config.clientSecret) {
|
||||
throw new Error(
|
||||
'Microsoft Graph credentials missing. Set MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, and MSGRAPH_TENANT_ID.'
|
||||
);
|
||||
}
|
||||
|
||||
msGraphClientInstance = new MsGraphClient(config);
|
||||
console.log('[MSGRAPH] Client initialized');
|
||||
}
|
||||
|
||||
return msGraphClientInstance;
|
||||
}
|
||||
|
||||
export function resetMsgraphClient(): void {
|
||||
msGraphClientInstance = null;
|
||||
}
|
||||
```
|
||||
PAX8 only has 2 env vars (`PAX8_CLIENT_ID`, `PAX8_CLIENT_SECRET` — no tenant), so `appgate-factory.ts`'s tighter `Boolean(a && b)` early-return style (lines 19-42, reproduced below) is the better literal template since it's closer to PAX8's 2-var shape and uses the `_reset` naming convention RESEARCH.md's own code example (Pattern 2) already committed to:
|
||||
```typescript
|
||||
import { AppgateClient } from './appgate-client';
|
||||
|
||||
let _client: AppgateClient | null = null;
|
||||
|
||||
export function isAppgateConfigured(): boolean {
|
||||
return Boolean(
|
||||
process.env.APPGATE_URL &&
|
||||
process.env.APPGATE_USERNAME &&
|
||||
process.env.APPGATE_PASSWORD &&
|
||||
process.env.APPGATE_DEVICE_ID,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAppgateClient(): AppgateClient {
|
||||
if (_client) return _client;
|
||||
if (!isAppgateConfigured()) {
|
||||
throw new Error('AppGate is not configured — set APPGATE_URL, APPGATE_USERNAME, APPGATE_PASSWORD, APPGATE_DEVICE_ID');
|
||||
}
|
||||
_client = new AppgateClient({ /* ... */ });
|
||||
return _client;
|
||||
}
|
||||
|
||||
// Test seam — reset the cached client (e.g. after rotating credentials).
|
||||
export function _resetAppgateClient(): void {
|
||||
_client = null;
|
||||
}
|
||||
```
|
||||
**Recommendation:** follow RESEARCH.md's own Pattern 2 code example verbatim (it already merges both templates correctly) — `isPax8Configured()`, `getPax8Client()` throwing `'PAX8 is not configured — set PAX8_CLIENT_ID and PAX8_CLIENT_SECRET'`, and `_resetPax8Client()`.
|
||||
|
||||
---
|
||||
|
||||
### `lib/types/pax8.ts` (model, transform)
|
||||
|
||||
**Analog:** `lib/types/appgate.ts` — same "types barrel per external integration" role, most recently added, uses `[key: string]: unknown` escape hatch for partially-typed vendor payloads.
|
||||
|
||||
**File header + section-comment convention** (`lib/types/appgate.ts` lines 1-10):
|
||||
```typescript
|
||||
/**
|
||||
* Type definitions for the AppGate SDP Controller REST API (v22.5) and the
|
||||
* shapes Pulse persists. Source spec lives at
|
||||
* `https://wawnvaagp01.wulfconsulting.com:8443/api_specs.html`.
|
||||
*
|
||||
* Only the slices Pulse consumes are typed — the API exposes ~150 endpoints,
|
||||
* most of which are administrative writes Pulse never makes.
|
||||
*/
|
||||
|
||||
// ─── API response shapes ──────────────────────────────────────────────────
|
||||
```
|
||||
Adapt to: `PAX8 REST API (v1)` header, cite `https://devx.pax8.com`, keep the `// ─── API response shapes ───` / `// ─── Sync orchestration ───` section-divider convention (lines 10, 115 of `appgate.ts`).
|
||||
|
||||
**Envelope + partial-typing pattern** (`lib/types/appgate.ts` lines 49-70, 109-113):
|
||||
```typescript
|
||||
export interface AppgateAppliance {
|
||||
id: string;
|
||||
name: string;
|
||||
// ...fully-typed known fields...
|
||||
[key: string]: unknown; // escape hatch for fields not yet modeled
|
||||
}
|
||||
|
||||
export interface AppgateResultList<T> {
|
||||
range?: string;
|
||||
totalCount?: number;
|
||||
data: T[];
|
||||
}
|
||||
```
|
||||
For PAX8, define the equivalent pagination envelope generic matching RESEARCH.md's confirmed shape:
|
||||
```typescript
|
||||
export interface Pax8PageEnvelope<T> {
|
||||
content: T[];
|
||||
page: { size: number; totalElements: number; totalPages: number; number: number };
|
||||
}
|
||||
```
|
||||
Type the four entities (`Pax8Company`, `Pax8Subscription`, `Pax8Product`, and the Invoice/InvoiceItem-sourced `Pax8Order`/`Pax8OrderItem` — see RESEARCH.md Pitfall 1 on field-source naming) with known fields typed and `[key: string]: unknown` for the long tail, matching `AppgateAppliance`'s style. Do NOT type every documented PAX8 field exhaustively — `appgate.ts` deliberately types "only the slices Pulse consumes."
|
||||
|
||||
---
|
||||
|
||||
### `migrations/091_pax8_tables.sql` (migration, CRUD)
|
||||
|
||||
**Analogs:** `migrations/089_appgate_tables.sql` (table/index/comment style for the 4 primary entity tables) + `migrations/080_device_xref_company_id.sql` (`device_link_review` — explicit shape reference for `pax8_company_match_review` per CONTEXT.md's Claude's Discretion section).
|
||||
|
||||
**Header comment convention** (`migrations/089_appgate_tables.sql` lines 1-11):
|
||||
```sql
|
||||
-- AppGate SDP integration — Postgres schema.
|
||||
--
|
||||
-- Mirrors the slices of Appgate SDP Controller REST API v22.5 that Pulse
|
||||
-- surfaces for the manager-on-the-go view:
|
||||
--
|
||||
-- • Active sessions (current snapshot) -> appgate_active_sessions
|
||||
-- • On-boarded devices (slowly changing list) -> appgate_devices
|
||||
-- ...
|
||||
```
|
||||
Adapt to a similar bullet list mapping PAX8 entities → table names, and flag inline (per RESEARCH.md Pitfall 1) that `pax8_orders`/`pax8_order_items` columns are sourced from PAX8's Invoice/InvoiceItem objects, not the bare Order/LineItem objects.
|
||||
|
||||
**Primary entity table shape — audit columns + soft-delete + raw JSONB** (`migrations/089_appgate_tables.sql` lines 56-77, closest to a "typed columns + raw payload" table):
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS appgate_appliances (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT,
|
||||
tags JSONB,
|
||||
roles JSONB, -- {controller, gateway, logServer, ...} subset flags
|
||||
raw JSONB, -- full payload — versioned schema changes
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_appgate_appliances_is_deleted ON appgate_appliances(is_deleted);
|
||||
```
|
||||
Use this as the template for `pax8_companies`, `pax8_subscriptions`, `pax8_products` (rename `raw` → `raw_payload` per D-03's explicit naming) — all get `synced_at`, `is_deleted`, `deleted_at` per CLAUDE.md's stated audit-column convention. Add `idx_*_is_deleted` indexes matching this pattern.
|
||||
|
||||
**Monetary + currency columns pattern** (`migrations/051_create_qbo_tables.sql` lines 14-33 — closest existing precedent for NUMERIC + currency on a financial entity, matching D-04 exactly):
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS qbo_invoices (
|
||||
id TEXT PRIMARY KEY, -- QBO Id
|
||||
...
|
||||
total_amt NUMERIC(12,2),
|
||||
balance NUMERIC(12,2),
|
||||
status TEXT, -- Open, Paid, Voided, etc.
|
||||
currency_code TEXT DEFAULT 'USD',
|
||||
line_items JSONB,
|
||||
...
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
D-04 locks `NUMERIC(12,2)` + `currency CHAR(3) DEFAULT 'USD'` (CHAR(3), not TEXT — a slightly stricter type than `qbo_invoices.currency_code TEXT` uses, but the same intent). Apply this to `pax8_orders.total`/`pax8_order_items.unit_price`/`line_total`, sourcing the actual field names from PAX8's Invoice/InvoiceItem schema per RESEARCH.md Pitfall 1 (`amountDue`, `price`, `subTotal`, invoice-level `status`), not the bare Order/LineItem fields which don't carry these.
|
||||
|
||||
**Header/line-item FK relationship pattern** — no existing Pulse migration models a strict header+lines pattern as cleanly as PAX8's own docs describe; RESEARCH.md's migration skeleton (Code Examples section, lines 478-515 of RESEARCH.md) is the most concrete starting point:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS pax8_companies (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
external_id TEXT,
|
||||
website TEXT,
|
||||
status TEXT,
|
||||
raw_payload JSONB,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
```
|
||||
`pax8_order_items` should FK to `pax8_orders(id)` `ON DELETE CASCADE`, mirroring how `migrations/051_create_qbo_tables.sql`'s later tables (payments/credit memos, not shown above) and `device_link_review`'s `ON DELETE CASCADE` (below) both use cascade deletes for child rows.
|
||||
|
||||
**Review-queue table shape** (`migrations/080_device_xref_company_id.sql` lines 62-86 — copy near-verbatim per CONTEXT.md's explicit instruction):
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS device_link_review (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
device_external_id BIGINT NOT NULL REFERENCES device_external_ids(id) ON DELETE CASCADE,
|
||||
candidate_ci_ids BIGINT[] NOT NULL,
|
||||
match_confidences TEXT[] NOT NULL,
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by_user_id TEXT NULL REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
resolved_to_ci_id BIGINT NULL REFERENCES configuration_items(id) ON DELETE SET NULL,
|
||||
resolution_note TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_device_link_review_unresolved
|
||||
ON device_link_review(detected_at DESC) WHERE resolved_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS ix_device_link_review_xref
|
||||
ON device_link_review(device_external_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_device_link_review_open_per_xref
|
||||
ON device_link_review(device_external_id) WHERE resolved_at IS NULL;
|
||||
|
||||
COMMENT ON TABLE device_link_review IS
|
||||
'Reconciler conflicts queue: one row per unlinked device_external_ids row that matches 2+ configuration_items. Admin picks the right CI; reconciler does not auto-merge.';
|
||||
```
|
||||
Map field-for-field per CONTEXT.md: `device_external_id` → `pax8_company_id` (FK to `pax8_companies(id) ON DELETE CASCADE`), `candidate_ci_ids BIGINT[]` → `candidate_company_ids BIGINT[]` (FK target: Autotask `companies.id`), `resolved_to_ci_id` → `resolved_to_company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL`. Keep `match_confidences TEXT[]` exactly as-is (RESEARCH.md Open Question 3 explicitly recommends matching this type rather than a numeric score, to keep the door open for either representation). Keep the three-index pattern (unresolved-queue index, xref lookup index, partial-unique "one open review per source row" index) and the `COMMENT ON TABLE` documentation habit — RESEARCH.md's own skeleton reproduces this table almost verbatim (lines 501-511 of RESEARCH.md), confirming this is the intended template.
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### OAuth2 Client-Credentials Token Cache
|
||||
**Source:** `lib/services/msgraph-client.ts` lines 70-98
|
||||
**Apply to:** `lib/services/pax8-client.ts` (with JSON-body + `audience` deviation per Pitfall 2/3 above)
|
||||
```typescript
|
||||
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
// ...POST to token endpoint...
|
||||
this.accessToken = data.access_token;
|
||||
this.tokenExpiry = Date.now() + data.expires_in * 1000;
|
||||
```
|
||||
|
||||
### Factory Singleton + `is<Name>Configured()` + Throw-If-Missing + Reset Seam
|
||||
**Source:** `lib/services/msgraph-factory.ts` (full file) / `lib/services/appgate-factory.ts` (full file)
|
||||
**Apply to:** `lib/services/pax8-factory.ts`
|
||||
```typescript
|
||||
export function isPax8Configured(): boolean {
|
||||
return Boolean(process.env.PAX8_CLIENT_ID && process.env.PAX8_CLIENT_SECRET);
|
||||
}
|
||||
export function getPax8Client(): Pax8Client {
|
||||
if (_client) return _client;
|
||||
if (!isPax8Configured()) {
|
||||
throw new Error('PAX8 is not configured — set PAX8_CLIENT_ID and PAX8_CLIENT_SECRET');
|
||||
}
|
||||
_client = new Pax8Client({ clientId: process.env.PAX8_CLIENT_ID!, clientSecret: process.env.PAX8_CLIENT_SECRET! });
|
||||
return _client;
|
||||
}
|
||||
export function _resetPax8Client(): void { _client = null; }
|
||||
```
|
||||
|
||||
### Error Handling — Never Interpolate Secrets
|
||||
**Source:** `lib/services/msgraph-client.ts` (every method), `lib/services/appgate-factory.ts` line 31 (error names missing env vars, never values)
|
||||
**Apply to:** All PAX8 files
|
||||
```typescript
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`PAX8 API error ${res.status} for ${path}: ${text}`);
|
||||
}
|
||||
```
|
||||
Never log/throw `config.clientSecret` itself — only name which env var is missing.
|
||||
|
||||
### Migration Audit Columns + Soft Delete
|
||||
**Source:** `migrations/089_appgate_tables.sql` (all tables), `migrations/088_qbo_invoices_soft_delete.sql`, CLAUDE.md's stated convention
|
||||
**Apply to:** All four `pax8_*` primary entity tables in `091_pax8_tables.sql`
|
||||
```sql
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
deleted_at TIMESTAMPTZ
|
||||
```
|
||||
Plus a matching `CREATE INDEX IF NOT EXISTS idx_<table>_is_deleted ON <table>(is_deleted);` per table.
|
||||
|
||||
### Raw-Payload Safety Net (D-03)
|
||||
**Source:** `migrations/089_appgate_tables.sql` line 69 (`raw JSONB — full payload`), CONTEXT.md D-03 citing `itglue-search.ts` precedent
|
||||
**Apply to:** `pax8_orders`, `pax8_order_items` (locked by D-03); recommended default for `pax8_companies`/`pax8_subscriptions`/`pax8_products` per Claude's Discretion section
|
||||
```sql
|
||||
raw_payload JSONB, -- full API payload — safety net for fields not yet modeled
|
||||
```
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `lib/services/pax8-client.test.ts` | test | request-response (mocked `fetch`) | No `*-client.test.ts` exists for any integration client in this codebase (confirmed by RESEARCH.md's grep of the whole tree). `lib/services/llm/call.test.ts` is the closest available *mocking-style* reference (vi.fn() fake + call-count/body assertions) but tests a different layer (Anthropic SDK wrapper, not raw `fetch`). Planner should write this test from RESEARCH.md's Validation Architecture section rather than an in-repo analog. |
|
||||
| `lib/services/pax8-factory.test.ts` | test | request-response (env-var check) | No `*-factory.test.ts` exists anywhere in the codebase. No analog to copy from; use plain vitest `describe`/`it` with `process.env` mutation + `_resetPax8Client()` between tests. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `lib/services/` (integration clients + factories), `lib/types/`, `migrations/`
|
||||
**Files scanned:** `lib/services/msgraph-client.ts`, `lib/services/msgraph-factory.ts`, `lib/services/veeam-factory.ts`, `lib/services/appgate-client.ts`, `lib/services/appgate-factory.ts`, `lib/types/appgate.ts`, `migrations/080_device_xref_company_id.sql`, `migrations/089_appgate_tables.sql`, `migrations/088_qbo_invoices_soft_delete.sql`, `migrations/051_create_qbo_tables.sql`, `lib/services/llm/call.test.ts`
|
||||
**Pattern extraction date:** 2026-07-10
|
||||
Loading…
Add table
Add a link
Reference in a new issue