Commit graph

83 commits

Author SHA1 Message Date
59294cee5e test(12-04): add pax8-sync-service.test.ts for syncOrders + syncCompanyMatches
- 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.
2026-07-10 22:54:18 -04:00
5f960601e3 feat(12-04): wire syncOrders + syncCompanyMatches into fullSync
- 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
2026-07-10 22:52:30 -04:00
a06c1d314b feat(12-04): add syncOrders nested invoice->item upsert + tombstone
- 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
2026-07-10 22:52:03 -04:00
fe7860760b chore: merge executor worktree (worktree-agent-a0da7e5b2254b56f8) 2026-07-10 22:48:53 -04:00
ae44669b9e test(12-03): add pax8-company-matcher unit tests
- Mocks postgresClient.query (default export) per pax8-client.test.ts
  discipline, adapted from fetch mocking
- Covers all five decision branches: auto-link, review (below
  threshold), review (near-tie, D-02), empty candidates (D-03), and
  the idempotency guard (D-05/SC#4)
- Covers dryRun: asserts zero UPDATE/INSERT/DELETE calls issued
- npx vitest run lib/services/pax8-company-matcher.test.ts: 6/6 passed
2026-07-10 22:47:43 -04:00
691bb47a91 feat(12-03): create pax8-company-matcher.ts
- 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
2026-07-10 22:47:33 -04:00
5cfbd13bcb test(12-02): add pagination + GET-only tests for listAllInvoices/listAllInvoiceItems
- 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)
2026-07-10 22:46:12 -04:00
0920ef8e43 feat(12-02): add listAllInvoices and listAllInvoiceItems to Pax8Client
- 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)
2026-07-10 22:45:40 -04:00
cf3ae61dcb feat(11-02): add Pax8SyncService (companies/subscriptions/referenced catalog)
- 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
2026-07-10 19:46:11 -04:00
c3a0432869 feat(11-01): implement PAX8 client read-only pagination helpers
- listAllCompanies/listAllSubscriptions/listAllProducts each loop pages via
  the existing fetchJson (inherits 429/Retry-After backoff), size=200
- Shared private paginateAll() helper stops once page.number >= totalPages-1
- Existing listCompanies(page, size) signature unchanged
- GET-only, no mutating method ever set (PAX8-08)
2026-07-10 19:39:26 -04:00
19fe788778 test(11-01): add failing tests for PAX8 client pagination helpers
- 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
2026-07-10 19:39:00 -04:00
532ca96dd0 feat(10-01): implement pax8-factory config check + singleton
- 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
2026-07-10 17:33:56 -04:00
a07fe4574a test(10-01): add failing tests for pax8-factory config check + singleton 2026-07-10 17:33:52 -04:00
1da08093eb feat(10-01): implement Pax8Client token exchange + auth-proof call
- 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
2026-07-10 17:32:25 -04:00
ed485d8bde test(10-01): add failing tests for Pax8Client token exchange + auth-proof call 2026-07-10 17:32:20 -04:00
758b7e7f15 feat(engagement): replace Graph email counts with real-time mimecast data
- 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
2026-06-02 20:25:14 -04:00
62c529fb91 fix(quick-260521-foj-02): defensively nullify ticket company_id for missing companies
- 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
2026-05-21 11:22:33 -04:00
1ecaefe85a fix(quick-260521-foj-01): widen Companies full sync to fetch all companies
- 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
2026-05-21 11:21:48 -04:00
badd718194 feat(260521-fci-02): wire tickets-reconcile schedule + migration 090
- 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.
2026-05-21 11:09:36 -04:00
51f0b32cb3 feat(260521-fci-01): add ticket reconciliation service + API route
- 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).
2026-05-21 11:08:24 -04:00
ef9b31e7c2 feat(260519-0oz-01): add QboPaymentCreatePayload, QboDepositCreatePayload types and createPayment/createDeposit methods to QboClient
- 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
2026-05-19 00:36:01 -04:00
2ff2dc9904 fix(09.1-01): pulse-me- prefix, company ntfy server + bearer auth for personal channels
- NTFY_TOPIC_RE tightened to ^pulse-me-[A-Za-z0-9-]{6,64}$ (rejects noc-*, soc-*, bare pulse-)
- mintNtfyTopic() now returns pulse-me-XXXXXXXX (8 hex chars, same entropy)
- sendChannelTest (ntfy): forced to NTFY_BASE_URL + NTFY_PULSE_TOKEN; drops channel.config.auth_token path
- sendNtfy (notify.ts): personal/global branch on owner_user_id; personal -> company server + bearer NTFY_PULSE_TOKEN
- approval.ts ntfy branch: same personal/global split (soft fallback when token missing)
- ticket-digest-service.ts: deliver() + getAvailableChannels() SELECTs now include owner_user_id; ntfy branch applies same split
2026-05-11 06:42:52 -04:00
1bce661648 chore: merge 09-02 worktree commits (Wave 2) 2026-05-10 07:36:15 -04:00
c35b968522 feat(09-02): personal channels service + /api/me/channels routes
- 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)
2026-05-10 07:30:05 -04:00
fd19a5d997 test(09-03): add vitest unit test for muted user route behavior
- 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
2026-05-10 07:29:20 -04:00
86acc06b16 feat(09-03): rewrite executeNotify with route_to_user branch and fallback semantics
- Extract dispatchToGlobalChannel helper (backward-compat path unchanged)
- Add dispatchUserRoute: field lookup, resolver dispatch, email→user_id
  resolution, mute check, personal channel lookup, send with fallback
