- Extend ScheduleConfig.sync_type union with 'pax8-daily'
- Add dual-guarded branch in executeScheduledSync: skips with a
distinct log when PAX8 is not configured (isPax8Configured()) or
when integration_settings.key='pax8' is disabled, otherwise calls
getPax8SyncService().fullSync('scheduled')
- PAX8-only inline check per D-01 — no shared helper, no changes to
getDbDisabledKeys()/applyDisableOverlay() or other switch branches
Registers AppGate as a checkConfigOnly integration-health row and public
sync route, matching the existing factory + is<Name>Configured() pattern.
Committed now so Phase 13's worktree-isolated executors fork from a HEAD
that includes this integration-health.ts entry, since Plan 13-02 inserts
the PAX8 row immediately after it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR
- First unit tests for Pax8SyncService, mocking postgresClient.query and
pax8-company-matcher's matchPax8Companies (following
pax8-company-matcher.test.ts's mocking discipline)
- Asserts INSERT INTO pax8_orders / pax8_order_items with companyId bound
into pax8_company_id and amountDue into line_total
- Asserts resolveCostColumns' default (CONFIRM) mapping for both observed
item types (subscription, one-time) per 12-02-SUMMARY.md
- Asserts child-then-parent tombstone ordering (pax8_order_items before
pax8_orders)
- Asserts fullSync's entities include 'orders' and 'company_matches'
No TDD gate — plan is autonomous without a plan-level `type: tdd`
frontmatter; the implementation already existed from Tasks 1-2, so this
follows the same single-commit test-addition pattern established by
12-02-SUMMARY.md's Task 2.
- syncCompanyMatches() delegates to matchPax8Companies() (Plan 03),
shaping its result into the standard Pax8EntitySyncResult
- fullSync() now pushes ordersResult then matchResult after products,
so pax8_companies is fully populated before matching runs
- Both steps roll up into the existing success/status/totals reducer
and sync_history record unchanged
- Adds Pax8SyncService.syncOrders(): pages all invoice headers, then
per-header pages its items (12-RESEARCH.md Pattern 1 nested fetch)
- resolveCostColumns() branches on item.type per 12-02-SUMMARY.md's
live spot-check verdicts (all types CONFIRM -> single default branch,
kept as a named seam for future divergence)
- pax8_orders.pax8_company_id stays NULL (Pitfall 1); per-company data
lives on pax8_order_items.pax8_company_id
- Tombstones child (pax8_order_items) before parent (pax8_orders) to
respect the FK, using the existing id <> ALL($1::uuid[]) pattern
- Ports device-link-reconciler.ts's findBy*/applyLink/recordConflict/
pickBestCandidate shape to a single pg_trgm similarity() score
- AUTO_LINK_THRESHOLD=0.90 (D-01), TIE_MARGIN=0.05 (D-02),
CANDIDATE_FLOOR=0.3, exported and tunable
- decide() implements D-01..D-04: auto-link only on unambiguous
high-confidence match, review with top-3 candidates (or empty array
when none clear the floor)
- applyLink()/recordConflict() guard resolved_at IS NOT NULL and
match_method IS DISTINCT FROM 'manual' (D-05/SC#4 idempotency)
- matchPax8Companies() scans the re-scoring-eligible subset of
pax8_companies and reports scanned/autoLinked/flaggedAmbiguous/
flaggedNoCandidate/durationMs
- listAllInvoices() concatenates pages in order, size=200 on each request
- listAllInvoiceItems(invoiceId) requests the nested /invoices/{id}/items
path and concatenates its pages
- Extends the existing GET-only / Authorization-header assertion to both
new methods (PAX8-08)
- listAllInvoices() pages the flat /invoices header list via paginateAll
- listAllInvoiceItems(invoiceId) pages the nested per-invoice
/invoices/{id}/items child resource
- Both GET-only, reusing the existing paginateAll helper (PAX8-08)
- Note: no /orders call added (12-RESEARCH.md Pitfall 3 — unreliable/504s)
- Log pre-existing unrelated sync-scheduler.ts TS2307 errors to
deferred-items.md (appgate-factory/appgate-sync-service not in this
worktree's git history)
- Replace stale unused Pax8Order/Pax8OrderItem stubs with live-verified
Pax8Invoice (header) and Pax8InvoiceItem (per-company line item) types
- Field shapes sourced from 12-RESEARCH.md live PAX8 API verification
- fullSync() orchestrates companies -> subscriptions -> referenced-only
products, each with independent try/catch returning Pax8EntitySyncResult
- Referenced product catalog resolved in a single pass: listAllProducts()
fetched once, filtered in-memory to subscription-referenced ids (D-01/D-02)
- Each entity's UUID-array tombstone soft-deletes rows PAX8 no longer
returns (is_deleted=true, deleted_at set), skipped when zero ids seen
- sync_history row (entity_type='pax8', sync_type='full') tracks
started/completed/failed with base columns only
- No PAX8 writes: only Pax8Client's read methods are called
- listAllCompanies/listAllSubscriptions/listAllProducts multi-page concat
- size=200 param assertion, single-page no-infinite-loop case
- Assert every request is GET with Authorization: Bearer header
- isPax8Configured(): both PAX8_CLIENT_ID and PAX8_CLIENT_SECRET required
- getPax8Client(): throws exact error naming both env vars when missing;
caches singleton Pax8Client instance
- _resetPax8Client(): test seam to clear the cached singleton
- follows appgate-factory.ts / 10-RESEARCH.md Pattern 2 verbatim
- getToken() JSON-body OAuth2 client-credentials exchange with audience field
(deviates from msgraph-client.ts's form-encoded body per 10-RESEARCH.md Pitfall 3)
- 60s expiry-buffer token cache, reused across calls
- fetchJson<T>() with 429/Retry-After retry copied from msgraph-client.ts
- listCompanies() auth-proof call parsing the {content,page} envelope
- secret never interpolated into any throw/console call
- Broaden mimecast retention from 30 days to 18 months rolling
- Re-enable mimecast-sync schedule (was disabled since March 17)
- Full sync triggered: 35,559 messages loaded for last 30 days
- Users list API: LATERAL join on mimecast_messages for emails_sent/received
- User detail API: add emails{d7,d30,d90} field from mimecast
- Engagement page: prefer mimecast email counts in detail panel sub-label
Graph API has 48-72hr reporting lag; mimecast is same-day
- Add getValidCompanyIds() helper mirroring getValidResourceIds()
- Add a new TICKETS validation block that nullifies ticket.company_id when
the referenced company is not present (is_deleted=false) in the Pulse
mirror, instead of letting tickets_company_id_fkey roll back the
bulkUpsert transaction
- Block runs after the existing recordsWithoutCompany filter and before
the existing resource-FK nullification block (correct ordering)
- Belt-and-suspenders on top of Task 1: covers hard-deleted-in-Autotask
companies that Task 1's widening still won't fetch
- Add buildCompaniesFilter() returning id > 0 in lib/utils/sync-helpers.ts
- Route COMPANIES through buildCompaniesFilter on full sync instead of
the generic buildActiveFilter (which applied isActive=true and missed
inactive companies with tickets, causing tickets_company_id_fkey on
weekly-full and full syncs since 2026-05-15)
- hasAppliedFilters stays true (filter is non-empty), so soft-delete of
companies is not triggered
- sync-scheduler.ts: extend sync_type union with 'tickets-reconcile', add a
default schedule entry (disabled, 30 4 * * *), and a dispatch case using
the device-link-reconcile / integration-health dynamic-import pattern.
- migrations/090_ticket_reconcile_schedule.sql: idempotent INSERT (ON CONFLICT
DO NOTHING) so existing installs pick up the row without disturbing the
fresh-DB default-seed path.
- New lib/services/ticket-reconciliation-service.ts: reconcileStaleTickets()
scans tickets where is_deleted=false AND status<>5 AND synced_at older than
7 days (capped at 500), re-fetches each from Autotask, and either upserts
via the webhook SQL pattern or soft-deletes when Autotask returns null.
- Returns { scanned, updated, statusFlippedToComplete, softDeleted, errors }.
- New POST /api/sync/reconcile-tickets — fire-and-forget trigger mirroring
/api/sync/incremental (public per existing middleware allowlist).
- Add QboPaymentCreatePayload + QboDepositCreatePayload interfaces to lib/types/qbo.ts
- Add createPayment(payload) and createDeposit(payload) public methods to QboClient
- Both methods use existing private this.request<T>() with POST + minorversion=65
- Both methods throw descriptively if QBO returns no Id in response
Two more sites with the same bug as the theme route — Better Auth's "user"
table column is quoted camelCase. Caught via UAT after the theme PUT fix.
- app/api/settings/profile/route.ts:26 (PATCH admin profile name)
- lib/bootstrap.ts:97 (clearSetupFlag — first-login setup wizard)
- lib/services/personal-channels.ts: isValidTeamsWebhookUrl, isValidNtfyTopic,
mintNtfyTopic, sendChannelTest, TEST_MESSAGE_BODY, isPersonalChannelType,
PERSONAL_CHANNEL_TYPES
- GET /api/me/channels: returns user's personal channels (owner_user_id scoped)
- PUT /api/me/channels/[type]: WITH-CTE UPSERT + best-effort test send
- DELETE /api/me/channels/[type]: removes user's channel, 404 if missing
- POST /api/me/channels/[type]/test: re-sends test to existing channel
- SSRF mitigation via Teams URL hostname allowlist (T-09-02-06)
- Race window closed by partial unique index from Plan 01 (T-09-02-10)
- Asserts muted user (enabled=false in user_event_subscriptions) returns
success:true, notified:false, skipped_reason='user_muted'
- Asserts no notification_channels SQL is issued on the mute path
- Asserts no outbound fetch() is called (no personal or global send)
- Asserts user_route_fallback is absent from output (mute != fallback)
- Uses _INTERNALS test seam to call dispatchUserRoute directly
- Behavioral guarantee for D-12/ROUTE-05 enforced by CI, not just grep
- migrations/084_add_user_theme.sql: ALTER TABLE user ADD COLUMN theme TEXT NOT NULL DEFAULT 'system'
- Defensive backfill UPDATE for in-flight NULL rows on managed Postgres
- COMMENT ON COLUMN documents allowed values (light|dark|system)
- lib/auth.ts: adds theme additionalField with defaultValue 'system' after timezone
- session.user.theme now exposed via Better Auth same as session.user.timezone
- New public method fetches binary photo from Graph /users/{id}/photo/$value
- Returns { bytes, contentType } on 200, null on 404 (no photo)
- Throws on other non-2xx for upstream caller to map to 502/503
- Reuses getToken() OAuth2 cache; no retry (best-effort per D-26)
- Existing methods (getToken, fetchJson, getUsers, etc.) untouched
- New lib/hooks/use-user-timezone.ts exporting useUserTimezone() and formatInUserTimezone()
- Reads user.timezone from Better Auth useSession() additionalField (Plan 01)
- Validates against Intl.supportedValuesOf('timeZone') with safe fallback to NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'
- Pure formatInUserTimezone helper safe to call inside loops (not a hook)
- Resolves TZ-04
- Adds `timezone` to additionalFields on the auth `user` config
- Default value reads process.env.DEFAULT_TIMEZONE (falls back to "UTC")
- session.user.timezone now available on every authenticated request
- Inferred User type automatically picks up the new field — no type changes needed
Builds on the env-var INTEGRATIONS_DISABLED shipped with the nav-design
overhaul. Adds a DB-backed admin UI so operators can flip integrations
without editing .env and restarting the container, plus the remaining
visual cleanup items from the design backlog.
Integration toggles
- Migration 081 — integration_settings table (key PK, disabled flag,
reason, disabled_by audit, disabled_at). Seeded with all 13 known
integrations as enabled.
- GET / PATCH /api/admin/integrations — gated by requirePermission
(admin, access). PATCH clears the in-process integration-health
cache so toggles take effect within seconds.
- /admin/integrations admin page with a Switch per integration, optional
reason input, audit-info subtitle (disabled by, when, why), live
status light from /api/dashboard/integration-health.
- integration-health service merges env-var disable list with DB rows;
degrades gracefully if migration unapplied / DB unreachable.
- Wired into the Admin nav dropdown (eight items now).
- CLAUDE.md describes both env + DB sources.
Sticky first column on tables
- Table primitive accepts stickyFirstColumn?: boolean. When true, TH
and TD :first-child stay pinned during horizontal scroll, with
background inheritance preserving hover and selected row tints.
- DataTable exposes the prop too — on by default for paginated tables.
- /addigy-devices opts in.
Dark-mode contrast
- --border lifted from 10% to 14% in .dark; --input from 15% to 18%;
--sidebar-border to 14%.
- StatusLight outline ring lifted from /10 to /15 (light) and /20 (dark).
- DetailModal empty-cell em-dash lifted from /40 to /70 so missing
values are legible on dark surfaces.
DESIGN.md
- Closed sticky-first-column, dark-mode contrast, and palette-audit
items (palette deprioritized — most uses are semantic).
- Skeleton helpers documented as preferred for new code; existing
ad-hoc patterns left in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Major UI refresh on the nav-design-improvements branch. Drops 2013-era
inline styles and consolidates patterns behind shared primitives.
Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
the standards-guide blue (#0075AD) with utility classes for numerics
(.num / .num-lg / .num-xl), metric labels, surface tints, and the
wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
"Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page
Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
health table, worker pulse cards (analyzer / RMM / sync scheduler),
token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
integrations (e.g. SentinelOne) — no failure noise from broken-on-
purpose entries. Aliases supported (sentinelone → s1, etc.)
Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
area chart, 30-day mean resolution time line chart, today's active
engineers leaderboard
Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
status, classification, source, company type, publish, active /
yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)
Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
PageHeader rule (consistent across flat links and submenu triggers);
active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config
Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs
DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow
Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below
Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>