Major UI refresh on the nav-design-improvements branch. Drops 2013-era
inline styles and consolidates patterns behind shared primitives.
Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
the standards-guide blue (#0075AD) with utility classes for numerics
(.num / .num-lg / .num-xl), metric labels, surface tints, and the
wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
"Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page
Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
health table, worker pulse cards (analyzer / RMM / sync scheduler),
token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
integrations (e.g. SentinelOne) — no failure noise from broken-on-
purpose entries. Aliases supported (sentinelone → s1, etc.)
Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
area chart, 30-day mean resolution time line chart, today's active
engineers leaderboard
Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
status, classification, source, company type, publish, active /
yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)
Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
PageHeader rule (consistent across flat links and submenu triggers);
active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config
Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs
DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow
Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below
Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A container restart leaves analyzer_jobs rows stuck in
fetching/triaging/itglue/analyzing/deep_review forever — the worker's
claimQueuedJob only picks up status='queued', so a job mid-pipeline
when the process died gets orphaned.
resetStaleJobsToQueued() reverts any active-state row whose started_at
is older than 10 min back to 'queued' with started_at=NULL. The worker
calls it once on start() before scheduling the first poll. 10 min is
3x the realistic pipeline ceiling — well past Sonnet+Opus combined.
Logs the count when nonzero so restarts that recover work are visible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IT Glue's /flexible_assets endpoint refuses requests without a
filter[flexible-asset-type-id] (returns 422 "Cannot index flexible
assets without providing a flexible asset type ID filter"). The
analyzer's Stage 2 search was caught and tolerated, but never returned
docs.
Added getFlexibleAssetsForOrganization(orgId) on ITGlueClient. It
fetches the type list once per process (memoized), then fans out
per-type fetches with Promise.allSettled so a permission-restricted
type doesn't poison the whole org. Wired into itglue-search and
aggregate-persistence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filter-options endpoint was rejecting from the priorities subquery, and
because all six lookups run in Promise.all the whole endpoint failed
with HTTP 500 — leaving every multi-select dropdown empty including
Client. priorities is a small reference table with no soft-delete; just
filter on is_active.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Radix Checkbox renders as <button role="checkbox">, which we were nesting
inside the option <button>. Browsers can swallow the outer click in that
arrangement despite pointer-events-none on the inner element. Replaced the
option with <div role="option" tabIndex={0}> and an inline non-button
visual checkbox (square + Check icon when selected). Keyboard support
(Enter/Space) preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight sub-phases per docs/ticket-analyzer-phase2-spec.md:
2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot,
aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses.
model_traces marked LEGACY (kept for back-compat).
2.2 Every pipeline stage records a row to analyzer_stage_executions, success
or failure. Worker persists a status='failed' analyzer_analyses row when
the pipeline throws so partial stage records have a parent. Pipeline
exposes raw triage/sonnet/opus responses for downstream stages.
2.3 Stage 3 prompt updated with markdown formatting rules + banned filler
phrases. Added react-markdown + remark-gfm + @tailwindcss/typography.
New <AnalysisMarkdown> component replaces <ProseText>; coerces stray
headers to bold paragraphs.
2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant.
scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the
legacy model_traces blob.
2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/
queue/status/priority/assignee, sticky filter bar, active-filter chips,
bulk selection persisted via localStorage, "Analyze N selected" +
"Generate aggregate report" actions. New <MultiSelect> primitive.
Staleness uses last_activity_date > completed_at heuristic per spec C.1.
2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists
SQL distributions immediately so UI shows partial state during the
Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new
/:id]). IT Glue context fetcher capped at 200 doc titles.
2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at
$20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS
override. Every gating decision audited.
2.8 Runbook + build notes updated.
128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent
(IF NOT EXISTS). model_traces double-write retained — drop in a future
migration once aggregate reports have soaked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /analyzer/tickets — period chips (today/yesterday/this+last
week/30d/60d/all), client + issue-type Selects, debounced search,
per-row Analyze/Re-analyze plus View shortcut when an analysis
already exists.
- API: /api/analyzer/tickets/list (period/companyId/issueType/search,
paginated via COUNT(*) OVER) and /filter-options (companies that
actually have tickets, active issue types).
- ProseText helper in analysis-view splits on blank lines and renders
each chunk with leading-7 — Summary, Next Step, rationale, and
Post-Resolution now have proper paragraph rhythm. Next Step card
re-styled with bg-primary/5 tint, ArrowRight icon, and an indented
rationale block.
- Top-level "Analyzer" nav menu (Browse Tickets + Needs Review).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
worker.ts has a self-init side effect on module load, but nothing in the
shipped code imported it — so jobs queued but no worker ran. Adding a
side-effect import to the analyze route handler; Next.js eagerly loads
route modules at boot to build the routing manifest, so this runs once
per server process. Confirmed live: [ANALYZER-WORKER] starting log line
fires on container start.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs/wulf-pulse-ticket-analyzer-runbook.md covers cost monitoring
queries, the $2 cost ceiling, IT Glue alias workflow, failure triage
(failed jobs vs needs_human_review), and manual ops (queue from psql,
force re-run, inspect model_traces). Calls out the manual migration
step for existing DBs and lists the unimplemented surfaces (no auto
retries, no viewed_at, no email_sent_at) so operators don't trip on
them. Linked from README.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sendAnalysisShareEmail() reuses the existing nodemailer SMTP transport
(same path as magic-link/invitation mail). Share route persists the
audit row first, then attempts send; on failure returns
{share, emailSent:false, emailError} at HTTP 200 so the audit log
stays intact. Modal surfaces send failures as a warning toast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add /admin/display-settings page with Kiosk and Mobile sections
- Company category checkbox filter + excluded companies searchable multi-select
- New DB tables: company_categories, company_types (migration 064)
- Sync COMPANY_CATEGORIES via CompanyCategories entity (id/name/isActive)
- Sync COMPANY_TYPES via Companies.companyType picklist
- Add to EntityType, ENTITY_DEPENDENCIES, sync-helpers, entity-mapper, entity-sync
- New API routes: /api/admin/display-settings (GET/POST), /api/data/company-categories, /api/data/companies-list
- Update all 4 routes (kiosk/stats, kiosk/activity, mobile/tickets, mobile/dashboard)
to filter by kiosk_settings company_category_ids + excluded_company_ids
- Add Display Settings nav link (SlidersHorizontal icon) to Admin menu
- Seed kiosk_settings: kiosk_company_category_ids=1, mobile_company_category_ids=1
The hardcoded UDF filter (MSP Service Model = 'Wulf Managed') matched
only 1 company, making all mobile ticket views empty. Now reads
included_classifications from kiosk_settings (same as kiosk) which
correctly identifies all managed clients by classification ID.
- bulkUpsert now accepts preserveExistingOnNull column list, using
COALESCE(EXCLUDED.col, table.col) so null incoming values never
overwrite existing non-null DB values
- bulkUpsertRecords passes resource ID columns as preserve-on-null
for TICKETS and TASKS entities
- getValidResourceIds now throws on DB error instead of returning
empty set (which would nullify every resource reference)
- Fix mimecast mailbox-remediate fetch handlers to check res.ok and
content-type before calling res.json(), preventing JSON parse crash
on 502 Bad Gateway responses
- Add searchMailboxMessages, deleteMailboxMessage, moveToDeletedItems to MsGraphClient
- POST /api/mimecast/mailbox-remediate: search, move, delete actions with permission error handling
- DeliveredAnalysisDialog: Remove from mailbox panel with search → confirm → delete flow
- Shows matching messages in mailbox with checkboxes, received time, read/unread status
- Moves selected to Deleted Items (recoverable) via Graph API
- Surfaces clear permission guidance if Mail.ReadWrite not yet granted
- New POST /api/mimecast/delivered route using message-finder/search API
- DeliveredMailTab: search by recipient, sender, subject, time range (6h–7d)
- Results table with status badge, spam score, row tinting for high/moderate risk
- Summary stats bar: total / high spam (≥10) / moderate (5-9) / clean counts
- Sort by date or spam score; filter by status (accepted/held/rejected/bounced)
- DeliveredAnalysisDialog: explains why high-score mail got through, envelope mismatch detection, actionable remediation steps (block domain, adjust policy threshold, report)
- MimecastDeliveredMessage interface + searchDeliveredMessages() method in client
- Add releaseHeldMessage() to MimecastClient (POST /api/gateway/hold-release)
- Add POST /api/mimecast/held/release route
- HeldMailTab: tenant selector before load (defaults to Wulf), only fetches selected tenant
- Release button per row with spinner + optimistic removal on success
- Error shown inline under Release button if release fails
getMimecastClientForTenant now always uses accountCode='' so the
x-mc-account header is never sent. Sending your own account code
with tenant-specific OAuth credentials causes Mimecast to 403
(it interprets it as an invalid impersonation attempt).
- export const dynamic = 'force-dynamic' on /api/mimecast/held to prevent Next.js caching
- Add AbortController timeout (20s) per request in MimecastClient.request()
- getHeldMessages: 403 fallback without admin:true flag for tenants lacking permission
- Reduce maxMessages default to 100 (10 pages) to stay within route timeout
- Show 'permission denied' tooltip in tenant badge for 403 errors
- Surface HTTP errors in HeldMailTab instead of silently failing
- Add missing favicons: sentinelone.ico, itglue.ico, mimecast.ico
- data-browser/companies: resolve classification picklist IDs to labels in
table column and detail modal; also added to DetailModal COMPANY_GROUPS
- DetailModal: add 'classification' FieldType with color-coded badge map
- kiosk stats + activity: switch from label-based exclusion to ID-based
allowlist (included_classifications). Only shows companies with
classification IN (15,16,17,18,203,205,206,207,202,5,12)
= managed service / recurring revenue tiers only
Covers architecture, data synced (6 tables), sync process, rate limiting,
incremental auth logs, company matching, API endpoints, UI elements,
bypass vs disabled user separation, env vars, and file inventory.
Bypass = security risk (MFA not enforced) — shown in red, expandable panel
Disabled = locked out, no threat — shown in muted gray, separate expandable panel
- Split /api/duo/status counts into bypass and disabled separately
- /api/duo/users/flagged returns { bypass: [], disabled: [] } instead of flat list
- Overview card: only bypass triggers red warning icon (disabled does not)
- Detail page: two separate expandable sections with distinct severity styling
- Both sections include user, email, account name, enrolled status, last login, notes
- Covers all accounts (parent + children)
- Created GET /api/duo/users/flagged — returns users with status bypass or disabled, joined with account name
- Clickable warning banner expands to show full user table
- Table shows: user, email, account, status badge (yellow=bypass, red=disabled), enrolled, last login, notes
- Fixed JOIN: duo_users.duo_account_id is varchar account_id, not integer id
The Autotask Tasks bulk API does not return projectID in its response,
causing all tasks.project_id to be NULL. This fixes it by:
- Adding project_phases as a synced entity (Autotask endpoint: /Phases)
- Migration 059: project_phases table with project_id, phase_number,
estimated_hours, start/due dates, parent_phase_id, is_scheduled
- EntityType.PROJECT_PHASES added to all sync maps and dependency graph
(depends on PROJECTS, runs before TASKS in sync order)
- buildProjectPhasesFilter: Phases endpoint requires a filter (id > 0)
- mapProjectPhase: maps Autotask field names to DB columns
- Post-sync backfill in syncEntity: after each project_phases sync,
UPDATE tasks SET project_id = pp.project_id FROM project_phases pp
JOIN projects p WHERE tasks.phase_id = pp.id
Only backfills where the project exists in our DB (FK constraint on
tasks.project_id; archived projects are skipped gracefully)
Result: 2,455 of 4,966 tasks now have project_id populated. Tasks
belonging to archived/completed projects have phase_id resolvable via
project_phases even when project_id remains NULL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AppNavigation returns null on /mobile/* (no more horizontal scroll)
- Mobile header: 'Pulse' title (links home) + Menu icon (links to /mobile/nav)
- /mobile/nav: full-screen nav page with touch-friendly cards
- Mobile Views: Dashboard, Tickets, Finance (large icon cards)
- Full Site: Quotes, Config Items, Backup, Engagement, Ticket Digest, Admin
- Sign out button
- Bottom tab bar unchanged (Dashboard / Tickets / Finance)
- DB: inserted qbo-sync-2am (0 2 * * *) and qbo-sync-4pm (0 16 * * *) schedules
- Mobile finance: 'Sync QBO' button triggers POST /api/qbo/sync incremental,
polls /api/qbo/sync GET until lastSync timestamp changes (max 90s),
then reloads finance data
- Shows last sync timestamp below page title
- Separate refresh-only button (↻) for quick display refresh without re-syncing
- Sync status message shown during polling