- Add fallbackToGlobal: annotates output with user_route_fallback reason
- Mute path (enabled=false) returns success:true/notified:false, no fallback
- Default channel-type order when omitted: ntfy then teams (ROUTE-06)
- Export _INTERNALS test seam following link-discovery.ts precedent
- All five fallback reasons: no_channel, send_failed, user_not_found,
  no_field_value, resolver_unknown (ROUTE-03..06)
2026-05-10 07:28:21 -04:00
d27462f713 feat(09-03): add RouteToUser types and resolver registry
- Add RouteToUser, ResolvedRecipient, NotifyResolver, UserRouteFallback,
  UserRouteFallbackReason types to lib/types/pipeline.ts (ROUTE-01)
- Create lib/services/pipeline-steps/notify-resolvers.ts with three v1
  resolvers: direct_email, pulse_user_id, autotask_resource_email (ROUTE-02)
- Resolver registry as Map<string, NotifyResolver> with registerResolver()
  and resolveRecipient() dispatcher with try/catch error handling
2026-05-10 07:27:00 -04:00
3f6b13572e feat(08-01): add getUserPhotoBytes() to MsGraphClient
- 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
2026-05-07 20:39:42 -04:00
ea5532c5c3 feat(07.1-03): add lib/services/user-timezone.ts helper
- getUserTimezone(session) returns validated IANA tz string with safe fallback
- DEFAULT_TIMEZONE_FALLBACK reads process.env.DEFAULT_TIMEZONE || 'UTC'
- Validates against Intl.supportedValuesOf('timeZone'); 64-char length cap
- Pure / synchronous / no DB / no @/lib/auth-utils import (avoids circular)
2026-05-07 08:01:23 -04:00
e1427b62d7 feat(admin): DB-backed integration toggles + sticky cols + dark contrast
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>
2026-05-03 09:55:22 -04:00
9bfb57553d feat(design): nav/visual overhaul — brand layer, /status route, KPI dashboard, TanStack DataTable
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>
2026-05-03 09:33:13 -04:00
1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:18 -04:00
378e68ad8a fix(analyzer): reset stale in-flight jobs on worker boot
A container restart leaves analyzer_jobs rows stuck in
fetching/triaging/itglue/analyzing/deep_review forever — the worker's
claimQueuedJob only picks up status='queued', so a job mid-pipeline
when the process died gets orphaned.

resetStaleJobsToQueued() reverts any active-state row whose started_at
is older than 10 min back to 'queued' with started_at=NULL. The worker
calls it once on start() before scheduling the first poll. 10 min is
3x the realistic pipeline ceiling — well past Sonnet+Opus combined.

