feat(04-01): rewrite /api/mobile/tickets with cursor pagination and exported interfaces
- Replace page/offset pagination with opaque base64 cursor (last_activity_date, id) - Export MobileTicket and MobileTicketListResponse interfaces for Plan 02 import - Add requireAuth() gate (T-04-03: legacy route lacked auth) - Server-side limit cap at 25 rows (D-11, T-04-04) - Default status filter [1,8,7] when no status param supplied (matches legacy t.status != 5) - Preserve getMobileCompanyFilter() helper verbatim - Support status/priority arrays, queue, mine, and search filters - Cursor seek predicate: (last_activity_date, id) < (cursor) for stable keyset order
This commit is contained in:
parent
77073bac97
commit
6268d1fe37
51 changed files with 580 additions and 9642 deletions
|
|
@ -1,211 +0,0 @@
|
|||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-05-03
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** Single-instance Next.js 16 backend with in-process background workers (no external job queue).
|
||||
|
||||
**Key Characteristics:**
|
||||
- App Router pages (`'use client'`) fetch data via API routes using `fetch()`
|
||||
- Three auto-starting background workers via side-effect imports (sync-scheduler, analyzer, RMM overshell)
|
||||
- Service layer in `lib/services/` with factories + configuration helpers
|
||||
- Postgres 16 as system of record (snake_case columns), Redis for caching only
|
||||
- Manual transformation from snake_case DB columns to camelCase API responses
|
||||
- Better Auth 1.4 for sessions + magic link + TOTP 2FA + Microsoft OAuth
|
||||
|
||||
## Layers
|
||||
|
||||
**Route Layer (HTTP entry):**
|
||||
- Purpose: Accept HTTP requests, validate auth, delegate to services, return JSON responses
|
||||
- Location: `app/api/*/route.ts`, `app/*/page.tsx`
|
||||
- Contains: Next.js route handlers (GET/POST/PATCH/DELETE), page components
|
||||
- Depends on: Auth via `lib/auth-utils.ts`, services via `lib/services/`
|
||||
- Used by: Client-side fetch calls from UI components
|
||||
|
||||
**Service Layer (business logic):**
|
||||
- Purpose: Sync data from external APIs, run background jobs, execute workflows, store results in Postgres
|
||||
- Location: `lib/services/*.ts` (~50 files) plus subdirectories (`analyzer/`, `rmm/`, `llm/`, `b2/`)
|
||||
- Contains: Integration clients (autotask, datto-rmm, itglue, veeam, msgraph, etc.), sync logic, job executors, pipeline orchestration
|
||||
- Depends on: Postgres client, external API clients, environment configuration
|
||||
- Used by: API routes (sync endpoints, webhook handlers) and background workers
|
||||
|
||||
**Data Access Layer (Postgres):**
|
||||
- Purpose: Query, upsert, and manage state in Postgres
|
||||
- Location: `lib/services/postgres-client.ts` (singleton) + `migrations/NNN_*.sql`
|
||||
- Contains: Connection pool, query builder methods (`query()`, `transaction()`, `upsert()`, `bulkUpsert()`), migration definitions
|
||||
- Depends on: PostgreSQL 16 connection string from env
|
||||
- Used by: All services that read/write data
|
||||
|
||||
**Background Workers (long-running processes):**
|
||||
- Purpose: Poll for work and execute sync/analysis/RMM tasks without blocking HTTP requests
|
||||
- Location: `lib/services/sync-scheduler.ts`, `lib/services/analyzer/worker.ts`, `lib/services/rmm/worker.ts`
|
||||
- Contains: node-cron scheduler, polling loops with exponential backoff, state machine handlers
|
||||
- Depends on: Postgres client, service layer integrations
|
||||
- Used by: Auto-start on module import (side effects); HTTP routes that need them running will import the module to start them
|
||||
|
||||
**Component Layer (UI):**
|
||||
- Purpose: Render pages, dialogs, tables, charts, and handle client-side state
|
||||
- Location: `components/ui/` (shadcn primitives), `components/*/` (feature-specific), `app/*/page.tsx`
|
||||
- Contains: React components, hooks for fetch + state, sonner toasts, recharts visualizations
|
||||
- Depends on: API routes via `fetch()`, client auth via Better Auth SDK
|
||||
- Used by: Next.js pages and other components
|
||||
|
||||
**Types & Schema (contracts):**
|
||||
- Purpose: Define TypeScript interfaces and database schema
|
||||
- Location: `lib/types/<domain>.ts`, `migrations/NNN_*.sql`
|
||||
- Contains: Entity types (ticket, company, analysis, rmm_execution, etc.), sync request shapes, API response envelopes
|
||||
- Depends on: (none — they define contracts)
|
||||
- Used by: Services, API routes, components
|
||||
|
||||
## Data Flow
|
||||
|
||||
**Autotask Webhook → Analyzer Pipeline:**
|
||||
|
||||
1. **Webhook ingress** (`POST /api/webhooks/autotask`) — Public endpoint (no auth), HMAC-verified inside handler via `lib/services/webhook-service.ts`
|
||||
2. **Enqueue job** — If ticket.created, fires webhook handler in `lib/services/webhook-service.ts` which inserts `analyzer_jobs` row with `status='queued'`
|
||||
3. **Worker poll** — `lib/services/analyzer/worker.ts` auto-starts in production; every 2s polls for `analyzer_jobs.status='queued'`, claims one with `FOR UPDATE SKIP LOCKED`
|
||||
4. **Pipeline orchestration** — `lib/services/analyzer/pipeline.ts` runs 7 stages:
|
||||
- Stage 0: Preprocess (filter noise, compute `content_hash` for idempotency)
|
||||
- Stage 1: Triage (Haiku — categorize, extract entities)
|
||||
- Stage 2: IT Glue retrieval (if configured, redacted docs only via `itglue-search.ts`)
|
||||
- Stage 3: Deep analysis (Sonnet — summary, gaps, root cause)
|
||||
- Stage 4: Deep reasoning (Opus — optional, skipped above $2.00 cost ceiling)
|
||||
- Stage 5: Persist to `analyzer_analyses` + stage execution rows
|
||||
- Stage 6: Fingerprint (Haiku — cross-ticket aggregation data)
|
||||
5. **Link-aware bundles** — `lib/services/analyzer/link-discovery.ts` resolves related tickets; members get `pending_analyses` rows
|
||||
6. **Aggregate reports** (optional) — Once all bundle members analyzed, `stages/aggregate-reduce.ts` fires
|
||||
|
||||
**Periodic Sync Cadence:**
|
||||
|
||||
1. **Cron trigger** — `lib/services/sync-scheduler.ts` (node-cron singleton) reads `sync_schedules` table; fires at configured times
|
||||
2. **Entity sync** — `lib/services/entity-sync.ts` per entity type (tickets, companies, resources, etc.), incremental via `lastTrackedModificationDateTime` when supported
|
||||
3. **Postgres upsert** — `postgresClient.bulkUpsert()` writes batches to DB tables (`tickets`, `companies`, etc.)
|
||||
4. **Integration-specific syncs** — Datto RMM devices/alerts, IT Glue configs/contacts, Veeam agents/alarms, Engagement data, Zoom, Duo, etc.
|
||||
|
||||
**RMM Overshell Execution:**
|
||||
|
||||
1. **User trigger** (`POST /api/rmm/execute`) — Admin user picks registered script + device
|
||||
2. **Validation & rate limit** — `lib/services/rmm/executor.ts` validates script ID, resolves device, enforces per-user limit (50 / 24h)
|
||||
3. **Queue insertion** — Insert `pending` row in `rmm_executions` table, call Datto `client.runQuickJob()`
|
||||
4. **Worker poll** — `lib/services/rmm/worker.ts` (5s cadence) polls in-flight executions, queries Datto for result status
|
||||
5. **Output parsing** — Script's `parseOutput()` method transforms Datto output; result stored in `rmm_executions`
|
||||
|
||||
**LogLift Evidence Ingest:**
|
||||
|
||||
1. **Webhook** (`POST /api/rmm/loglift/upload`) — Public, `x-openclaw-key` header auth
|
||||
2. **Decompression** — Download gzipped JSON from B2, decompress, cap at 100 MB (zip-bomb guard)
|
||||
3. **Storage** — Slim summary to `loglift_uploads` table, full payload to B2 via `lib/services/b2/client.ts`
|
||||
4. **Auto-audit** — Resolve device → Autotask company → IT Glue config; if unique match, fire asset-first audit
|
||||
|
||||
**IT Glue Write-back:**
|
||||
|
||||
1. **Audit runner** (`lib/services/analyzer/asset-audit/runner.ts`) — Post-analysis, runs LLM audit against IT Glue config/flexible asset
|
||||
2. **Results** — Persisted to `itglue_audit_logs`, linked via `itglue_ticket_xrefs`
|
||||
3. **Revert** — Patch `/api/analyzer/itglue/configurations/[id]/revert/[writeId]` rolls back changes
|
||||
|
||||
**State Management:**
|
||||
|
||||
- **HTTP requests:** Stateless; session from Better Auth cookie
|
||||
- **Background jobs:** State in Postgres (status columns: `queued` → `in_flight` → `complete` / `failed`)
|
||||
- **Caching:** Redis (optional, used for integration health checks, cache duration varies)
|
||||
- **Authorization:** Checked in route handlers via `requireAuth()` / `requireAdmin()` / `requirePermission()` from `lib/auth-utils.ts`
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**Factory Pattern (Integration Clients):**
|
||||
- Purpose: Lazy-load integration clients with configured credentials; provide `is<Name>Configured()` helper to check env vars
|
||||
- Examples: `lib/services/autotask-factory.ts`, `lib/services/datto-rmm-factory.ts`, `lib/services/msgraph-factory.ts`
|
||||
- Pattern: Export `getAutotaskClient()`, `getDattoRmmClient()`, `getMsgraphClient()` with caching; throw if credentials missing; bundled with `is<Name>Configured()` for upstream checks
|
||||
- Why: Decouples client initialization from route handlers; allows conditional feature gates per env
|
||||
|
||||
**PostgresClient Singleton:**
|
||||
- Purpose: Single connection pool for all Postgres queries; auto-lazy-initializes; provides ORM-like query builder
|
||||
- Examples: `postgresClient.query()`, `postgresClient.transaction()`, `postgresClient.upsert()`, `postgresClient.bulkUpsert()`
|
||||
- Pattern: Private constructor, static `getInstance()`, pool initialized on first use
|
||||
- Why: Prevents connection leaks; provides consistent interface across ~50 services
|
||||
|
||||
**Sync Service (Entity-Agnostic):**
|
||||
- Purpose: Incremental/full sync of any entity type from external API → Postgres
|
||||
- Examples: `lib/services/entity-sync.ts` (Autotask), `lib/services/itglue-sync-service.ts`, `lib/services/veeam-sync-service.ts`
|
||||
- Pattern: Reads `lastTrackedModificationDateTime` from last_sync table; queries external API; batches upsert via `bulkUpsert()`
|
||||
- Why: Codifies the "last sync timestamp + incremental pull + batch insert" pattern across integrations
|
||||
|
||||
**Analyzer Pipeline (7 Stages):**
|
||||
- Purpose: Orchestrate multi-stage LLM analysis with fallbacks, cost guards, and persistence
|
||||
- Examples: `lib/services/analyzer/pipeline.ts`, `lib/services/analyzer/stages/*.ts`
|
||||
- Pattern: Each stage returns structured output (gap analysis, category, cost estimate); cost guard checks ceiling before Stage 4; all stages persisted to `analyzer_stage_executions`
|
||||
- Why: Allows cost control (skip expensive Opus above $2.00), idempotency (content_hash), and debugging (inspect each stage's output)
|
||||
|
||||
**Background Worker Polling Loop:**
|
||||
- Purpose: Auto-start in production, poll for work, claim rows with `FOR UPDATE SKIP LOCKED`, execute, persist result
|
||||
- Examples: `lib/services/analyzer/worker.ts`, `lib/services/rmm/worker.ts`, `lib/services/sync-scheduler.ts`
|
||||
- Pattern: Side-effect import auto-starts on module load; exponential backoff if no work; row locking for multi-instance safety (analyzer), or single-instance gate (sync scheduler)
|
||||
- Why: Keeps background work out of HTTP request path; analyzer is safe to scale (row locking); sync scheduler should run on one instance only
|
||||
|
||||
**Better Auth Roles (RBAC):**
|
||||
- Purpose: Define three roles (`user`, `admin`, `super-admin`) with per-resource permissions
|
||||
- Examples: `lib/auth.ts` (auth config), `lib/permissions.ts` (permission matrix), `lib/auth-utils.ts` (runtime checks)
|
||||
- Pattern: Route handler calls `requireAdmin()` / `requirePermission()`, which decode session and check role; middleware only verifies session cookie exists
|
||||
- Why: Separates auth (middleware) from authorization (route handler); role-based gates are checked at the point of use
|
||||
|
||||
## Entry Points
|
||||
|
||||
**HTTP Pages (Authenticated):**
|
||||
- Location: `app/*/page.tsx`
|
||||
- Triggers: Browser navigation to any route except public paths
|
||||
- Responsibilities: Render page shell with `AppNavigation` + `PageHeader`, fetch data from API, render client-side components, handle toast/dialog interactions
|
||||
|
||||
**HTTP API Routes (Public & Authenticated):**
|
||||
- Location: `app/api/*/route.ts`
|
||||
- Triggers: `fetch()` from client, external webhooks (Autotask, Zabbix, RMM), scheduler HTTP calls
|
||||
- Responsibilities: Validate auth/webhook signature, delegate to service layer, return JSON response with appropriate status (200, 401, 403, 503, 500)
|
||||
|
||||
**Webhook Handlers (Public):**
|
||||
- Location: `app/api/webhooks/autotask`, `app/api/zabbix/webhook`, `app/api/rmm/loglift`
|
||||
- Triggers: External systems (Autotask, Zabbix, OpenClaw) POST events
|
||||
- Responsibilities: Verify HMAC or custom header, parse event, enqueue jobs or upsert data, return 200 (even on error so Autotask doesn't deactivate)
|
||||
|
||||
**Sync Endpoints (Public, called by scheduler):**
|
||||
- Location: `app/api/sync/*`, `app/api/datto-rmm/sync`, `app/api/itglue/sync`, `app/api/veeam/sync`, etc.
|
||||
- Triggers: `lib/services/sync-scheduler.ts` fires HTTP POST at configured times
|
||||
- Responsibilities: Call sync service, update `last_sync` timestamp, return 200 on success or error message
|
||||
|
||||
**Background Workers (Auto-starting, in-process):**
|
||||
- Location: `lib/services/sync-scheduler.ts` (cron), `lib/services/analyzer/worker.ts` (2s poll), `lib/services/rmm/worker.ts` (5s poll)
|
||||
- Triggers: Auto-starts as side effect of module import; runs indefinitely in production
|
||||
- Responsibilities: Poll for work from DB, claim row, execute, persist result, handle errors + logging
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** Defensive; assume external APIs can fail, return 200 on webhook failures (so Autotask doesn't deactivate), log all errors, flag analyses for human review if cost ceiling exceeded.
|
||||
|
||||
**Patterns:**
|
||||
|
||||
- **Webhook handlers** — Return 200 even if processing fails; log error so ops can investigate via audit log
|
||||
- **API route handlers** — `try/catch`, return `NextResponse.json({ error, message }, { status })` with conventions:
|
||||
- 401: Session missing or invalid
|
||||
- 403: Authenticated but lacks permission
|
||||
- 503: Missing/bad integration config (e.g., Autotask API key not set)
|
||||
- 500: Runtime error (query failed, external API timeout, etc.)
|
||||
- **Analyzer pipeline** — Cost ceiling guard at $2.00; above that, Stage 4 (Opus) skipped, analysis flagged with `needs_review=true`
|
||||
- **Sync services** — Incremental sync errors log + re-trigger on next schedule; full sync errors persist `last_error` to `sync_schedules` table
|
||||
- **Background workers** — Stale jobs reset on worker boot (no recovery); exponential backoff on empty polls; errors logged with job ID for manual inspection
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:** Console (stdout) in all services; elevated to syslog or Datadog in production. Analyzer logs all LLM calls + cost to `analyzer_cost_audit` table for billing reconciliation.
|
||||
|
||||
**Validation:** Explicit checks in route handlers where it matters (e.g., device ID exists before RMM execute); no centralized validation framework. Zod used for auth/admin forms only.
|
||||
|
||||
**Authentication:** Better Auth session stored in Postgres; cookie `Auth` + `Auth.Secure` sent on all requests. `middleware.ts` verifies session cookie exists for authenticated pages. Role checks happen in route handlers.
|
||||
|
||||
**Authorization:** Per-resource permissions defined in `lib/permissions.ts` (tickets, configItems, admin, users, roles, auditLog, settings, itglue, rmm). API routes call `requirePermission(resource, action)` to enforce. UI hides links based on `session.user.role`.
|
||||
|
||||
**Rate Limiting:** RMM execute endpoint has per-user limit (50 scripts / 24h via `lib/services/rate-limiter.ts`). No global rate limiter.
|
||||
|
||||
**Integration Health:** `lib/services/integration-health.ts` polls each integration's health (e.g., Autotask token expiry, last sync age); stores status in `integration_health` table; fires alerts if integration is down or sync is stale.
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-05-03*
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-05-03
|
||||
|
||||
## Critical Database Access Pattern
|
||||
|
||||
**Widespread Pool Instantiation:**
|
||||
- Issue: Multiple new Pool instances created directly in files instead of using the postgresClient singleton
|
||||
- Files:
|
||||
- `app/admin/users/[id]/page.tsx` (line 11)
|
||||
- `app/admin/roles/[id]/page.tsx` (line 7)
|
||||
- `app/api/data/time-entries/route.ts` (line 6)
|
||||
- `app/api/admin/audit-log/route.ts` (line 5)
|
||||
- `app/api/admin/settings/route.ts` (line 6)
|
||||
- `app/api/admin/users/route.ts` (line 5)
|
||||
- `app/api/admin/users/[id]/route.ts` (line 5)
|
||||
- `app/api/admin/users/[id]/sessions/route.ts` (line 5)
|
||||
- `app/api/admin/users/[id]/sessions/[sessionId]/route.ts` (line 5)
|
||||
- `app/api/admin/users/invite/route.ts` (line 6)
|
||||
- `app/api/admin/roles/route.ts` (line 5)
|
||||
- `app/api/admin/roles/[id]/route.ts` (line 5)
|
||||
- `app/api/rmm-devices/route.ts` (line 13)
|
||||
- `app/api/openclaw/datto-rmm/devices/route.ts` (line 6)
|
||||
- `app/api/openclaw/datto-rmm/devices/[uid]/route.ts` (line 6)
|
||||
- `app/api/openclaw/datto-rmm/alerts/route.ts` (line 6)
|
||||
- `app/api/openclaw/datto-rmm/sites/route.ts` (line 6)
|
||||
- `app/api/openclaw/datto-rmm/alerts/open/route.ts` (line 6)
|
||||
- `app/api/addigy/org-mappings/route.ts` (line 6)
|
||||
- `app/api/settings/profile/route.ts` (line 5)
|
||||
- `app/api/auvik/tenant-mappings/route.ts` (line 6)
|
||||
- `app/api/rmm/site-mappings/route.ts` (line 6)
|
||||
- `lib/bootstrap.ts` (line 3)
|
||||
- `lib/auth.ts` (line 9)
|
||||
- `lib/services/audit.ts` (line 3)
|
||||
- `lib/services/auvik-client.ts` (line 216)
|
||||
- Impact: Each Pool() call creates a new connection pool, consuming resources and database connections. In production with multiple instances, this can exhaust connection limits. No centralized control over connection pooling.
|
||||
- Fix approach: Replace all instances with `postgresClient` singleton from `lib/services/postgres-client.ts`. The singleton implements lazy initialization and reuses the same pool. Create a migration script to audit all imports and replace new Pool() with postgresClient imports.
|
||||
|
||||
## Duplicate Migration Numbers
|
||||
|
||||
**Out-of-Order Migrations:**
|
||||
- Issue: Multiple migrations share the same number prefix, causing alphabetical apply order to diverge from intent
|
||||
- Files: `migrations/`
|
||||
- Duplicates found:
|
||||
- `002_add_indexes.sql` and `002_relax_foreign_keys.sql`
|
||||
- `004_fix_contacts_company_id.sql` and `004_webhook_support.sql`
|
||||
- `005_add_webhook_ip_logging.sql` and `005_fix_tickets_company_id.sql`
|
||||
- `009_create_auvik_tenant_mappings.sql`, `009_relax_configuration_items_constraints.sql`, `009_restore_deleted_tickets.sql`
|
||||
- `028_create_device_lifecycle_policies.sql` and `028_create_veeam_agents_alarms.sql`
|
||||
- `049_create_ping_flap_suppressions.sql` and `049_create_ticket_digest_tables.sql`
|
||||
- `057_contacts_missing_fields.sql` and `057_create_autotask_tags_tables.sql`
|
||||
- `058_create_duo_tables.sql` and `058_create_repo_commit_tracking.sql`
|
||||
- Impact: Alphabetical filesystem sort determines execution order (not numeric). On Postgres init, migrations apply in ASCII order. New environment setups may fail if a constraint or schema change in one migration depends on another with the same number. This is a hidden fragility.
|
||||
- Fix approach: Rename all duplicate migrations to sequential numbers (e.g., `009_create_auvik_tenant_mappings.sql`, `010_relax_configuration_items_constraints.sql`, `011_restore_deleted_tickets.sql`). Verify that the alphabetical apply order would work for existing databases. Test full init sequence on fresh Postgres. Update deployment docs to warn against re-numbering.
|
||||
|
||||
## Extensive console.log in Production Code
|
||||
|
||||
**Unstructured Logging:**
|
||||
- Issue: `console.log()` statements left throughout production code instead of proper logging framework
|
||||
- Files with multiple instances:
|
||||
- `lib/utils/sync-helpers.ts` (lines 344, 417, 441)
|
||||
- `lib/services/redis-client.ts` (lines 7, 34, 95)
|
||||
- `lib/services/background-processor.ts` (lines 185, 224, 255, 272, 319, 327, 335)
|
||||
- `lib/services/duo-sync-service.ts` (lines 53, 64, 70, 95, 139, 591)
|
||||
- `lib/services/workflow-engine.ts` (lines 104, 221, 326, 664)
|
||||
- `lib/services/duo-client.ts` (line 165)
|
||||
- `lib/services/itglue-sync-service.ts` (line 54)
|
||||
- `lib/services/veeam-sync-service.ts` (lines 91, 112, 127)
|
||||
- `lib/services/webhook-service.ts` (lines 71, 76, 110, 164, 193, 233, 429)
|
||||
- `lib/services/veeam-factory.ts` (line 30)
|
||||
- `lib/services/veeam-compliance-service.ts` (lines 25, 50, 190, 191)
|
||||
- Impact: Logs go directly to stdout, not aggregated to a logging service. In production, container logs are hard to filter and correlate. No structured metadata (timestamp in some, not others; inconsistent prefixes). Search for "WEBHOOK" or "VEEAM-SYNC" is the only way to filter.
|
||||
- Fix approach: Create a simple logger module at `lib/logger.ts` with methods `.info()`, `.warn()`, `.error()` that preserve prefixes but add timestamps and structure. Replace all `console.log()` with `logger.info()` etc. Consider using pino or winston if needed in future, but start with a lightweight wrapper.
|
||||
|
||||
## Tech Debt: Worker Side-Effect Imports
|
||||
|
||||
**Hidden Auto-Initialization:**
|
||||
- Issue: `lib/services/sync-scheduler.ts`, `lib/services/analyzer/worker.ts`, and `lib/services/rmm/worker.ts` auto-start on import as a side effect
|
||||
- Files:
|
||||
- `lib/services/sync-scheduler.ts` (bottom of file)
|
||||
- `lib/services/analyzer/worker.ts` (auto-starts in production or if `ANALYZER_WORKER_AUTOSTART=1`)
|
||||
- `lib/services/rmm/worker.ts` (same gates as analyzer)
|
||||
- Impact: Importing these modules from shared utilities (e.g., a shared auth helper) will start worker loops unexpectedly. If a hot path accidentally imports one, it runs polling immediately. The analyzer worker uses `SELECT … FOR UPDATE SKIP LOCKED` so it's safe across instances, but the sync scheduler does NOT — running it on multiple instances causes duplicate syncs.
|
||||
- Fix approach: Add comments on all three modules warning against hot-path imports. Consider a factory pattern: `initSyncScheduler()`, `startAnalyzerWorker()` with explicit function calls instead of side effects. Or gate them behind a feature flag that must be explicitly enabled. Document in CLAUDE.md that only `app/api/*/route.ts` handlers should import these. Add a linting rule if possible.
|
||||
|
||||
## Type Safety in Shared Types
|
||||
|
||||
**Excessive use of `any`:**
|
||||
- Issue: Shared type definitions use `any` for workflow/pipeline configuration and error handling
|
||||
- Files:
|
||||
- `lib/types/ticket-workflow.ts` (lines 48, 78, 84, 100, 104, 107 — field_changes, condition values, validation errors)
|
||||
- `lib/types/pipeline.ts` (lines 34, 128 — stage values, context dict)
|
||||
- `lib/types/workflow.ts` (lines 108, 111, 113, 139, 193, 207, 243, 245, 273, 293-294, 358, 361, 363, 384 — match_value, result_value, field_changes)
|
||||
- `lib/types/datto-rmm.ts` (lines 105, 141, 146-147 — autotaskDevice, field mapping)
|
||||
- `lib/types/errors.ts` (lines 148, 229, 241 — error categorization and formatting)
|
||||
- Impact: Configuration values for workflow conditions/actions are not validated at the type level. A malformed workflow rule with `match_value: 123` (number instead of string/regex) will only fail at runtime. Error handling functions that accept `any` can mask type errors silently.
|
||||
- Fix approach: Use TypeScript discriminated unions or explicit types for workflow values. For errors, define a proper error interface and use type guards. Start with `lib/types/workflow.ts` since it's most critical for the workflow engine. Consider adding a validation layer that runs on workflow rule creation.
|
||||
|
||||
## Auth Validation Gap
|
||||
|
||||
**Middleware-only Session Check:**
|
||||
- Issue: `middleware.ts` (lines 75-82) only verifies a session cookie exists, does NOT check user role for `/admin` routes
|
||||
- Files: `middleware.ts`
|
||||
- Impact: Role-based access control is entirely at the API route level via `requireAdmin()` or `requirePermission()`. If a developer forgets to call `requireAuth()` or `requireAdmin()` in an API route, the middleware won't catch it. A mistake like returning user data without checking role is a privilege escalation. The comment on line 76 acknowledges this.
|
||||
- Fix approach: Add a helper function `enforceRole()` that is harder to forget than calling `requireAdmin()` early in a route handler. Better: make it so unauthenticated users can't even reach admin routes (redirect in middleware if no admin role detected — requires decoding the session token). Or add a lint rule that checks all API handlers for requireAuth calls. At minimum, add a test that verifies at least 10 admin API routes call requireAdmin() or requirePermission().
|
||||
|
||||
## Missing Test Coverage
|
||||
|
||||
**Limited Test Suite:**
|
||||
- Issue: Only `lib/services/analyzer/**`, `lib/services/rmm/**`, and `lib/services/b2/**` have unit tests; the rest of the codebase relies on TypeScript type-checking only
|
||||
- Files:
|
||||
- Test files: `lib/services/analyzer/*.test.ts` (11 test files), `lib/services/rmm/*.test.ts` (2 test files), `lib/services/b2/*.test.ts` (1 test file), `lib/services/llm/*.test.ts` (2 test files)
|
||||
- No tests for: sync services, webhook handlers, API routes, auth flows, workflow engine logic, integrations (Autotask, IT Glue, Veeam, etc.)
|
||||
- Impact: Refactoring core services like `entity-sync.ts` (1438 lines) or `webhook-service.ts` cannot be validated. Breaking changes in Autotask mapping logic, webhook handling, or sync schedules are only caught in staging. The NO CI note in `ARCHITECTURE.md` means no automated regression detection.
|
||||
- Fix approach: Start with high-impact areas: `lib/services/entity-sync.ts` and `lib/services/webhook-service.ts`. Add unit tests covering the main sync paths and webhook processing. Set a coverage target of 50% for critical services. Add pre-commit hook that runs `npm test` to prevent untested code from being committed.
|
||||
|
||||
## Large Files with Complex Logic
|
||||
|
||||
**Size and Complexity Hotspots:**
|
||||
- Issue: Several service files exceed 1000+ lines, indicating potential refactoring opportunities
|
||||
- Files:
|
||||
- `lib/services/entity-sync.ts` (1438 lines) — Main Autotask sync, many entity types
|
||||
- `lib/services/workflow-engine.ts` (954 lines) — Workflow execution and Autotask write-back
|
||||
- `lib/services/ticket-digest-service.ts` (782 lines) — Daily digest generation
|
||||
- `lib/services/sync-scheduler.ts` (760 lines) — Background task scheduling
|
||||
- `lib/services/analyzer/asset-audit/data-builder.ts` (719 lines) — Evidence aggregation for IT Glue audits
|
||||
- `lib/services/veeam-rpo-service.ts` (675 lines) — Veeam RPO logic
|
||||
- `lib/services/mimecast-client.ts` (675 lines) — Mimecast API client
|
||||
- `lib/services/itglue-sync-service.ts` (663 lines) — IT Glue integration
|
||||
- Impact: Large files are harder to reason about, test, and refactor. Bug fixes in `entity-sync.ts` might accidentally affect the sync of a different entity type if the logic isn't clearly separated. The workflow engine's 954 lines likely interleaves execution logic, Autotask API calls, and error handling.
|
||||
- Fix approach: Extract smaller modules from these files. For example, in `entity-sync.ts`, separate sync logic per entity into `sync/tickets.ts`, `sync/contacts.ts`, etc. In `ticket-digest-service.ts`, split template rendering into separate files. This is a gradual refactoring — start with `workflow-engine.ts` since it has the most potential for breaking bugs.
|
||||
|
||||
## Unvalidated Input in API Routes
|
||||
|
||||
**No Zod Validation Framework:**
|
||||
- Issue: API routes do not systematically validate request bodies or query parameters
|
||||
- Files: All `app/api/**/route.ts` files
|
||||
- Impact: Endpoints accept any JSON and only validate inputs "when it matters" (per CLAUDE.md). An admin form endpoint could silently ignore a malformed field instead of returning a 400 Bad Request. Developers must remember to manually validate each input; forgetting is easy.
|
||||
- Fix approach: Add a lightweight input validation helper (not necessarily Zod, but something). For example, a simple function like `validateInput(req.body, schema)` that checks required fields and types. Use it in high-risk endpoints: user creation, role assignment, workflow rules, integration settings. Start with `/api/admin/*` routes.
|
||||
|
||||
## Known TODOs
|
||||
|
||||
**Incomplete Implementations:**
|
||||
- Issue: Active TODOs left in production code
|
||||
- Files:
|
||||
- `lib/services/sync-service.ts` (line 444) — "TODO: Implement graceful cancellation"
|
||||
- `lib/services/workflow-steps/ai-troubleshooting.ts` (line 40) — "TODO: Implement createTicketNote in AutotaskClient if needed"
|
||||
- `app/api/veeam/backup-status/route.ts` (line 51) — "TODO: compute from config items without matching workloads"
|
||||
- Impact: `sync-service.ts` sync cancellation is not implemented — if a sync is running and needs to be stopped (e.g., on pod termination), it will run to completion. This can delay graceful shutdown. The Veeam backup status compute is a stub returning 0.
|
||||
- Fix approach: Prioritize graceful cancellation in sync-service. For the others, either implement them or remove the TODOs if the current behavior is acceptable. Add a CI check that fails on new TODOs (optional but helpful).
|
||||
|
||||
## Multi-Instance Sync Scheduler Risk
|
||||
|
||||
**Sync Scheduler Not Instance-Safe:**
|
||||
- Issue: `lib/services/sync-scheduler.ts` uses node-cron but does NOT use row locking like the analyzer worker does
|
||||
- Files: `lib/services/sync-scheduler.ts`
|
||||
- Impact: In a multi-instance deployment, every instance will run every scheduled sync at the same time. If the sync scheduler is running on 3 replicas, Autotask gets 3 sync requests simultaneously, which wastes API quota and can cause race conditions on the Postgres side. The ARCHITECTURE.md (line 256) warns: "pin to one instance."
|
||||
- Fix approach: Either (a) force the sync scheduler to run on a single instance only by setting an env flag like `RUN_SYNC_SCHEDULER=1` and defaulting it to false on replicas, or (b) add a distributed lock (e.g., in Postgres with advisory locks) so only one instance's cron fires. Test multi-instance behavior in staging before deploying.
|
||||
|
||||
## Analyzer Cost Ceiling Enforcement
|
||||
|
||||
**Opacity in Stage 4 Skipping:**
|
||||
- Issue: Analyzer Stage 4 (Opus) is skipped if estimated cost exceeds $2.00 (line 138 in ARCHITECTURE.md), flagged for human review, but no clear UI/alert when this happens
|
||||
- Files: `lib/services/analyzer/pipeline.ts`, `lib/services/llm/pricing.ts`
|
||||
- Impact: An analysis with a cost ceiling hit is marked with a flag, but there's no alerting mechanism to tell admins that a ticket analysis was incomplete due to cost. The ticket analysis might be silently insufficient for the user.
|
||||
- Fix approach: Add a cost-ceiling alert row to `analyzer_analyses` or a separate cost-alert table. Expose this in the admin UI at `/admin/analyzer` with a filter for "cost-ceiling alerts." Log a structured event with `logger.warn()` so it's visible in centralized logs.
|
||||
|
||||
## IT Glue Redaction Mandatory But Not Enforced
|
||||
|
||||
**Redaction Bypass Risk:**
|
||||
- Issue: IT Glue search results MUST be redacted before being sent to an LLM (line 87-89 in ARCHITECTURE.md), but there's no type-system enforcement
|
||||
- Files: `lib/services/analyzer/itglue-search.ts` (redacted output), `lib/services/analyzer/` (callers)
|
||||
- Impact: A developer could accidentally import `itglue-client.ts` (the raw client) and pass results directly to an LLM prompt, exposing credentials or PII. The redaction is documented but optional in code.
|
||||
- Fix approach: Make the raw IT Glue client non-exported from its module, forcing all LLM-bound queries through the redacted search function. Add a type wrapper like `RedactedDocument` that is the only type accepted by LLM callers. Or add a pre-commit hook that scans for `itglue-client` imports in analyzer files and warns.
|
||||
|
||||
## .env File Committed to Repo
|
||||
|
||||
**Potential Secrets Exposure:**
|
||||
- Issue: `.env` file exists at `/opt/stacks/pulse/.env` and is NOT in `.gitignore`, but git ls-files shows no .env files tracked
|
||||
- Files: `/opt/stacks/pulse/.env`
|
||||
- Impact: Although the .env file is not currently tracked in git, it exists on the filesystem with potentially real configuration. The `.gitignore` pattern `.env*` should prevent accidental commits, but if someone edits `.gitignore` or adds `--force`, secrets could leak. This is a human-error risk.
|
||||
- Fix approach: Verify that no real secrets are in the committed .env file. Document that `.env*` is gitignored and point developers to `.env.example`. Add a pre-commit hook with `detect-secrets` or similar to catch hardcoded secrets. Rotate any keys/tokens that might have been exposed in historical runs.
|
||||
|
||||
## Missing Graceful Shutdown for Workers
|
||||
|
||||
**Worker Cleanup on Pod Termination:**
|
||||
- Issue: The analyzer worker (line 40 in ARCHITECTURE.md) resets stale in-flight jobs on boot, but there is no graceful shutdown handler for long-running operations
|
||||
- Files: `lib/services/analyzer/worker.ts`, `lib/services/sync-scheduler.ts`, `lib/services/rmm/worker.ts`
|
||||
- Impact: If a worker is in the middle of processing and the container is killed, that job is left in a partially-complete state (e.g., partial analyzer analysis, incomplete RMM execution). On pod restart, the worker resets jobs but may lose partial work.
|
||||
- Fix approach: Implement a `SIGTERM` handler that stops accepting new jobs, finishes in-flight work, then exits cleanly. Use `process.on('SIGTERM', async () => { ... })`. Set Kubernetes `terminationGracePeriodSeconds` to allow time for cleanup. Log completion of final jobs.
|
||||
|
||||
## Pool Connection Leaks from Page Components
|
||||
|
||||
**Server Component Pool Usage:**
|
||||
- Issue: Page components like `app/admin/users/[id]/page.tsx` and `app/admin/roles/[id]/page.tsx` create Pools without closing them, relying on garbage collection
|
||||
- Files: `app/admin/users/[id]/page.tsx`, `app/admin/roles/[id]/page.tsx`
|
||||
- Impact: Each page render creates a new Pool(). In development with fast reloads, pools accumulate. While they will eventually be garbage-collected, this is inefficient and can cause "too many connections" errors in development if tests run fast enough.
|
||||
- Fix approach: Use the postgresClient singleton instead (which is already lazy-initialized). This is the same fix as the broader pool instantiation issue above. Pages should import `postgresClient` from `lib/services/postgres-client.ts` rather than creating new Pools.
|
||||
|
||||
## Performance: Large Page Sizes
|
||||
|
||||
**Pagination Defaults Not Optimized:**
|
||||
- Issue: `qbo-client.ts` uses a pageSize of 1000 (line 187), which is high for API response sizes
|
||||
- Files: `lib/services/qbo-client.ts`
|
||||
- Impact: A single API call fetching 1000 QuickBooks objects can be slow and memory-intensive. If the endpoint returns large objects, response times spike.
|
||||
- Fix approach: Reduce default page size to 100-250 and let callers opt in for larger pages if needed. Measure API response times for the most common queries and set pageSize accordingly.
|
||||
|
||||
## Idempotency Key Complexity
|
||||
|
||||
**Provider-Scoped Content Hash Subtle:**
|
||||
- Issue: Analyzer content_hash is provider-scoped (per ARCHITECTURE.md), meaning the same ticket can have one Claude analysis and one OpenRouter analysis, but this is easy to miss
|
||||
- Files: `lib/services/analyzer/pipeline.ts` (lines 239, 253), `lib/services/analyzer/worker.ts` (lines 181, 314)
|
||||
- Impact: If a developer switches the default LLM provider from Anthropic to OpenRouter and re-runs an analysis with `force=false`, the code will treat it as a new analysis because the provider is part of the idempotency key. This is correct behavior but non-obvious and could confuse operators.
|
||||
- Fix approach: Add a comment at the storage point explaining that `provider` is part of the idempotency key. Document in CLAUDE.md or ARCHITECTURE.md that switching providers intentionally allows re-analysis. Add a debug endpoint that shows all analyses for a ticket grouped by provider.
|
||||
|
||||
## Broken env Pattern in Datto RMM Sync
|
||||
|
||||
**Optional API URL with Fallback:**
|
||||
- Issue: `lib/services/datto-rmm-sync-service.ts` (line 38) has a fallback hardcoded URL: `process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net'`
|
||||
- Files: `lib/services/datto-rmm-sync-service.ts`
|
||||
- Impact: The hardcoded fallback is correct (Datto's standard endpoint), but this pattern is inconsistent with other integrations which throw if env vars are missing. If an env var is unset by mistake, it silently uses the public endpoint instead of failing loudly.
|
||||
- Fix approach: Check if DATTO_RMM_API_URL should be required or optional. If optional, document why. If required, remove the fallback and let the code throw. Audit other clients for similar silent fallbacks.
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-05-03*
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-05-03
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
- kebab-case for all files and directories (e.g., `postgres-client.ts`, `invite-user-form.tsx`, `entity-sync.ts`)
|
||||
- Nested directories use kebab-case (e.g., `lib/services/analyzer/`, `components/admin/users/`)
|
||||
|
||||
**Functions:**
|
||||
- camelCase for all functions (e.g., `getAutotaskClient()`, `transformCompany()`, `extractExplicitFromText()`)
|
||||
- Factory functions prefixed with `get` (e.g., `getAutotaskClient()`, `getDattoRmmClient()`)
|
||||
- Helper functions suffixed with descriptive intent (e.g., `relTime()`, `deriveSigningKey()`)
|
||||
- Private/internal functions prefixed with underscore: `_INTERNALS` objects expose internals for test access
|
||||
|
||||
**Variables:**
|
||||
- camelCase for all variables (e.g., `isLoading`, `setData`, `ticketNumber`)
|
||||
- Constants in UPPER_SNAKE_CASE (e.g., `MAX_EXPLICIT_LINKS`, `TICKET_NUMBER_REGEX`)
|
||||
- Database column names are always snake_case (e.g., `company_name`, `is_active`, `created_at`)
|
||||
|
||||
**Types:**
|
||||
- PascalCase for all type names (e.g., `ClassificationRule`, `WorkflowExecution`, `TicketData`)
|
||||
- Single-letter generics are acceptable (e.g., `queryEntity<T>()`)
|
||||
- Union types as literal strings (e.g., `type RuleType = 'branch_routing' | 'ticket_type'`)
|
||||
|
||||
**Components:**
|
||||
- PascalCase exported from kebab-case files (e.g., export `InviteUserForm` from `invite-user-form.tsx`)
|
||||
- Page components: `export default function ComponentName()` at end of file
|
||||
- Form components: follow `[Resource]Form` naming (e.g., `InviteUserForm`, `SignInForm`, `UserForm`)
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting:**
|
||||
- TypeScript strict mode enabled (`"strict": true` in `tsconfig.json`)
|
||||
- No explicit formatter config (ESLint handles style)
|
||||
- Indentation: 2 spaces (inferred from existing code)
|
||||
|
||||
**Linting:**
|
||||
- ESLint: `eslint.config.mjs` with Next.js config (`eslint-config-next/core-web-vitals`, `eslint-config-next/typescript`)
|
||||
- No additional custom rules beyond Next.js defaults
|
||||
- Type checking: `npx tsc --noEmit --pretty` (must pass before commit)
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Order:**
|
||||
1. Node.js built-ins (e.g., `fs`, `path`)
|
||||
2. Third-party packages (e.g., `next/server`, `zod`, `vitest`)
|
||||
3. Type imports (e.g., `import type { ... } from '...'`)
|
||||
4. Local imports from `@/*` (using path alias)
|
||||
5. Local imports from `./` (relative, less common)
|
||||
|
||||
**Path Aliases:**
|
||||
- Configured as `"@/*": ["./*"]` in `tsconfig.json`
|
||||
- Use `@/lib/...`, `@/components/...`, `@/app/...` always
|
||||
- Never use relative paths like `../../../` for imports
|
||||
|
||||
**Example import block** (from `/opt/stacks/pulse/components/admin/users/invite-user-form.tsx`):
|
||||
```typescript
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Pattern:**
|
||||
- All async functions use `try/catch` blocks
|
||||
- API routes: catch errors and return `NextResponse.json({ error, message }, { status })`
|
||||
- Standard status codes: `500` for runtime errors, `503` for missing/bad config, `401`/`403` from auth helpers
|
||||
- Error messages: include `error instanceof Error ? error.message : 'fallback message'`
|
||||
|
||||
**Example from `/opt/stacks/pulse/app/api/companies/route.ts`:**
|
||||
```typescript
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await postgresClient.query('SELECT * FROM companies ...');
|
||||
return NextResponse.json({ companies: result.rows.map(transformCompany) });
|
||||
} catch (error) {
|
||||
console.error('Error fetching companies from database:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch companies' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Client-side:** Use try/catch with `.json()` nulling:
|
||||
```typescript
|
||||
const [overviewRes, trendsRes] = await Promise.all([
|
||||
fetch('/api/dashboard/overview', { cache: 'no-store' }),
|
||||
fetch('/api/dashboard/trends', { cache: 'no-store' }),
|
||||
]);
|
||||
if (!overviewRes.ok) {
|
||||
const body = (await overviewRes.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${overviewRes.status}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
**Framework:** Plain `console` (no structured logging library)
|
||||
|
||||
**Patterns:**
|
||||
- `console.error()` for exceptions caught in try/catch (usually in API routes and services)
|
||||
- Include context: `console.error('Failed to fetch companies:', error)`
|
||||
- No `console.log()` for debugging (remove before commit per linter checks)
|
||||
|
||||
## Comments
|
||||
|
||||
**When to Comment:**
|
||||
- Explain *why*, not what (code shows the what)
|
||||
- Non-obvious logic or business rules
|
||||
- Performance-critical sections
|
||||
- Workarounds or hacks (mark with `// HACK:` or `// NOTE:`)
|
||||
|
||||
**JSDoc/TSDoc:**
|
||||
- Used sparingly on complex functions
|
||||
- Example from `lib/services/analyzer/link-discovery.ts`:
|
||||
```typescript
|
||||
/**
|
||||
* Marks refs in a RELATED TICKETS: block as high confidence
|
||||
*/
|
||||
export function extractExplicitFromText(text: string, source: string) { ... }
|
||||
```
|
||||
- Not required for simple getters/setters or obvious functions
|
||||
|
||||
## Function Design
|
||||
|
||||
**Size:**
|
||||
- Keep functions focused: one responsibility per function
|
||||
- Aim for <50 lines for page components, <30 for utilities
|
||||
- Complex operations broken into smaller helpers
|
||||
|
||||
**Parameters:**
|
||||
- Prefer object parameters for >3 arguments
|
||||
- Don't use `any` — use specific types
|
||||
- Use `Partial<T>` for optional object shapes
|
||||
|
||||
**Return Values:**
|
||||
- Async functions always return `Promise<T>` explicitly
|
||||
- Prefer `null` over `undefined` for missing values
|
||||
- Use discriminated unions for success/error returns in critical paths (see analyzer pipeline)
|
||||
|
||||
## Module Design
|
||||
|
||||
**Exports:**
|
||||
- Prefer `export` at declaration point rather than grouped re-exports
|
||||
- One main export per file (exception: barrel files in `components/ui/`)
|
||||
- Internal utilities prefixed with underscore: `_INTERNALS` object for test access
|
||||
|
||||
**Barrel Files:**
|
||||
- `components/ui/index.ts` exports all shadcn primitives
|
||||
- `lib/types/` has domain-specific barrel files (e.g., `lib/types/workflow.ts`, `lib/types/autotask.ts`)
|
||||
- Avoid deep nesting — import from files, not directories unless barrel exists
|
||||
|
||||
## Database Transformations
|
||||
|
||||
**Pattern:** All columns are `snake_case` in database. API responses transform to `camelCase`.
|
||||
|
||||
**Example from `/opt/stacks/pulse/app/api/companies/route.ts`:**
|
||||
```typescript
|
||||
function transformCompany(row: any) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
companyName: row.company_name,
|
||||
companyType: row.company_type,
|
||||
isActive: row.is_active,
|
||||
// ... all snake_case → camelCase
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
No ORM is used — all transforms are manual per handler.
|
||||
|
||||
## Shared Components & Libraries
|
||||
|
||||
**UI Components:**
|
||||
- shadcn primitives live in `components/ui/`
|
||||
- Feature-specific components in sibling directories (e.g., `components/dashboard/`, `components/admin/`)
|
||||
- Icons: Always use `lucide-react` (e.g., `import { Loader2, Send } from 'lucide-react'`)
|
||||
|
||||
**Tables:**
|
||||
- Use `@tanstack/react-table` via `components/admin/DataTable.tsx` wrapper
|
||||
- Example: `<DataTable columns={columns} data={data} />`
|
||||
|
||||
**Modals:**
|
||||
- Use `components/admin/DetailModal.tsx` for entity details
|
||||
- Follows card + tabs pattern (formatted/raw)
|
||||
|
||||
**Navigation:**
|
||||
- Use `components/navigation/app-navigation.tsx` (`NavigationMenu` from Radix)
|
||||
- Dropdowns prefer `@radix-ui/react-dropdown-menu` over submenus
|
||||
|
||||
**Toasts:**
|
||||
- Use `sonner` library: `import { toast } from 'sonner'`
|
||||
- Patterns: `toast.success()`, `toast.error()`, `toast.info()`
|
||||
|
||||
**Forms:**
|
||||
- Use `react-hook-form` + Zod for validation
|
||||
- Only in admin/auth forms — NOT in every page
|
||||
- Pattern: `useForm()` with `zodResolver()`, then `<Form>` wrapper from shadcn
|
||||
|
||||
**Charts:**
|
||||
- Use `recharts` for data visualization (e.g., `<BarChart>`, `<LineChart>`)
|
||||
|
||||
## What NOT to Introduce
|
||||
|
||||
**Forbidden:**
|
||||
- No ORMs (Prisma, TypeORM, etc.) — use `postgresClient` singleton and manual transforms
|
||||
- No server actions (`'use server'`) — use API routes called via `fetch()` from clients
|
||||
- No additional state libraries (SWR, react-query, TanStack Query) — match local `useState` + `fetch` pattern
|
||||
- No change to authentication (Better Auth is final)
|
||||
- No editing of committed migrations — always create new numbered ones
|
||||
|
||||
**Rationale:**
|
||||
- Keeps codebase lean and explicit
|
||||
- Reduces abstraction overhead
|
||||
- Makes data flow (DB → API → Client) visible
|
||||
|
||||
## Migrations
|
||||
|
||||
**Creating a new migration:**
|
||||
1. Number it sequentially: if last is `041_create_engagement_tables.sql`, next is `042_*.sql`
|
||||
2. Use `IF NOT EXISTS` for CREATE statements
|
||||
3. Use `ON CONFLICT DO NOTHING` for seed data INSERT
|
||||
4. Never drop columns or tables without explicit guard
|
||||
5. Include audit columns: `created_at`, `updated_at`, `synced_at`, `is_deleted`, `deleted_at`
|
||||
|
||||
**Example structure:**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS new_table (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO new_table (id, name) VALUES (1, 'Example')
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
**Note:** Migrations are applied in alphabetical order. Existing duplicates (002, 004, 009) exist; respect that order.
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-05-03*
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-05-03
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**Autotask PSA:**
|
||||
- Primary integration — syncs projects, tickets, time entries, contacts, configuration items to Postgres
|
||||
- SDK/Client: `lib/services/autotask-factory.ts` → `getAutotaskClient()`, `lib/services/autotask-client.ts`
|
||||
- Auth env vars: `AUTOTASK_API_URL`, `AUTOTASK_USERNAME`, `AUTOTASK_SECRET`, `AUTOTASK_API_INTEGRATION_CODE`
|
||||
- Webhook secret: `AUTOTASK_WEBHOOK_SECRET` — HMAC-SHA1 verification in `lib/services/webhook-service.ts`
|
||||
- Sync service: `lib/services/entity-sync.ts` (incremental via `lastTrackedModificationDateTime` when supported)
|
||||
- Webhook handler: `/api/webhooks/autotask` (public endpoint)
|
||||
|
||||
**Microsoft Graph:**
|
||||
- User/employee engagement data (calendar, presence, mail metrics)
|
||||
- SDK/Client: `lib/services/msgraph-factory.ts` → `getMsgraphClient()`, `isMsgraphConfigured()`, `lib/services/msgraph-client.ts`
|
||||
- Auth env vars: `MSGRAPH_CLIENT_ID`, `MSGRAPH_CLIENT_SECRET`, `MSGRAPH_TENANT_ID` (specific tenant, NOT 'common')
|
||||
- Flow: client_credentials OAuth — no user interaction required
|
||||
- Sync service: `lib/services/engagement-sync-service.ts`
|
||||
- API: `/api/engagement/sync` (POST fire-and-forget, GET status), `/api/engagement/summary`, `/api/engagement/users`, `/api/engagement/user/[userId]`
|
||||
- Scheduler: `engagement-daily` task (disabled by default, 6am UTC)
|
||||
|
||||
**Microsoft OAuth (Login):**
|
||||
- SSO via Microsoft identity
|
||||
- Auth env vars: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` (same as above; tenant-aware)
|
||||
- Integrated into Better Auth 1.4 — `lib/auth.ts` configures `microsoft` social provider
|
||||
- Account linking enabled: Microsoft OAuth can link to existing accounts
|
||||
|
||||
**Datto RMM:**
|
||||
- Remote device management — sites, devices, alerts
|
||||
- SDK/Client: `lib/services/datto-rmm-factory.ts` → `getDattoRMMClient()`, `lib/services/datto-rmm-client.ts`
|
||||
- Auth env vars: `DATTO_RMM_API_URL`, `DATTO_RMM_API_KEY`, `DATTO_RMM_API_SECRET`
|
||||
- Sync service: `lib/services/datto-rmm-sync-service.ts` (on-demand or scheduled)
|
||||
- API: `/api/datto-rmm/sync` (public POST, fire-and-forget)
|
||||
- Alternate simpler client: `lib/services/datto-rmm-client-simple.ts` available
|
||||
|
||||
**Veeam Backup & Replication:**
|
||||
- Backup infrastructure data — sites, repositories, jobs, backup chains
|
||||
- SDK/Client: `lib/services/veeam-factory.ts` → `getVeeamClient()`, `isVeeamConfigured()`, `lib/services/veeam-client.ts`
|
||||
- Auth env vars: `VEEAM_VSPC_URL`, `VEEAM_VSPC_API_KEY`
|
||||
- Shadow mode: `VEEAM_RPO_SHADOW_MODE=true` (default)
|
||||
- Sync service: `lib/services/veeam-sync-service.ts`
|
||||
- API: `/api/veeam/sync` (public POST), `/api/veeam/rpo-check` (public POST, RPO health check)
|
||||
|
||||
**Auvik:**
|
||||
- Network monitoring — devices, interfaces, metrics
|
||||
- SDK/Client: `lib/services/auvik-factory.ts` → `getAuvikClient()`, `lib/services/auvik-client.ts`
|
||||
- Auth env vars: `AUVIK_API_URL`, `AUVIK_API_USER`, `AUVIK_API_KEY`
|
||||
|
||||
**Addigy:**
|
||||
- Apple device management
|
||||
- SDK/Client: `lib/services/addigy-factory.ts` → `getAddigyClient()`, `clearAddigyClientCache()`, `lib/services/addigy-client.ts`
|
||||
- Auth env vars: `ADDIGY_API_URL` (default: `https://api.addigy.com/api/v2`), `ADDIGY_API_TOKEN`, `ADDIGY_ORG_ID` (optional)
|
||||
|
||||
**IT Glue:**
|
||||
- Documentation platform — organizations, configurations, passwords, flexible assets
|
||||
- SDK/Client: `lib/services/itglue-client.ts` (direct client, no factory)
|
||||
- Auth env var: `ITGLUE_API_KEY` (x-api-key header)
|
||||
- Base URL: `https://api.itglue.com`
|
||||
- Format: JSON:API (application/vnd.api+json)
|
||||
- Search function for analyzer: `lib/services/analyzer/itglue-search.ts` (redacted output before LLM prompts)
|
||||
- Sync service: `lib/services/itglue-sync-service.ts`
|
||||
- API: `/api/itglue/sync` (public POST, fire-and-forget)
|
||||
|
||||
**Mimecast:**
|
||||
- Email security — threat/policy logs
|
||||
- SDK/Client: `lib/services/mimecast-client.ts` (direct client, no factory)
|
||||
- Auth env vars: `MIMECAST_CLIENT_ID`, `MIMECAST_CLIENT_SECRET`, `MIMECAST_ACCOUNT_CODE`, `MIMECAST_BASE_URL` (default: `https://api.services.mimecast.com`)
|
||||
|
||||
**SentinelOne:**
|
||||
- EDR/XDR — agents, threats, sites, groups
|
||||
- SDK/Client: `lib/services/sentinelone-client.ts` (direct client, no factory)
|
||||
- Auth env var: `S1_API_KEY` or `SENTINELONE_API_KEY`
|
||||
- Sync service: `lib/services/sentinelone-sync-service.ts`
|
||||
- API: `/api/sentinelone/sync` (public POST, fire-and-forget)
|
||||
|
||||
**Duo Security:**
|
||||
- 2FA/MFA monitoring — users, phones, auth logs, accounts
|
||||
- SDK/Client: `lib/services/duo-client.ts` (direct client, no factory)
|
||||
- Auth env vars: `DUO_IKEY`, `DUO_SKEY`, `DUO_HOST` — HMAC-SHA1 request signing
|
||||
- Supports both Accounts API (parent) and Admin API (parent + child accounts)
|
||||
- API: `/api/duo` (public POST; Duo-specific endpoints for sync, data retrieval)
|
||||
|
||||
**Zoom:**
|
||||
- Videoconferencing — meeting analytics, users
|
||||
- SDK/Client: `lib/services/zoom-factory.ts` → `getZoomClient()`, `isZoomConfigured()`, `lib/services/zoom-client.ts`
|
||||
- Auth env vars: `ZOOM_ACCOUNT_ID`, `ZOOM_CLIENT_ID`, `ZOOM_CLIENT_SECRET` — Server-to-Server OAuth
|
||||
- Sync service: `lib/services/zoom-sync-service.ts`
|
||||
- API: `/api/zoom/sync` (public POST, fire-and-forget)
|
||||
|
||||
**QuickBooks Online:**
|
||||
- Accounting — invoices, payments, deposits, purchases, journal entries
|
||||
- SDK/Client: `lib/services/qbo-client.ts` (singleton, uses DATABASE_URL)
|
||||
- Auth env vars: `QBO_CLIENT_ID`, `QBO_CLIENT_SECRET`, `QBO_REALM_ID`
|
||||
- OAuth2 token management: tokens stored in `qbo_tokens` table (migration 014), auto-refreshed
|
||||
- Sandbox mode: `QBO_SANDBOX=true` switches to sandbox URL
|
||||
- Callback: `/api/qbo/auth` (public POST, OAuth redirect), `/api/qbo/disconnect` (public POST)
|
||||
- Sync service: `lib/services/qbo-sync-service.ts`
|
||||
- API: `/api/qbo/sync` (public POST, fire-and-forget)
|
||||
|
||||
**Zabbix:**
|
||||
- Infrastructure monitoring — events, problems, hosts, metrics
|
||||
- SDK/Client: `lib/services/zabbix-client.ts` (direct client, no factory)
|
||||
- Auth env vars: `ZABBIX_API_URL`, `ZABBIX_API_TOKEN`
|
||||
- Webhook handler: `/api/zabbix/webhook` (public POST)
|
||||
|
||||
**SalesBldr:**
|
||||
- Sales engagement platform
|
||||
- SDK/Client: `lib/services/salesbldr-client.ts` (direct client, no factory)
|
||||
- Auth env vars: `SALESBLDR_API_URL`, `SALESBLDR_API_KEY`
|
||||
|
||||
**ipinfo.io:**
|
||||
- IP geolocation (optional)
|
||||
- Auth env var: `IPINFO_TOKEN` (optional, defaults to empty)
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- PostgreSQL 16 — Primary data store
|
||||
- Connection: `POSTGRES_HOST`, `POSTGRES_PORT` (5432), `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` (or `DATABASE_URL`)
|
||||
- Client: `lib/services/postgres-client.ts` singleton via `postgresClient.query()`, `.transaction()`, `.upsert()`, `.bulkUpsert()`
|
||||
- Migrations: `migrations/*.sql` (numbered sequentially, applied in alphabetical order on Postgres init)
|
||||
- All columns: `snake_case`; API responses: `camelCase` (manual transformation)
|
||||
- Audit columns: `created_at`, `updated_at`, `synced_at`, `is_deleted`, `deleted_at`
|
||||
|
||||
**Cache:**
|
||||
- Redis 7 (optional)
|
||||
- Connection: `REDIS_URL` (e.g., `redis://localhost:6380` in Docker)
|
||||
- Client: `lib/services/redis-client.ts` via `getRedisClient()`, `getCachedData()`, `setCachedData()`, `flushCache()`
|
||||
- TTL default: 300 seconds (5 minutes)
|
||||
- Graceful fallback: if REDIS_URL unset or connection fails, caching disabled
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Auth Provider:**
|
||||
- Better Auth 1.4 — Magic link + TOTP 2FA + Microsoft OAuth
|
||||
- Implementation: `lib/auth.ts` configures plugins, session TTL, roles (user, admin, super-admin)
|
||||
- Database: Tables created in migration 012 (user, session, account, verification)
|
||||
- Default admin bootstrap: via `DEFAULT_ADMIN_EMAIL` env var, `lib/bootstrap.ts`
|
||||
- Session cookie handling: next-js plugin enabled
|
||||
- Account linking: Microsoft OAuth can link to existing accounts
|
||||
- Roles: `super-admin`, `admin`, `user`
|
||||
- RBAC: `lib/permissions.ts` defines `ac` (access-control) rules
|
||||
|
||||
**API Route Auth:**
|
||||
- Helpers in `lib/auth-utils.ts`: `requireAuth()`, `requireAdmin()`, `requireSuperAdmin()`, `requirePermission(resource, action)`
|
||||
- Middleware: `middleware.ts` checks session cookie existence; role verification happens in API routes
|
||||
- Public routes: hardcoded in `middleware.ts` (webhooks, sync endpoints, health checks, auth callbacks, mobile, openclaw, kiosk, legal)
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Error Tracking:**
|
||||
- None (no Sentry/DataDog integration detected)
|
||||
|
||||
**Logs:**
|
||||
- Console logging: `console.log()`, `console.error()`
|
||||
- Slow query warnings: queries > 1000ms logged in `postgresClient.query()`
|
||||
|
||||
## AI & LLM
|
||||
|
||||
**Analyzer Pipeline:**
|
||||
- Primary: Anthropic Claude API
|
||||
- SDK: `@anthropic-ai/sdk` 0.91.1
|
||||
- Auth: `ANTHROPIC_API_KEY` env var
|
||||
- Models: Haiku (stage 1, 6), Sonnet (stage 2, 5), Opus (stage 3, 4)
|
||||
- Worker: `lib/services/analyzer/worker.ts` (auto-starts in production, polled every 2s)
|
||||
- Pipeline: `lib/services/analyzer/pipeline.ts` (6 stages: triage, analysis, reasoning, fingerprint, aggregate, link-discovery)
|
||||
- Cost guard: `lib/services/analyzer/cost-guard.ts` (skips Opus above $2.00 estimated cost, flags for review)
|
||||
- Idempotency: per-request provider-scoped (`anthropic` | `openrouter`); same ticket can have both
|
||||
|
||||
**Alternate Provider:**
|
||||
- OpenRouter (opt-in per request)
|
||||
- SDK: HTTP client, OpenAI-compatible format
|
||||
- Auth: `OPENROUTER_API_KEY` env var
|
||||
- Models: DeepSeek V4 Flash (fast), DeepSeek V4 Pro (standard), DeepSeek R1 (reasoning)
|
||||
- Call layer: `lib/services/llm/openrouter-call.ts`
|
||||
- Provider hints: `data_collection: 'deny'` (privacy floor), `sort: 'throughput'`, `allow_fallbacks: true`
|
||||
|
||||
**Model Constants:**
|
||||
- `lib/services/llm/models.ts` — canonical model IDs and stage-model mappings
|
||||
- Anthropic: `claude-haiku-4-5`, `claude-sonnet-4-6`, `claude-opus-4-7`
|
||||
- OpenRouter: `deepseek/deepseek-v4-flash`, `deepseek/deepseek-v4-pro`, `deepseek/deepseek-r1-0528`
|
||||
|
||||
**Pricing & Token Tracking:**
|
||||
- `lib/services/llm/pricing.ts` — per-token cost calculation
|
||||
- `lib/services/analyzer/cost-guard.ts` — estimated cost ceiling enforcement
|
||||
|
||||
## File Storage
|
||||
|
||||
**Backblaze B2 (S3-compatible):**
|
||||
- LogLift evidence upload/download
|
||||
- Auth env vars: `B2_KEY_ID`, `B2_APP_KEY`
|
||||
- Config env vars: `B2_BUCKET` (default: `wulf-audits`), `B2_REGION` (default: `us-west-002`), `B2_ENDPOINT` (default: `s3.us-west-002.backblazeb2.com`)
|
||||
- SDK/Client: `lib/services/b2/client.ts` — presigned URLs (AWS SigV4), download/upload with object-key validation
|
||||
- Max download: 25 MB
|
||||
- Object key format validation: `{client_id_or_uuid}/{computer_name}/eventlogs_{timestamp}.json.gz` (path-traversal guard)
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting:**
|
||||
- Docker Compose (provided in `docker-compose.yml`)
|
||||
- Traefik labels for reverse-proxy routing at `pulse.wulfconsulting.cloud`
|
||||
- Standalone Next.js output for containerization
|
||||
|
||||
**CI Pipeline:**
|
||||
- None detected; local testing only (vitest)
|
||||
- Build: `npm run build` (turbopack)
|
||||
- Type check: `npx tsc --noEmit --pretty`
|
||||
- Lint: `npm run lint` (eslint)
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming Webhooks:**
|
||||
- `/api/webhooks/autotask` — Autotask event notifications (public, HMAC-SHA1 verified)
|
||||
- `/api/zabbix/webhook` — Zabbix problem notifications (public)
|
||||
- `/api/rmm/loglift` — LogLift evidence uploads (public, verified via `x-openclaw-key` header)
|
||||
- `/api/qbo/auth` — QuickBooks OAuth callback (public)
|
||||
- `/api/qbo/disconnect` — QBO token revocation (public)
|
||||
|
||||
**Outgoing Webhooks:**
|
||||
- Autotask write-back: workflow engine executes Autotask API calls (POST notes, status updates, custom fields)
|
||||
- Workflow execution: `lib/services/workflow-engine.ts` chains actions after classification
|
||||
|
||||
**Fire-and-Forget Sync Endpoints:**
|
||||
- `/api/datto-rmm/sync` (POST)
|
||||
- `/api/veeam/sync` (POST)
|
||||
- `/api/veeam/rpo-check` (POST)
|
||||
- `/api/itglue/sync` (POST)
|
||||
- `/api/sentinelone/sync` (POST)
|
||||
- `/api/engagement/sync` (POST)
|
||||
- `/api/zoom/sync` (POST)
|
||||
- `/api/qbo/sync` (POST)
|
||||
- All public, triggered by admin UI or cron schedule in sync-scheduler
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars (production):**
|
||||
- `BETTER_AUTH_URL` — must match deployed domain
|
||||
- `BETTER_AUTH_SECRET` — session signing secret
|
||||
- `DATABASE_URL` or `POSTGRES_*` — database connection
|
||||
- `AUTOTASK_API_URL`, `AUTOTASK_USERNAME`, `AUTOTASK_SECRET`, `AUTOTASK_API_INTEGRATION_CODE` — Autotask API
|
||||
- `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID` — OAuth login
|
||||
- `ANTHROPIC_API_KEY` — AI Analyzer (required if analyzer enabled)
|
||||
- `DEFAULT_ADMIN_EMAIL` — initial super-admin account
|
||||
|
||||
**Optional env vars:**
|
||||
- `REDIS_URL` — enables caching; graceful no-op if missing
|
||||
- `MSGRAPH_*` — Microsoft Graph (engagement sync)
|
||||
- `DATTO_RMM_*`, `VEEAM_VSPC_*`, `AUVIK_*`, `ADDIGY_*`, `ITGLUE_API_KEY`, `MIMECAST_*`, `S1_API_KEY`, `DUO_*`, `ZOOM_*`, `QBO_*`, `ZABBIX_*`, `SALESBLDR_*` — per-integration
|
||||
- `OPENROUTER_API_KEY` — alternate LLM provider
|
||||
- `B2_*` — LogLift evidence storage
|
||||
- `IPINFO_TOKEN` — optional IP geolocation
|
||||
|
||||
**Secrets location:**
|
||||
- `.env.local` (development, mounted read-only in Docker)
|
||||
- Environment variables passed to container (production)
|
||||
- *Note: `.env` file is committed to the repository; treat values as potentially real.*
|
||||
|
||||
**Integration disable mechanism:**
|
||||
- Two sources (merged):
|
||||
1. `INTEGRATIONS_DISABLED` env var (legacy, comma/space-separated keys; aliases: `sentinelone` → `s1`, `datto` → `datto_rmm`, `it-glue` → `itglue`, `ms-graph` → `msgraph`)
|
||||
2. `integration_settings` table (migration 081) — admin-toggled at `/admin/integrations` without restart; cache invalidation immediate; audit columns: `disabled_by`, `disabled_at`, `disabled_reason`
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-05-03*
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-05-03
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- TypeScript 5 - Entire codebase, strict mode enabled
|
||||
- JavaScript/JSX - React components via TypeScript with JSX support
|
||||
|
||||
**Secondary:**
|
||||
- SQL - PostgreSQL migrations and queries
|
||||
- Bash - Build and deployment scripts
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
- Node.js (version inferred from package.json compatibility)
|
||||
- Next.js 16.1.1 running on port 3100
|
||||
|
||||
**Package Manager:**
|
||||
- npm (lockfile: package-lock.json)
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core:**
|
||||
- Next.js 16.1.1 - App Router with `output: 'standalone'` for Docker, React Compiler enabled
|
||||
- React 19.2.3 - Server and client components, React Compiler active
|
||||
- Better Auth 1.4.10 - Authentication with magic link, TOTP 2FA, Microsoft OAuth
|
||||
|
||||
**UI & Styling:**
|
||||
- Tailwind CSS 4.1.18 - Utility-first styling
|
||||
- shadcn/ui (via Radix UI primitives) - Component library: `components/ui/`
|
||||
- @radix-ui packages: accordion, alert-dialog, checkbox, collapsible, dialog, dropdown-menu, label, navigation-menu, popover, progress, scroll-area, select, separator, slot, switch, tabs
|
||||
- Recharts 3.7.0 - Charts and graphs (analytics/dashboards)
|
||||
- Lucide React 0.562.0 - Icon library
|
||||
- Sonner 2.0.7 - Toast notifications
|
||||
- cmdk 1.1.1 - Command palette component
|
||||
|
||||
**Forms & Validation:**
|
||||
- react-hook-form 7.70.0 - Form state management (admin/auth only)
|
||||
- Zod 4.3.5 - Type-safe schema validation
|
||||
- @hookform/resolvers 5.2.2 - Form resolver for Zod
|
||||
|
||||
**Tables & Data:**
|
||||
- @tanstack/react-table 8.21.3 - Headless table library with sorting, pagination, search
|
||||
- react-markdown 10.1.0 - Markdown rendering
|
||||
- remark-gfm 4.0.1 - GitHub-flavored markdown support
|
||||
|
||||
**Utilities:**
|
||||
- date-fns 4.1.0 - Date manipulation and formatting
|
||||
- react-day-picker 9.13.0 - Calendar date picker
|
||||
- clsx 2.1.1 - Conditional className utility
|
||||
- tailwind-merge 3.4.0 - Merge Tailwind class conflicts
|
||||
- class-variance-authority 0.7.1 - CSS-in-JS variant management
|
||||
- next-themes 0.4.6 - Dark mode theme switching
|
||||
|
||||
## Testing & Build
|
||||
|
||||
**Testing:**
|
||||
- vitest 4.1.5 - Unit and integration testing runner
|
||||
- Run: `npm test` (run once), `npm run test:watch` (watch mode)
|
||||
- Test coverage for: `lib/services/analyzer/**`, `lib/services/rmm/**`, `lib/services/b2/**`, `lib/services/analyzer/link-discovery.test.ts`
|
||||
|
||||
**Build/Dev:**
|
||||
- Turbopack (via Next.js 16) - Fast bundler for development and production
|
||||
- ESLint 9.39.2 - Linting (with eslint-config-next 16.1.1)
|
||||
- TypeScript - Type checking via `npx tsc --noEmit --pretty`
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**Critical:**
|
||||
- pg 8.11.0 - PostgreSQL client (no ORM); queries via `postgresClient` singleton
|
||||
- ioredis 5.9.0 - Redis client for caching; optional (graceful fallback if REDIS_URL unset)
|
||||
- node-cron 4.2.1 - Job scheduler for sync tasks and workflows
|
||||
- @anthropic-ai/sdk 0.91.1 - Anthropic Claude API client for AI Ticket Analyzer pipeline
|
||||
|
||||
**External API Integrations:**
|
||||
- Better Auth ecosystem packages - OAuth, 2FA, session management
|
||||
- nodemailer 7.0.12 - Email delivery for magic link auth
|
||||
|
||||
**Development:**
|
||||
- babel-plugin-react-compiler 1.0.0 - React Compiler for optimized renders
|
||||
- shadcn 4.6.0 - CLI tool for adding shadcn/ui components
|
||||
- baseline-browser-mapping 2.10.8 - Browser compatibility mapping
|
||||
- tw-animate-css 1.4.0 - Tailwind animation utilities
|
||||
|
||||
## Configuration
|
||||
|
||||
**Environment Variables:**
|
||||
- `BETTER_AUTH_URL` - Base URL for auth (e.g., `http://localhost:3100` or `https://pulse.wulfconsulting.cloud`)
|
||||
- `BETTER_AUTH_SECRET` - Secret key for session signing
|
||||
- `DATABASE_URL` or `POSTGRES_*` - PostgreSQL connection (host, port, db, user, password)
|
||||
- `REDIS_URL` - Redis connection (e.g., `redis://localhost:6380` in Docker compose)
|
||||
- `SESSION_TIMEOUT_SECONDS` - Session TTL (default: 86400 / 24 hours)
|
||||
- `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID` - Microsoft OAuth for login (tenant is specific, not 'common')
|
||||
- `DEFAULT_ADMIN_EMAIL` - Bootstrap admin account email
|
||||
- Integration env vars: prefixed by service (e.g., `AUTOTASK_*`, `DATTO_RMM_*`, `MSGRAPH_*`, etc.) — see INTEGRATIONS.md
|
||||
|
||||
**TypeScript Config:**
|
||||
- Path alias: `@/*` maps to project root for cleaner imports
|
||||
- Target: ES2017
|
||||
- Strict mode enabled
|
||||
- Config: `tsconfig.json`
|
||||
|
||||
**Next.js Config:**
|
||||
- File: `next.config.ts`
|
||||
- Standalone output for Docker deployment
|
||||
- React Compiler enabled
|
||||
- Image domains: configurable (currently empty)
|
||||
|
||||
**Build Output:**
|
||||
- `npm run build` → Next.js standalone app in `.next/`
|
||||
- `npm run start` → Starts production server on port 3100
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- Node.js 18+ (inferred from Next.js 16 compatibility)
|
||||
- npm 8+
|
||||
- PostgreSQL 16 (local or Docker)
|
||||
- Redis 7 (optional, enables caching)
|
||||
- Docker & Docker Compose (for full stack)
|
||||
|
||||
**Production:**
|
||||
- Docker with docker-compose.yml provided
|
||||
- Postgres 16 container (applies migrations on init)
|
||||
- Redis 7 container on port 6380 (custom, not 6379)
|
||||
- Next.js app container on port 3100
|
||||
- Traefik integration available (via labels in docker-compose.yml) for routing at `pulse.wulfconsulting.cloud`
|
||||
|
||||
**Deployment:**
|
||||
- Container-based: standalone Next.js image with migrations applied via Postgres init
|
||||
- Volume mounts for data persistence: `redis_data`, `postgres_data`
|
||||
- Environment configuration via `.env.local` file mounted read-only
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-05-03*
|
||||
|
|
@ -1,418 +0,0 @@
|
|||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-05-03
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
pulse/
|
||||
├── app/ # Next.js App Router pages + API routes
|
||||
│ ├── api/ # API route handlers (GET/POST/PATCH/DELETE)
|
||||
│ │ ├── admin/ # Admin settings, users, roles, integrations
|
||||
│ │ ├── analyzer/ # Ticket analysis, aggregate reports, IT Glue
|
||||
│ │ ├── auth/ # Better Auth endpoints
|
||||
│ │ ├── webhooks/ # Autotask webhook endpoint (public)
|
||||
│ │ ├── sync/ # Sync trigger endpoints (public, called by scheduler)
|
||||
│ │ ├── health # Health check (public)
|
||||
│ │ ├── integrations/ # Integration status (public)
|
||||
│ │ └── …/ # Other feature APIs (companies, tickets, etc.)
|
||||
│ ├── admin/ # /admin landing page + sub-pages (sync, workflow, RMM, IT Glue)
|
||||
│ ├── analyzer/ # /analyzer/* pages (tickets, reports, queue)
|
||||
│ ├── dashboard/ # /dashboard — KPI home
|
||||
│ ├── status/ # /status — integration + worker health
|
||||
│ ├── configuration-items/ # /configuration-items — CI browser
|
||||
│ ├── engagement/ # /engagement/* — MS Graph employee data
|
||||
│ ├── backup-status/ # /backup-status — Veeam RPO dashboard
|
||||
│ ├── veeam-*/ # Veeam comparison + ticket analysis pages
|
||||
│ ├── auth/ # /auth/sign-in, /auth/2fa — magic link flow
|
||||
│ ├── settings/ # /settings, /settings/security — user profile
|
||||
│ ├── kiosk/ # /kiosk/* — public field app (no nav)
|
||||
│ ├── mobile/ # /mobile/* — mobile API endpoints (no nav)
|
||||
│ ├── styles/ # CSS modules (brand.css, globals.css)
|
||||
│ ├── layout.tsx # Root layout (AppNavigation, CommandPalette, Toaster)
|
||||
│ └── page.tsx # Redirect to /dashboard
|
||||
│
|
||||
├── lib/ # Shared utilities, services, types
|
||||
│ ├── services/ # Integration clients + sync/worker logic (~50 files)
|
||||
│ │ ├── autotask-*.ts # Autotask API client + sync
|
||||
│ │ ├── datto-rmm-*.ts # Datto RMM client + sync
|
||||
│ │ ├── itglue-*.ts # IT Glue client + sync
|
||||
│ │ ├── veeam-*.ts # Veeam VSPC client + sync
|
||||
│ │ ├── msgraph-*.ts # MS Graph client + engagement sync
|
||||
│ │ ├── engagement-*.ts # Engagement dashboard data
|
||||
│ │ ├── analyzer/ # Analyzer pipeline (8 subdirs + 20 files)
|
||||
│ │ │ ├── pipeline.ts # 7-stage orchestration
|
||||
│ │ │ ├── worker.ts # 2s polling loop (auto-starts in production)
|
||||
│ │ │ ├── persistence.ts # Read/write analyzer_analyses
|
||||
│ │ │ ├── link-discovery.ts # Find related tickets
|
||||
│ │ │ ├── itglue-search.ts # Redacted IT Glue lookups
|
||||
│ │ │ ├── asset-audit/ # IT Glue write-back audits
|
||||
│ │ │ ├── stages/ # 7 pipeline stages
|
||||
│ │ │ └── fixtures/ # Test data
|
||||
│ │ ├── rmm/ # RMM executor + Overshell
|
||||
│ │ │ ├── executor.ts # Validate + enqueue
|
||||
│ │ │ ├── worker.ts # 5s polling loop
|
||||
│ │ │ ├── scripts/ # Script registry (code-defined)
|
||||
│ │ │ ├── target-resolver.ts
|
||||
│ │ │ └── …/
|
||||
│ │ ├── llm/ # LLM dispatch + cost tracking
|
||||
│ │ │ ├── call.ts # Claude + OpenRouter routing
|
||||
│ │ │ ├── models.ts # Stage → model mapping
|
||||
│ │ │ └── pricing.ts # Cost estimation
|
||||
│ │ ├── b2/ # Backblaze B2 object storage
|
||||
│ │ ├── sync-scheduler.ts # node-cron singleton (auto-starts)
|
||||
│ │ ├── sync-service.ts # Incremental sync orchestration
|
||||
│ │ ├── entity-sync.ts # Per-entity Autotask → Postgres
|
||||
│ │ ├── postgres-client.ts # DB singleton (query/transaction/upsert)
|
||||
│ │ ├── integration-health.ts # Health check orchestration
|
||||
│ │ ├── email.ts # Email service (magic link)
|
||||
│ │ ├── webhook-service.ts # HMAC verification
|
||||
│ │ ├── redis-client.ts # Redis cache (optional)
|
||||
│ │ ├── rate-limiter.ts # RMM execute limits
|
||||
│ │ ├── audit.ts # Audit log writes
|
||||
│ │ └── (25+ other services for Zoom, Duo, Mimecast, S1, etc.)
|
||||
│ │
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ │ ├── autotask.ts # Autotask API shapes
|
||||
│ │ ├── analyzer.ts # Analysis, stage execution, aggregate report shapes
|
||||
│ │ ├── database.ts # DB row types (tickets, companies, etc.)
|
||||
│ │ ├── sync.ts # Sync schedule + progress shapes
|
||||
│ │ ├── veeam.ts # Veeam shapes
|
||||
│ │ ├── workflow.ts # Workflow engine shapes
|
||||
│ │ ├── datto-rmm.ts # RMM types
|
||||
│ │ └── …/
|
||||
│ │
|
||||
│ ├── auth.ts # Better Auth config + session type
|
||||
│ ├── auth-utils.ts # requireAuth(), requireAdmin(), requirePermission()
|
||||
│ ├── auth-client.ts # Client-side Better Auth SDK
|
||||
│ ├── permissions.ts # Role → resource → action matrix
|
||||
│ ├── bootstrap.ts # DEFAULT_ADMIN_EMAIL user creation
|
||||
│ ├── status-registry.ts # Priority/status color maps (shared UI state)
|
||||
│ ├── utils.ts # Misc helpers
|
||||
│ │
|
||||
│ └── utils/ # Utility modules
|
||||
│ ├── env.ts # Type-safe env var access
|
||||
│ └── …/
|
||||
│
|
||||
├── components/ # React components
|
||||
│ ├── ui/ # shadcn/ui primitives (50+ files)
|
||||
│ │ ├── button.tsx # Base button component
|
||||
│ │ ├── card.tsx # Card + CardHeader/CardTitle/CardContent
|
||||
│ │ ├── dialog.tsx # Modal + trigger + close
|
||||
│ │ ├── table.tsx # HTML table wrapper with Tailwind classes
|
||||
│ │ ├── input.tsx, select.tsx, checkbox.tsx, etc.
|
||||
│ │ ├── skeleton.tsx # Loading placeholder
|
||||
│ │ ├── skeleton-helpers.tsx # SkeletonRow, SkeletonCard, etc.
|
||||
│ │ ├── empty-state.tsx # Zero-data UI
|
||||
│ │ ├── status-badge.tsx # Status pill driven by status-registry
|
||||
│ │ ├── status-light.tsx # Status indicator dot
|
||||
│ │ ├── form.tsx # react-hook-form bridge
|
||||
│ │ └── …/
|
||||
│ │
|
||||
│ ├── navigation/ # Top bar + page structure
|
||||
│ │ ├── app-navigation.tsx # NavigationMenu + UserMenu + ThemeToggle
|
||||
│ │ ├── page-header.tsx # Title + breadcrumbs + action slot
|
||||
│ │ ├── command-palette.tsx # Cmd+K launcher
|
||||
│ │ ├── mobile-nav.tsx # Mobile hamburger menu
|
||||
│ │ ├── user-menu.tsx # User profile dropdown
|
||||
│ │ └── status-indicator.tsx # Top-bar integration health
|
||||
│ │
|
||||
│ ├── admin/ # Admin-specific components
|
||||
│ │ ├── DataTable.tsx # Paginated/sortable/searchable table (@tanstack/react-table)
|
||||
│ │ ├── DetailModal.tsx # Ticket deep-dive (tabs: status, priority maps)
|
||||
│ │ ├── SyncScheduler.tsx # Edit sync schedules
|
||||
│ │ └── …/
|
||||
│ │
|
||||
│ ├── analyzer/ # Analyzer-specific components
|
||||
│ │ ├── analyze-button.tsx # Trigger analysis from ticket
|
||||
│ │ ├── share-modal.tsx # Email share dialog
|
||||
│ │ ├── provider-toggle.tsx # Anthropic/OpenRouter switch
|
||||
│ │ ├── related-tickets-panel.tsx
|
||||
│ │ ├── itglue-suggestions-panel.tsx
|
||||
│ │ └── …/
|
||||
│ │
|
||||
│ ├── rmm/ # RMM components
|
||||
│ │ ├── rmm-dispatch-dialog.tsx # Pick + execute script
|
||||
│ │ ├── rmm-script-picker.tsx # Script browser
|
||||
│ │ ├── rmm-execution-stream.tsx # Live output tail
|
||||
│ │ └── …/
|
||||
│ │
|
||||
│ ├── dashboard/ # Dashboard-specific components
|
||||
│ │ ├── kpi-card.tsx # KPI with delta
|
||||
│ │ ├── volume-trend.tsx # 30-day ticket volume chart
|
||||
│ │ ├── resolution-trend.tsx # Resolution time chart
|
||||
│ │ ├── queue-heatmap.tsx # Queue × priority heatmap
|
||||
│ │ ├── active-engineers.tsx # Today's hours logged
|
||||
│ │ └── …/
|
||||
│ │
|
||||
│ ├── configuration-items/ # CI browser components
|
||||
│ │ └── config-item-modal.tsx # CI detail shell
|
||||
│ │
|
||||
│ ├── branding/ # Wulf branding
|
||||
│ │ ├── wulf-mark.tsx # W glyph or wordmark
|
||||
│ │ └── tagline-footer.tsx # "Don't be afraid to cry" footer
|
||||
│ │
|
||||
│ ├── auth/ # Auth flow components
|
||||
│ │ └── auth-provider.tsx # Better Auth session context
|
||||
│ │
|
||||
│ ├── status/ # Status page components
|
||||
│ │ ├── worker-pulse.tsx # Analyzer/RMM/sync heartbeats
|
||||
│ │ ├── activity-sparkline.tsx # 24h per-worker activity
|
||||
│ │ └── …/
|
||||
│ │
|
||||
│ └── (other feature dirs: backup, settings, tickets, tasks, etc.)
|
||||
│
|
||||
├── migrations/ # Numbered SQL migrations (001–089)
|
||||
│ ├── 001_initial_schema.sql # Core schema, audit columns
|
||||
│ ├── 012_create_auth_tables.sql # Better Auth tables
|
||||
│ ├── 030_create_workflow_engine_tables.sql
|
||||
│ ├── 041_create_engagement_tables.sql
|
||||
│ ├── 069_create_analyzer_tables.sql
|
||||
│ ├── 077_create_rmm_overshell_tables.sql
|
||||
│ └── …/
|
||||
│
|
||||
├── scripts/ # One-off operations scripts
|
||||
│ ├── apply-migrations # Manual migration runner for existing DB
|
||||
│ └── …/
|
||||
│
|
||||
├── docs/ # Long-form guides (linked from CLAUDE.md)
|
||||
│ ├── AUTOTASK_API_GUIDE.md
|
||||
│ ├── POSTGRES_SYNC_SETUP.md
|
||||
│ ├── ANALYZER_RUNBOOK.md
|
||||
│ ├── RMM_OVERSHELL_SPEC.md
|
||||
│ ├── LOGLIFT_SPEC.md
|
||||
│ ├── IT_GLUE_AUDIT_SPEC.md
|
||||
│ └── …/
|
||||
│
|
||||
├── public/ # Static assets (favicon, logos, branding)
|
||||
│ ├── favicon.png
|
||||
│ ├── wulff-logo.png
|
||||
│ └── branding/
|
||||
│
|
||||
├── hooks/ # React hooks (useSearchParams, fetch helpers, etc.)
|
||||
├── tasks/ # (Reserved for background tasks; unused today)
|
||||
├── .planning/ # GSD planning documents (generated)
|
||||
├── .env # Committed env vars (treat as potentially real secrets)
|
||||
├── next.config.js # Turbopack, React compiler, output: standalone
|
||||
├── tsconfig.json # Path alias @/*, strict mode
|
||||
├── package.json # Next 16, React 19, Tailwind 4, shadcn/ui, etc.
|
||||
├── Dockerfile # Standalone build, port 3100
|
||||
├── docker-compose.yml # Postgres 16, Redis 7, app
|
||||
├── CLAUDE.md # Repo guide for Claude (read first)
|
||||
├── ARCHITECTURE.md # Runtime topology, data flow, workers (read before touching workers)
|
||||
└── DESIGN.md # UI tokens, nav IA, component conventions
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
**`app/`:**
|
||||
- Purpose: Next.js App Router pages + API routes
|
||||
- Contains: Page components (`'use client'` with fetch), route handlers (GET/POST/PATCH/DELETE), layout shells
|
||||
- Key files: `layout.tsx` (root shell), `page.tsx` (redirect to /dashboard)
|
||||
|
||||
**`app/api/`:**
|
||||
- Purpose: HTTP API endpoints called by client or external systems
|
||||
- Contains: Route handlers exporting GET/POST/PATCH/DELETE
|
||||
- Patterns: Auth check via `requireAuth()`, delegate to service layer, return `NextResponse.json()`
|
||||
- Subdirs mirror resources: `admin/`, `analyzer/`, `sync/`, `webhooks/`, etc.
|
||||
|
||||
**`lib/services/`:**
|
||||
- Purpose: Business logic, integration clients, sync orchestration, background workers
|
||||
- Contains: ~50 files including factories, sync services, analyzer pipeline, RMM executor, integration health checks
|
||||
- Patterns: Factory pattern for clients, incremental sync via `lastTrackedModificationDateTime`, side-effect import auto-start for workers
|
||||
|
||||
**`lib/types/`:**
|
||||
- Purpose: TypeScript type definitions (no runtime code)
|
||||
- Contains: Entity shapes (ticket, company, analysis, rmm_execution, etc.), API request/response envelopes
|
||||
- Organized: By domain (autotask, analyzer, database, sync, workflow, etc.)
|
||||
|
||||
**`lib/auth.ts`, `lib/auth-utils.ts`, `lib/permissions.ts`:**
|
||||
- Purpose: Session management, role-based authorization, permission matrix
|
||||
- Contains: Better Auth config, `requireAuth()` / `requireAdmin()` / `requirePermission()`, resource → action matrix
|
||||
- Used by: Every API route handler for access control
|
||||
|
||||
**`components/ui/`:**
|
||||
- Purpose: shadcn/ui primitives (Button, Card, Dialog, Input, Select, Table, etc.)
|
||||
- Contains: Radix-based components with Tailwind styling
|
||||
- Pattern: One component per file (e.g., `button.tsx`), default export is the component
|
||||
|
||||
**`components/navigation/`:**
|
||||
- Purpose: Top bar, page headers, breadcrumbs, command palette, mobile menu
|
||||
- Contains: `app-navigation.tsx` (sticky top bar), `page-header.tsx` (title + actions), `command-palette.tsx` (Cmd+K)
|
||||
- Used by: Root layout + all pages
|
||||
|
||||
**`components/admin/`, `components/analyzer/`, `components/rmm/`, etc.:**
|
||||
- Purpose: Feature-specific components
|
||||
- Contains: Reusable UI for that domain (e.g., DataTable, DetailModal, analyze-button)
|
||||
- Pattern: Exported from kebab-case files (e.g., `analyze-button.tsx` exports `<AnalyzeButton />`)
|
||||
|
||||
**`migrations/`:**
|
||||
- Purpose: Database schema versioning
|
||||
- Contains: Numbered SQL files (001–089), applied in alphabetical order on Postgres init
|
||||
- Patterns: `IF NOT EXISTS` for idempotence, `ON CONFLICT DO NOTHING` for seed data, audit columns (`created_at`, `updated_at`, `is_deleted`)
|
||||
- Important: Never edit a committed migration; add a new one instead. Duplicate numbers exist (002, 004, 009) — apply order is filesystem-alphabetical.
|
||||
|
||||
**`scripts/`:**
|
||||
- Purpose: One-off operations and utilities
|
||||
- Contains: `apply-migrations` (manual migration runner for existing DB)
|
||||
- Important: Not tests; not part of the build
|
||||
|
||||
**`docs/`:**
|
||||
- Purpose: Long-form integration and feature guides
|
||||
- Contains: Setup guides for Autotask, sync architecture, analyzer runbook, RMM/LogLift specs, IT Glue audit spec
|
||||
- Pattern: One file per major system; referenced from CLAUDE.md, not duplicated in code
|
||||
|
||||
**`public/`:**
|
||||
- Purpose: Static web assets (favicon, logos, branding images)
|
||||
- Contains: PNG/SVG files served at `/favicon.png`, `/wulff-logo.png`, etc.
|
||||
|
||||
## Key File Locations
|
||||
|
||||
**Entry Points:**
|
||||
- `app/page.tsx` — Root page (redirect to /dashboard)
|
||||
- `app/layout.tsx` — Root layout (AppNavigation, CommandPalette, Toaster, theme provider)
|
||||
- `app/auth/sign-in/page.tsx` — Magic link entry
|
||||
- `app/dashboard/page.tsx` — KPI home
|
||||
|
||||
**Configuration:**
|
||||
- `lib/auth.ts` — Better Auth config (magic link, 2FA, Microsoft OAuth)
|
||||
- `lib/permissions.ts` — Role → resource → action matrix
|
||||
- `next.config.js` — Turbopack, React compiler, `output: 'standalone'`
|
||||
- `tsconfig.json` — Path aliases (`@/*`), strict mode
|
||||
- `.env` — Committed env vars (API keys, secrets — treat as real)
|
||||
|
||||
**Core Logic:**
|
||||
- `lib/services/postgres-client.ts` — DB singleton (query, transaction, upsert)
|
||||
- `lib/services/sync-scheduler.ts` — node-cron scheduler (auto-starts, self-initializes)
|
||||
- `lib/services/entity-sync.ts` — Per-entity incremental sync (Autotask, etc.)
|
||||
- `lib/services/analyzer/pipeline.ts` — 7-stage LLM analysis orchestration
|
||||
- `lib/services/analyzer/worker.ts` — 2s polling loop (auto-starts in production)
|
||||
- `lib/services/rmm/executor.ts` — Script validation + rate limiting
|
||||
- `lib/services/rmm/worker.ts` — 5s polling loop for RMM executions
|
||||
|
||||
**Authentication & Authorization:**
|
||||
- `lib/auth.ts` — Better Auth instance + session type
|
||||
- `lib/auth-utils.ts` — `requireAuth()`, `requireAdmin()`, `requirePermission()`
|
||||
- `lib/bootstrap.ts` — Admin user creation from `DEFAULT_ADMIN_EMAIL`
|
||||
- `middleware.ts` — Session cookie verification (no role checks here)
|
||||
|
||||
**Testing:**
|
||||
- `lib/services/analyzer/*.test.ts` — Unit tests for analyzer (preprocessor, pipeline, link-discovery, itglue-search, itglue-redact)
|
||||
- `lib/services/rmm/executor.test.ts` — RMM executor tests (rate limiting, script validation)
|
||||
- `lib/services/b2/client.test.ts` — B2 client tests
|
||||
|
||||
**UI & Components:**
|
||||
- `components/ui/` — shadcn primitives (50+ files)
|
||||
- `components/navigation/app-navigation.tsx` — Top bar + PageHeader
|
||||
- `components/navigation/page-header.tsx` — Title + breadcrumbs + action slot
|
||||
- `components/admin/DataTable.tsx` — Paginated/sortable table (@tanstack/react-table)
|
||||
- `components/admin/DetailModal.tsx` — Ticket detail tabs
|
||||
|
||||
**Types & Schemas:**
|
||||
- `lib/types/autotask.ts` — Autotask API shapes (ticket, company, contact, etc.)
|
||||
- `lib/types/analyzer.ts` — Analysis, stage execution, aggregate report shapes
|
||||
- `lib/types/database.ts` — DB row types (all tables)
|
||||
- `lib/types/sync.ts` — Sync schedule + progress shapes
|
||||
|
||||
**Status Registry (Shared State):**
|
||||
- `lib/status-registry.ts` — Priority/status color maps, state labels
|
||||
- Used by: DetailModal, StatusBadge, all pages that display ticket status/priority
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Files:**
|
||||
- kebab-case: `auth-utils.ts`, `sync-scheduler.ts`, `app-navigation.tsx`
|
||||
- Pattern: service/factory files end in `-service.ts`, `-factory.ts`, `-client.ts`
|
||||
- Example: `autotask-client.ts`, `datto-rmm-sync-service.ts`, `msgraph-factory.ts`
|
||||
|
||||
**Directories:**
|
||||
- kebab-case: `api/`, `lib/`, `services/`, `rmm/`, `admin/`
|
||||
- Resource-based: `app/api/analyzer/`, `app/api/admin/`, `app/analyzer/`
|
||||
- Subdomain grouping: `lib/services/analyzer/` (pipeline stages + persistence), `lib/services/rmm/` (executor + scripts + worker)
|
||||
|
||||
**Components:**
|
||||
- PascalCase exports from kebab-case files: `analyze-button.tsx` exports `<AnalyzeButton />`
|
||||
- Wrapper types: `*Modal`, `*Panel`, `*Dialog`, `*Card` (e.g., `DetailModal`, `related-tickets-panel`)
|
||||
|
||||
**Functions & Variables:**
|
||||
- camelCase: `requireAuth()`, `postgresClient.query()`, `getAutotaskClient()`
|
||||
- Factories: `get<Name>Client()`, `is<Name>Configured()` (e.g., `getDattoRmmClient()`, `isAutotaskConfigured()`)
|
||||
- Hooks: `useSearchParams()`, `useEffect()`, `useState()`
|
||||
|
||||
**Database:**
|
||||
- snake_case columns: `ticket_id`, `company_id`, `created_at`, `updated_at`, `is_deleted`
|
||||
- Audit columns: `created_at`, `updated_at`, `synced_at`, `is_deleted`, `deleted_at`
|
||||
- Tables named for entity plurals: `tickets`, `companies`, `resources`, `contacts`, `analyst_analyses`
|
||||
|
||||
**API Responses:**
|
||||
- camelCase keys (transformed from DB snake_case in route handlers)
|
||||
- Example: `{ ticketId, companyId, createdAt, updatedAt, isDeleted }`
|
||||
- Transformation happens in route handler, not ORM-based
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
**New Feature (e.g., new integration):**
|
||||
- Primary code: `lib/services/<integration>-client.ts` + `lib/services/<integration>-factory.ts` + `lib/services/<integration>-sync-service.ts`
|
||||
- Types: `lib/types/<integration>.ts`
|
||||
- API routes: `app/api/<resource>/route.ts`
|
||||
- Pages: `app/<resource>/page.tsx`
|
||||
- Components: `components/<resource>/*.tsx`
|
||||
- Tests: `lib/services/<integration>/*.test.ts` (if logic is testable)
|
||||
|
||||
**New Component/Module:**
|
||||
- Implementation: `components/<feature>/<kebab-case-name>.tsx` (or `components/ui/` if it's a primitive)
|
||||
- Usage: Import via `@/components/<feature>/<kebab-case-name>`
|
||||
|
||||
**New Page:**
|
||||
- File: `app/<route>/page.tsx`
|
||||
- Layout: Use `PageHeader` + `container mx-auto px-6 py-6` (standard padding)
|
||||
- Components: Import shared components from `components/`
|
||||
|
||||
**Utilities:**
|
||||
- Shared helpers: `lib/utils/<name>.ts` (e.g., `lib/utils/env.ts` for type-safe env access)
|
||||
- Service-local helpers: Inline in `lib/services/<name>.ts` if not reused
|
||||
|
||||
**Migrations:**
|
||||
- File: `migrations/NNN_*.sql` (next number in sequence)
|
||||
- Pattern: `IF NOT EXISTS` for idempotence, `ON CONFLICT DO NOTHING` for seed data
|
||||
- Audit columns: Include `created_at`, `updated_at`, `is_deleted`, `deleted_at` where applicable
|
||||
- Important: Never edit a committed migration; create a new one instead
|
||||
|
||||
**Tests:**
|
||||
- Location: `__tests__/` sibling to source file or `.test.ts` / `.spec.ts` suffix
|
||||
- Framework: `vitest` (import from `vitest`, not `jest`)
|
||||
- Patterns: Unit tests for analyzer, RMM, B2; other areas are type-checked only
|
||||
|
||||
## Special Directories
|
||||
|
||||
**`.planning/`:**
|
||||
- Purpose: Generated GSD (Goal, Scope, Definition) planning documents
|
||||
- Generated: By `/gsd-map-codebase` with focus areas (tech, arch, quality, concerns)
|
||||
- Committed: Yes (consumed by `/gsd-plan-phase` and `/gsd-execute-phase`)
|
||||
- Contains: STACK.md, INTEGRATIONS.md, ARCHITECTURE.md, STRUCTURE.md, CONVENTIONS.md, TESTING.md, CONCERNS.md
|
||||
|
||||
**`.env`:**
|
||||
- Purpose: Environment variables (API keys, secrets, connection strings)
|
||||
- Committed: Yes (treat values as potentially real production secrets)
|
||||
- Secrets: DATABASE_URL, REDIS_URL, AUTOTASK_API_URL, BETTER_AUTH_SECRET, LLM keys, etc.
|
||||
- Important: Never echo or log `.env` contents; don't add `.env.local` to version control
|
||||
|
||||
**`node_modules/`, `.next/`, `.git/`:**
|
||||
- Purpose: Generated directories
|
||||
- Committed: No (git-ignored)
|
||||
- Cleaned: `rm -rf node_modules && npm install`, `npm run build && rm -rf .next`
|
||||
|
||||
**`public/`:**
|
||||
- Purpose: Static web assets
|
||||
- Served: At `/path` (e.g., `/favicon.png`)
|
||||
- Committed: Yes
|
||||
|
||||
**`docs/`:**
|
||||
- Purpose: Long-form guides
|
||||
- Committed: Yes
|
||||
- Pattern: One file per major system (Autotask, Postgres, Analyzer, RMM, etc.)
|
||||
- Updated: As integration behavior changes; link from CLAUDE.md, don't duplicate in inline comments
|
||||
|
||||
---
|
||||
|
||||
*Structure analysis: 2026-05-03*
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-05-03
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
- Vitest 4.1.5
|
||||
- Config: `vitest.config.ts` at root
|
||||
- Node environment (not DOM)
|
||||
|
||||
**Assertion Library:**
|
||||
- Vitest built-in `expect()` — no separate library
|
||||
|
||||
**Run Commands:**
|
||||
```bash
|
||||
npm test # Run all tests once (vitest run)
|
||||
npm run test:watch # Watch mode (vitest)
|
||||
npx tsc --noEmit --pretty # Type check (required, only safety net for most code)
|
||||
npm run build # Build check (turbopack)
|
||||
```
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
- Co-located with source files in `lib/services/`
|
||||
- Pattern: `service-name.test.ts` in same directory as `service-name.ts`
|
||||
- Tests in `lib/**/*.test.ts` only (configured in `vitest.config.ts`)
|
||||
|
||||
**Coverage:**
|
||||
- **Fully tested:** `lib/services/analyzer/**/*.test.ts`, `lib/services/rmm/**/*.test.ts`, `lib/services/b2/**/*.test.ts`
|
||||
- **Partially tested:** `lib/services/analyzer/link-discovery.test.ts` (link discovery logic)
|
||||
- **Not tested:** Most of `app/api/`, all pages, forms, UI components, sync services, entity sync, webhooks
|
||||
|
||||
**Important:** Most of the codebase has no tests — type-check is the only safety net.
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Suite Organization:**
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('FEATURE_NAME', () => {
|
||||
beforeEach(() => {
|
||||
// Setup per test
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup per test
|
||||
});
|
||||
|
||||
it('should do something', () => {
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle edge case', async () => {
|
||||
const r = await someAsyncFunction();
|
||||
expect(r.done).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Patterns from actual tests:**
|
||||
|
||||
*Test with mock setup* (from `lib/services/analyzer/link-discovery.test.ts`):
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { discoverExplicitLinks } from './link-discovery';
|
||||
|
||||
vi.mock('@/lib/services/postgres-client', () => ({
|
||||
default: {
|
||||
query: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const mockedQuery = postgresClient.query as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('discoverExplicitLinks', () => {
|
||||
beforeEach(() => {
|
||||
mockedQuery.mockReset();
|
||||
});
|
||||
|
||||
it('skips self-references', async () => {
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [{ ticket_number: 'T20260428.0053', ... }],
|
||||
});
|
||||
const r = await discoverExplicitLinks(bundle);
|
||||
expect(r.explicit).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
*Test with utility fixture helper* (from `lib/services/analyzer/link-discovery.test.ts`):
|
||||
```typescript
|
||||
function bundle(partial: Partial<RawTicketBundle['ticket']> = {}): RawTicketBundle {
|
||||
return {
|
||||
ticket: {
|
||||
id: 1,
|
||||
ticket_number: 'T20260430.0084',
|
||||
title: 'Master problem ticket — Hynes',
|
||||
// ... default fields
|
||||
...partial,
|
||||
},
|
||||
notes: [],
|
||||
time_entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
it('parses refs from description', async () => {
|
||||
const b = bundle({ description: 'See T20260427.0142' });
|
||||
// ... test logic
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:** Vitest's `vi` object
|
||||
|
||||
**Patterns:**
|
||||
|
||||
*Mock entire module:*
|
||||
```typescript
|
||||
vi.mock('@/lib/services/postgres-client', () => ({
|
||||
default: {
|
||||
query: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
const mockedQuery = postgresClient.query as unknown as ReturnType<typeof vi.fn>;
|
||||
```
|
||||
|
||||
*Reset mocks between tests:*
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
mockedQuery.mockReset();
|
||||
// or vi.restoreAllMocks() for all mocks
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
```
|
||||
|
||||
*Mock implementation:*
|
||||
```typescript
|
||||
mockedQuery.mockImplementationOnce(async (_sql: string, params: unknown[]) => {
|
||||
const numbers = params[0] as string[];
|
||||
return {
|
||||
rowCount: numbers.length,
|
||||
rows: numbers.map((n) => ({
|
||||
ticket_number: n,
|
||||
title: 't',
|
||||
status_label: 'Open',
|
||||
})),
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
*Mock resolved value (for async):*
|
||||
```typescript
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [{ ticket_number: 'T20260428.0053', title: 'Issue', ... }],
|
||||
});
|
||||
```
|
||||
|
||||
*Spy on function:*
|
||||
```typescript
|
||||
let findExistingSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
findExistingSpy = vi
|
||||
.spyOn(persistence, 'findExistingAnalysisByContentHash')
|
||||
.mockResolvedValue(null);
|
||||
});
|
||||
afterEach(() => {
|
||||
findExistingSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
*Stub globals:*
|
||||
```typescript
|
||||
const realDate = Date;
|
||||
beforeEach(() => {
|
||||
const fixed = new Date('2026-05-02T20:00:00.000Z');
|
||||
vi.stubGlobal(
|
||||
'Date',
|
||||
class extends realDate {
|
||||
constructor(...args: unknown[]) {
|
||||
if (args.length === 0) {
|
||||
super(fixed.getTime());
|
||||
} else {
|
||||
super(...(args as [any]));
|
||||
}
|
||||
}
|
||||
static now() {
|
||||
return fixed.getTime();
|
||||
}
|
||||
} as unknown as DateConstructor
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
```
|
||||
|
||||
## What to Mock
|
||||
|
||||
**DO mock:**
|
||||
- Database queries (postgres-client)
|
||||
- External API clients (Autotask, IT Glue, etc.)
|
||||
- File I/O
|
||||
- Time-dependent operations (Date)
|
||||
- Long-running operations
|
||||
|
||||
**DO NOT mock:**
|
||||
- Regular functions being tested
|
||||
- Utility functions (regex helpers, string transformers)
|
||||
- Type definitions
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data Creation:**
|
||||
Use helper functions to build test fixtures:
|
||||
|
||||
```typescript
|
||||
// From link-discovery.test.ts
|
||||
function bundle(partial: Partial<RawTicketBundle['ticket']> = {}): RawTicketBundle {
|
||||
return {
|
||||
ticket: {
|
||||
id: 1,
|
||||
ticket_number: 'T20260430.0084',
|
||||
title: 'Master problem ticket — Hynes',
|
||||
description: null,
|
||||
status: 1,
|
||||
status_label: 'New',
|
||||
// ... 30+ default fields
|
||||
...partial, // Override with test-specific values
|
||||
},
|
||||
notes: [],
|
||||
time_entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Usage in test
|
||||
it('flags master-problem-ticket title', () => {
|
||||
const r = detectProblemTicket(
|
||||
bundle({ title: 'Master problem ticket — recurring degradation' }),
|
||||
false
|
||||
);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
**JSON Fixtures:**
|
||||
- Load from files for large datasets: `readFileSync(resolve(__dirname, 'fixtures', 'T20260424.0045.input.json'), 'utf8')`
|
||||
- Example: `/opt/stacks/pulse/lib/services/analyzer/fixtures/`
|
||||
|
||||
**Location:** Test fixtures live alongside test files in same directory
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:** None enforced (no CI, local-only testing)
|
||||
|
||||
**View Coverage:** Not configured
|
||||
|
||||
**Note:** Tests exist for:
|
||||
- `lib/services/analyzer/` — 9 test files covering pipeline stages, link discovery, redaction, preprocessing
|
||||
- `lib/services/rmm/` — 3 test files (worker, target-resolver, registry scripts)
|
||||
- `lib/services/b2/` — 1 test file (presign URLs, crypto)
|
||||
- `lib/services/llm/` — 2 test files (LLM calls, pricing)
|
||||
|
||||
Untested areas: All API routes, all pages, forms, UI components, sync services, webhooks
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests:**
|
||||
- Test individual functions in isolation
|
||||
- Mock external dependencies
|
||||
- Examples: `extractExplicitFromText()`, `OBJECT_KEY_REGEX`, `presignDownload()`
|
||||
|
||||
**Integration Tests:**
|
||||
- Not separated from unit tests
|
||||
- Some tests validate full workflow (e.g., `discoverExplicitLinks` querying mock DB)
|
||||
|
||||
**E2E Tests:**
|
||||
- Not present in codebase
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Async Testing:**
|
||||
```typescript
|
||||
it('resolves problem_ticket_id', async () => {
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [{ ticket_number: 'T20260427.0142' }],
|
||||
});
|
||||
|
||||
const r = await discoverExplicitLinks(bundle);
|
||||
expect(r.explicit[0].ticket_number).toBe('T20260427.0142');
|
||||
});
|
||||
```
|
||||
|
||||
**Error Testing:**
|
||||
```typescript
|
||||
it('throws on invalid object key', () => {
|
||||
expect(() =>
|
||||
presignDownload('../etc/eventlogs_1.json.gz', 600, config)
|
||||
).toThrow(B2InvalidObjectKeyError);
|
||||
});
|
||||
```
|
||||
|
||||
**Regex Testing:**
|
||||
```typescript
|
||||
describe('TICKET_NUMBER_REGEX', () => {
|
||||
it('matches canonical format', () => {
|
||||
const m = 'see T20260430.0084 and T20260427.0142'.match(TICKET_NUMBER_REGEX);
|
||||
expect(m).toEqual(['T20260430.0084', 'T20260427.0142']);
|
||||
});
|
||||
|
||||
it('does not match invalid lengths', () => {
|
||||
expect('T2026.0084'.match(TICKET_NUMBER_REGEX)).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Sequential Mock Queuing** (for LLM stages):
|
||||
```typescript
|
||||
interface Reply {
|
||||
text: string;
|
||||
usage?: Partial<Anthropic.Usage>;
|
||||
}
|
||||
|
||||
function makeFakeAnthropic(queue: Reply[]): { fake: Anthropic; bodies: any[] } {
|
||||
const bodies: any[] = [];
|
||||
let i = 0;
|
||||
const create = vi.fn(async (body: any) => {
|
||||
bodies.push(body);
|
||||
const next = queue[i++];
|
||||
if (!next) throw new Error('No more queued LLM replies');
|
||||
return {
|
||||
id: `msg_${i}`,
|
||||
content: [{ type: 'text', text: next.text }],
|
||||
usage: { input_tokens: 5000, output_tokens: 500, ... },
|
||||
} as Anthropic.Message;
|
||||
});
|
||||
return { fake: { messages: { create } } as unknown as Anthropic, bodies };
|
||||
}
|
||||
|
||||
// Usage
|
||||
const { fake: anthropic, bodies } = makeFakeAnthropic([
|
||||
{ text: validTriage() },
|
||||
{ text: validSonnet() },
|
||||
{ text: validOpus(), usage: { ... } },
|
||||
]);
|
||||
```
|
||||
|
||||
## Accessing Internals for Testing
|
||||
|
||||
**Pattern:** Modules export `_INTERNALS` object with functions/constants not otherwise exported:
|
||||
|
||||
```typescript
|
||||
// In source: lib/services/b2/client.ts
|
||||
export const _B2_INTERNALS = {
|
||||
deriveSigningKey,
|
||||
};
|
||||
|
||||
// In test: lib/services/b2/client.test.ts
|
||||
import { _B2_INTERNALS } from './client';
|
||||
|
||||
describe('deriveSigningKey', () => {
|
||||
it('produces a 32-byte HMAC-SHA256 chain', () => {
|
||||
const k = _B2_INTERNALS.deriveSigningKey('sec-fixture', '20260502', 'us-west-002', 's3');
|
||||
expect(k.length).toBe(32);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Also:
|
||||
```typescript
|
||||
// lib/services/analyzer/worker.ts
|
||||
export const _RMM_WORKER_INTERNALS = {
|
||||
extractResult,
|
||||
};
|
||||
|
||||
// lib/services/analyzer/worker.test.ts
|
||||
import { _RMM_WORKER_INTERNALS } from './worker';
|
||||
|
||||
describe('extractResult', () => {
|
||||
const { extractResult } = _RMM_WORKER_INTERNALS;
|
||||
it('returns done=false while jobStatus is running', () => {
|
||||
const r = extractResult({ jobStatus: 'running', stdOut: null }, 'dev-1');
|
||||
expect(r.done).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**Untested areas (HIGH RISK):**
|
||||
|
||||
| Component | Reason | Impact |
|
||||
|-----------|--------|--------|
|
||||
| `app/api/` all routes | No tests configured | New bugs undetected until runtime |
|
||||
| `app/` all pages | No tests | UI regressions undetected |
|
||||
| `components/` all | No tests | UI logic errors undetected |
|
||||
| `lib/services/entity-sync.ts` | No tests | Sync failures undetected; blocks on type-check |
|
||||
| `lib/services/sync-scheduler.ts` | No tests | Schedule logic errors undetected |
|
||||
| `lib/services/webhook-service.ts` | No tests | HMAC verification, webhook processing untested |
|
||||
| `lib/auth.ts`, `lib/auth-utils.ts` | No tests | Auth failures undetected until login attempt |
|
||||
| `lib/permissions.ts` | No tests | Permission checks untested |
|
||||
|
||||
**Partially tested areas:**
|
||||
- `lib/services/analyzer/` — pipeline stages tested, worker tested, but integration edge cases may be missed
|
||||
- `lib/services/llm/` — pricing and call patterns tested, but provider-specific behavior not fully covered
|
||||
|
||||
## Running Tests Locally
|
||||
|
||||
```bash
|
||||
# All tests once
|
||||
npm test
|
||||
|
||||
# Watch mode (rerun on file change)
|
||||
npm run test:watch
|
||||
|
||||
# Type check (required before commit)
|
||||
npx tsc --noEmit --pretty
|
||||
|
||||
# Build (catches more errors)
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-05-03*
|
||||
Loading…
Add table
Add a link
Reference in a new issue