Logs the count when nonzero so restarts that recover work are visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:56:00 -04:00
a0a6e7f192 fix(itglue): list flexible assets per type to satisfy API 422 requirement
IT Glue's /flexible_assets endpoint refuses requests without a
filter[flexible-asset-type-id] (returns 422 "Cannot index flexible
assets without providing a flexible asset type ID filter"). The
analyzer's Stage 2 search was caught and tolerated, but never returned
docs.

Added getFlexibleAssetsForOrganization(orgId) on ITGlueClient. It
fetches the type list once per process (memoized), then fans out
per-type fetches with Promise.allSettled so a permission-restricted
type doesn't poison the whole org. Wired into itglue-search and
aggregate-persistence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:48:53 -04:00
bd3401df1c feat(analyzer): Phase 2 — full stage persistence, fingerprints, aggregate reports, cost guards
Eight sub-phases per docs/ticket-analyzer-phase2-spec.md:

2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot,
    aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses.
    model_traces marked LEGACY (kept for back-compat).
2.2 Every pipeline stage records a row to analyzer_stage_executions, success
    or failure. Worker persists a status='failed' analyzer_analyses row when
    the pipeline throws so partial stage records have a parent. Pipeline
    exposes raw triage/sonnet/opus responses for downstream stages.
2.3 Stage 3 prompt updated with markdown formatting rules + banned filler
    phrases. Added react-markdown + remark-gfm + @tailwindcss/typography.
    New <AnalysisMarkdown> component replaces <ProseText>; coerces stray
    headers to bold paragraphs.
2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant.
    scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the
    legacy model_traces blob.
2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/
    queue/status/priority/assignee, sticky filter bar, active-filter chips,
    bulk selection persisted via localStorage, "Analyze N selected" +
    "Generate aggregate report" actions. New <MultiSelect> primitive.
    Staleness uses last_activity_date > completed_at heuristic per spec C.1.
2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists
    SQL distributions immediately so UI shows partial state during the
    Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new
    /:id]). IT Glue context fetcher capped at 200 doc titles.
2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at
    $20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS
    override. Every gating decision audited.
2.8 Runbook + build notes updated.

128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent
(IF NOT EXISTS). model_traces double-write retained — drop in a future
migration once aggregate reports have soaked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:00:22 -04:00
ed3b363d02 feat(analyzer): phase 7 — share-via-email
sendAnalysisShareEmail() reuses the existing nodemailer SMTP transport
(same path as magic-link/invitation mail). Share route persists the
audit row first, then attempts send; on failure returns
{share, emailSent:false, emailError} at HTTP 200 so the audit log
stays intact. Modal surfaces send failures as a warning toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:03:11 -04:00
8f8b5ab7be feat: AI ticket analyzer (phases 1-6)
Multi-stage LLM pipeline that produces structured analyses of Autotask
tickets from local Postgres. Migration 069 + Zod schemas, Stage 0
preprocessor, IT Glue redaction + search, Anthropic SDK wrapper, Stages
1/3/4 (Haiku/Sonnet/Opus), pipeline + cost circuit breaker, job worker
(opt-in autostart), 6 API routes, 3 frontend pages, share-row
persistence (email send deferred to phase 7). 128 vitest tests, tsc
clean. Build journal in docs/wulf-pulse-ticket-analyzer-build-notes.md.

Sync: adds syncTicketNotes() + ticket_notes to ordered/date-filtered
entities so the analyzer's local mirror stays current via scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:59:40 -04:00
ea3471d38d feat: Veeam RPO analysis, comparison, ticket analysis + company teams table
- Add Veeam RPO analysis page (/veeam-analysis) and comparison page (/veeam-comparison)
- Add API routes: /api/veeam/rpo-analyze, rpo-comparison, rpo-offline-log, ticket-analysis
- Add veeam-rpo-service.ts enhancements (RPO logic, offline detection, comparison)
- Add veeam-analysis-state.ts and rmm-device-resolver.ts services
- Add migrations 065-068: company_teams, veeam_rpo_offline_log, rpo_comparison_tables, veeam_ticket_analysis
- Add backup-status page updates and nav links for new Veeam pages
- Add scripts: deactivate-cis-for-inactive-companies, workstation category updates
- Add docs: mimecast-api-guide, veeam-backup-alerting-recommendation, workstation-backup-overview, ticket-analyzer-prompt
- Minor: webhook-service, entity-sync, entity-mapper, sync-helpers, sync.ts, middleware.ts updates
2026-04-29 09:16:46 -04:00
07067bef19 feat: Display Settings UI + Company Category/Type sync
- Add /admin/display-settings page with Kiosk and Mobile sections
- Company category checkbox filter + excluded companies searchable multi-select
- New DB tables: company_categories, company_types (migration 064)
- Sync COMPANY_CATEGORIES via CompanyCategories entity (id/name/isActive)
- Sync COMPANY_TYPES via Companies.companyType picklist
- Add to EntityType, ENTITY_DEPENDENCIES, sync-helpers, entity-mapper, entity-sync
- New API routes: /api/admin/display-settings (GET/POST), /api/data/company-categories, /api/data/companies-list
- Update all 4 routes (kiosk/stats, kiosk/activity, mobile/tickets, mobile/dashboard)
  to filter by kiosk_settings company_category_ids + excluded_company_ids
- Add Display Settings nav link (SlidersHorizontal icon) to Admin menu
- Seed kiosk_settings: kiosk_company_category_ids=1, mobile_company_category_ids=1
2026-04-06 09:03:19 -04:00
0e8eb4871e fix: prevent sync from nullifying assigned_resource_id on tickets
- bulkUpsert now accepts preserveExistingOnNull column list, using
  COALESCE(EXCLUDED.col, table.col) so null incoming values never
  overwrite existing non-null DB values
- bulkUpsertRecords passes resource ID columns as preserve-on-null
  for TICKETS and TASKS entities
- getValidResourceIds now throws on DB error instead of returning
  empty set (which would nullify every resource reference)
- Fix mimecast mailbox-remediate fetch handlers to check res.ok and
  content-type before calling res.json(), preventing JSON parse crash
  on 502 Bad Gateway responses
2026-04-05 08:55:41 -04:00
04a720f0d0 fix: use KQL $search with ConsistencyLevel:eventual for mailbox search (Graph $filter unsupported for nested from/emailAddress/address) 2026-04-01 10:14:16 -04:00
bc3904de4e feat: mailbox remediation via Graph Mail.ReadWrite — search + move to Deleted Items from analysis dialog
- Add searchMailboxMessages, deleteMailboxMessage, moveToDeletedItems to MsGraphClient
- POST /api/mimecast/mailbox-remediate: search, move, delete actions with permission error handling
- DeliveredAnalysisDialog: Remove from mailbox panel with search → confirm → delete flow
  - Shows matching messages in mailbox with checkboxes, received time, read/unread status
  - Moves selected to Deleted Items (recoverable) via Graph API
  - Surfaces clear permission guidance if Mail.ReadWrite not yet granted
2026-04-01 10:09:38 -04:00
8e28062d85 feat: add Delivered Mail tab with message-finder search, spam scoring, analysis dialog
- New POST /api/mimecast/delivered route using message-finder/search API
- DeliveredMailTab: search by recipient, sender, subject, time range (6h–7d)
- Results table with status badge, spam score, row tinting for high/moderate risk
- Summary stats bar: total / high spam (≥10) / moderate (5-9) / clean counts
- Sort by date or spam score; filter by status (accepted/held/rejected/bounced)
- DeliveredAnalysisDialog: explains why high-score mail got through, envelope mismatch detection, actionable remediation steps (block domain, adjust policy threshold, report)
- MimecastDeliveredMessage interface + searchDeliveredMessages() method in client
2026-04-01 08:17:18 -04:00
9952365df1 feat: held mail analysis dialog + fix release check
- Add analyzeMessage() — context-aware explanations for DMARC/impersonation/spam/auth-code/malware holds
- Add MessageAnalysisDialog with severity icon, message details, explanation, and resolution options
- Analyze button per row opens the dialog; Release button inside dialog triggers release + closes
- Fix releaseHeldMessage(): treat HTTP 200 + empty fail[] as success (not release===true check)
- Remove action:'release' from payload (API doesn't need it)
2026-04-01 07:18:52 -04:00
a15946daf8 feat: held mail release button + per-tenant fetch
- Add releaseHeldMessage() to MimecastClient (POST /api/gateway/hold-release)
- Add POST /api/mimecast/held/release route
- HeldMailTab: tenant selector before load (defaults to Wulf), only fetches selected tenant
- Release button per row with spinner + optimistic removal on success
- Error shown inline under Release button if release fails
2026-04-01 07:09:42 -04:00
a18d5b66bf fix: don't send x-mc-account header for tenant-specific credentials
getMimecastClientForTenant now always uses accountCode='' so the
x-mc-account header is never sent. Sending your own account code
with tenant-specific OAuth credentials causes Mimecast to 403
(it interprets it as an invalid impersonation attempt).
2026-04-01 06:57:40 -04:00
2f88be9ab3 fix: held mail route force-dynamic, 403 fallback, favicon 404s, error display
- export const dynamic = 'force-dynamic' on /api/mimecast/held to prevent Next.js caching
- Add AbortController timeout (20s) per request in MimecastClient.request()
- getHeldMessages: 403 fallback without admin:true flag for tenants lacking permission
- Reduce maxMessages default to 100 (10 pages) to stay within route timeout
- Show 'permission denied' tooltip in tenant badge for 403 errors
- Surface HTTP errors in HeldMailTab instead of silently failing
- Add missing favicons: sentinelone.ico, itglue.ico, mimecast.ico
2026-04-01 06:52:58 -04:00
fcdec8e38b feat: Mimecast multi-tenant held mail viewer
- migration 062: mimecast_tenants table (company_id, client_id/secret, account_code)
- Seed Wulf (CUSA13A95) + Seubert (CUSA96A181) tenants
- MimecastClient.getHeldMessages(): full pagination via meta.pagination.next cursor
  (API always returns 10/page regardless of pageSize param, totalCount in meta)
- getMimecastClientForTenant() factory for per-tenant instantiation
- GET /api/mimecast/held?tenantId=&recipient= — fetches all tenants in parallel,
  merges + sorts by date, returns per-tenant counts + combined messages[]
- Held Mail tab on /admin/sync/mimecast (on-demand load, recipient filter,
  tenant badges, policy filter dropdown, DMARC/impersonation highlighted red)
2026-03-31 22:38:22 -04:00
a4242b81be feat: Duo Security integration — full data sync from Accounts + Admin API
Duo API Client (lib/services/duo-client.ts):
- HMAC-SHA1 request signing, GET/POST, automatic pagination
- Rate-limit handling (429 + Retry-After), configurable timeout
- Accounts API: listAccounts() via POST /accounts/v1/account/list
- Admin API: getUsers, getPhones, getGroups, getIntegrations, getAuthLogs
- Child account access: parent creds signed against child api_hostname + account_id
- Factory helpers: getDuoAccountsClient(), getDuoAdminClient()

Database (migration 058):
- 6 tables: duo_accounts, duo_users, duo_phones, duo_auth_logs, duo_groups, duo_integrations
- All with proper FKs, indexes, JSONB fields for capabilities/location/groups

Sync Service (lib/services/duo-sync-service.ts):
- syncAll(): accounts → per-child data + auth logs → parent account → company matching
- Sequential child processing to respect rate limits
- Incremental auth logs (mintime = last synced timestamp, default 30 days)
- Company matching: exact → case-insensitive containment (30/32 = 94% matched)
- Non-blocking with sync ID tracking

API Routes:
- POST/GET /api/duo/sync — trigger sync / check status
- GET /api/duo/accounts — list all accounts with stats + matched company
- GET /api/duo/accounts/[id]/users — users for a specific account
- POST /api/openclaw/sync/duo — OpenClaw trigger with API key auth

Results: 33 accounts, 832 users, 925 phones, 5927 auth logs, 46 groups, 78 integrations
2026-03-27 09:18:04 -04:00
dd4cf68def feat: add project_phases entity sync with task project_id backfill
The Autotask Tasks bulk API does not return projectID in its response,
causing all tasks.project_id to be NULL. This fixes it by:

- Adding project_phases as a synced entity (Autotask endpoint: /Phases)
- Migration 059: project_phases table with project_id, phase_number,
  estimated_hours, start/due dates, parent_phase_id, is_scheduled
- EntityType.PROJECT_PHASES added to all sync maps and dependency graph
  (depends on PROJECTS, runs before TASKS in sync order)
- buildProjectPhasesFilter: Phases endpoint requires a filter (id > 0)
- mapProjectPhase: maps Autotask field names to DB columns
- Post-sync backfill in syncEntity: after each project_phases sync,
  UPDATE tasks SET project_id = pp.project_id FROM project_phases pp
  JOIN projects p WHERE tasks.phase_id = pp.id
  Only backfills where the project exists in our DB (FK constraint on
  tasks.project_id; archived projects are skipped gracefully)

Result: 2,455 of 4,966 tasks now have project_id populated. Tasks
belonging to archived/completed projects have phase_id resolvable via
project_phases even when project_id remains NULL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:57:33 -04:00