diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000..229c1be --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,140 @@ +# Pulse Mobile Shell Redesign + +## What This Is + +A ground-up redesign of Pulse's `/mobile/*` shell — the manager-on-the-go view of +the existing Pulse PSA dashboard. It replaces the current mobile layout, swaps +the standalone `/mobile/nav` page for a Sheet drawer, restyles Dashboard / +Tickets / Finance, and adds two new mobile surfaces (Analyzer feed, +Engagement). Built on the existing Pulse codebase — same routes, same data, +phone-first layouts. + +## Core Value + +A manager can open Pulse on their phone and, in under 30 seconds, see the +state of the business and triage tickets — without ever needing to switch to +desktop for read-only awareness. + +## Requirements + +### Validated + + + +- ✓ `/mobile` route shell with sticky header and bottom nav — existing +- ✓ `/mobile/dashboard`, `/mobile/tickets`, `/mobile/tickets/[id]`, + `/mobile/finance` routes — existing +- ✓ Standalone `/mobile/nav` page (to be replaced by drawer) — existing +- ✓ Authentication, sync workers, analyzer pipeline, Engagement data sources — + existing in desktop app and reused here +- ✓ PWA scaffolding (manifest, viewport-fit=cover, `pt-safe`/`pb-safe` utilities) + — Validated in Phase 1: PWA Scaffolding (PWA-01..04) +- ✓ New `/mobile/layout.tsx` shell — sticky header (WulfMark + Bell placeholder + + avatar), 5-cell bottom nav (Dashboard/Tickets/Finance/Analyzer + More), + shared MoreDrawer Sheet — Validated in Phase 2: Mobile Shell + More Drawer + (SHELL-01..06, NAV-01..03) +- ✓ More drawer (right Sheet) replaces `/mobile/nav` — Mobile sections, Full + site links (with `ExternalLink` hint), Account + Sign out — Validated in + Phase 2: Mobile Shell + More Drawer (DRAWER-01..06) +- ✓ Dashboard restyle — 2×2 KPI grid, Needs Attention strip (horizontal scroll), + worker/backup status row, no charts. Backed by single `/api/mobile/dashboard` + endpoint returning `kpis`/`needsAttention`/`workers`. Validated in Phase 3: + Dashboard Restyle (DASH-01..04) + +### Active + + + +- [ ] Tickets restyle — collapsible filter strip (URL-synced), priority-bar + rows, cursor-based infinite scroll (~25/page), keep detail page +- [ ] Finance restyle — adopt new Card + typography scale, swap wide tables for + stacked lists on mobile +- [ ] Analyzer feed (NEW) — `/mobile/analyzer` read-only stream of recent AI + analyses with mobile summary view; new `/api/mobile/analyzer/feed` +- [ ] Engagement mobile (NEW) — `/mobile/engagement` overview (period chips, + stacked summary cards, sortable per-employee list, sparkline) plus + `/mobile/engagement/[userId]` profile page replacing the desktop modal + +### Out of Scope + + + +- Service worker / offline cache / push notifications — deferred until a clear + offline use-case lands +- Tablet breakpoint (`md:max-w-2xl`) — noted as follow-up, keep `max-w-lg` +- Real notification list behind the Bell icon — placeholder only this iteration +- Mobile editing on Engagement (user detail) or Analyzer (re-run, prompt edits) + — read-only on mobile by design +- Charts / recharts on the mobile Dashboard — not earning their weight on + small widths +- Restyling or replacing the desktop pages reachable from the More drawer — + desktop pages stay as they are + +## Context + +- **Brownfield project.** Pulse is a Next.js 16 + React 19 PSA dashboard for + Wulf Consulting. Existing codebase fully mapped at `.planning/codebase/*.md`. + See `CLAUDE.md`, `ARCHITECTURE.md`, `DESIGN.md` in repo root. +- **Audience.** Wulf Consulting managers using Pulse on iOS/Android during the + workday — the shell is for status checks and triage, not full editing. +- **Mobile is not a replacement for desktop.** Pages where mobile editing + isn't justified link out via the More drawer with an `ExternalLink` hint. +- **Source of truth for this work.** `docs/superpowers/specs/2026-05-03-mobile-shell-design.md` + — 8-section spec with explicit build order. Phases should follow it. +- **Existing mobile shell is small.** Current `app/mobile/layout.tsx` has 3 + bottom-nav tabs and a `/mobile/nav` page. This redesign rebuilds it in + place — no `/mobile-v2`, no parallel routes. +- **Engagement and Analyzer pages on desktop are large** (~1300 + ~650 lines + for engagement; analyzer pipeline already has a desktop UI). Mobile + surfaces reuse the data sources but build phone-first layouts from scratch. + +## Constraints + +- **Tech stack**: Next.js 16 App Router, React 19, Tailwind 4, shadcn/ui — match + existing Pulse conventions (no new state libraries, no SWR/react-query, no + ORM, no Zod in API routes unless required) +- **Routes**: Keep all existing `/mobile/*` paths. Replace files in place. No + new top-level routes outside `/mobile/`. +- **Build order**: Each spec step ships independently — no big-bang merge. + Phase boundaries should let each step land on `master` cleanly. +- **No service worker / no offline**: don't introduce `next-pwa` or a custom + SW in this iteration. +- **Auth**: existing Better Auth + middleware handles `/mobile/*`. No new + auth surface. +- **Data sources**: reuse existing endpoints where possible; add + `/api/mobile/*` only when an existing list endpoint doesn't return the + needed shape (e.g., analyzer feed). + +## Key Decisions + + + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| Rebuild `/mobile` in place (no `/mobile-v2`) | Spec §2 — keep canonical URLs, avoid parallel maintenance burden | — Pending | +| Bottom nav = 4 tabs + More (5 cells), Engagement in More | Spec §3.1, §6.5 — managers don't check Engagement as often as the four primary surfaces | — Pending | +| Ship each spec step as its own phase | Spec §8 — independent ship reduces merge risk and keeps reviews focused | — Pending | +| Bell icon is a placeholder only | Spec §5.1 — real notification list deferred; keep keyboard-accessible button so future phase can wire it | — Pending | +| No service worker in this iteration | Spec §4 — defer until a clear offline use-case lands | — Pending | +| Engagement mobile is a real refactor, not a thin adaptation | Spec §6.5 — desktop's wide tables and modals don't translate; build phone-first from same data sources | — Pending | +| Mobile user-detail is a page, not a modal | Spec §6.5 — back gesture needs real navigation history | — Pending | + +## Evolution + +This document evolves at phase transitions and milestone boundaries. + +**After each phase transition** (via `/gsd-transition`): +1. Requirements invalidated? → Move to Out of Scope with reason +2. Requirements validated? → Move to Validated with phase reference +3. New requirements emerged? → Add to Active +4. Decisions to log? → Add to Key Decisions +5. "What This Is" still accurate? → Update if drifted + +**After each milestone** (via `/gsd-complete-milestone`): +1. Full review of all sections +2. Core Value check — still the right priority? +3. Audit Out of Scope — reasons still valid? +4. Update Context with current state + +--- +*Last updated: 2026-05-03 — Phase 3 complete (Dashboard Restyle)* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 0000000..58f4de0 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,196 @@ +# Requirements: Pulse Mobile Shell Redesign + +**Defined:** 2026-05-03 +**Core Value:** A manager can open Pulse on their phone and, in under 30 seconds, see the state of the business and triage tickets — without ever needing to switch to desktop for read-only awareness. +**Source spec:** `docs/superpowers/specs/2026-05-03-mobile-shell-design.md` + +## v1 Requirements + +Requirements for this milestone. Each maps to a spec section and a roadmap phase. + +### PWA — Progressive Web App scaffolding (spec §4) + +- [ ] **PWA-01**: `public/manifest.json` exists with name "Pulse", short_name "Pulse", `display: "standalone"`, `start_url: "/mobile"`, theme/background colors matching dark and light shells +- [ ] **PWA-02**: Manifest is referenced from `app/layout.tsx` via `` +- [ ] **PWA-03**: Viewport meta in `app/layout.tsx` includes `viewport-fit=cover` +- [ ] **PWA-04**: Header and bottom tab bar respect `env(safe-area-inset-top)` and `env(safe-area-inset-bottom)` (Tailwind arbitrary values or shared utility class) + +### Shell — `/mobile` layout (spec §5) + +- [x] **SHELL-01**: New `app/mobile/layout.tsx` replaces the current layout (rebuild in place — no parallel `/mobile-v2`) +- [ ] **SHELL-02**: Sticky top header: `bg-background/95 backdrop-blur` + bottom border; left = Wulf mark + "Pulse" wordmark linked to `/mobile/dashboard`; no page title in header +- [ ] **SHELL-03**: Header right slot — `Bell` icon button (placeholder, no menu/badge, `aria-label="Notifications"`, empty `onClick`, keyboard-accessible) +- [ ] **SHELL-04**: Header right slot — compact user avatar (`h-7 w-7`); tapping opens the More drawer +- [x] **SHELL-05**: `
` content area is scrollable with bottom padding equal to bottom-nav height + safe-area inset +- [ ] **SHELL-06**: Fixed bottom nav: `border-t bg-background`, `max-w-lg mx-auto` wrapper, 5 cells (4 tabs + More) + +### NAV — Bottom tab bar (spec §3.1) + +- [ ] **NAV-01**: Four equal-width primary tabs: Dashboard (`LayoutDashboard`), Tickets (`Ticket`), Finance (`DollarSign`), Analyzer (`Sparkles`) +- [ ] **NAV-02**: Tabs route to `/mobile/dashboard`, `/mobile/tickets`, `/mobile/finance`, `/mobile/analyzer` +- [ ] **NAV-03**: Active state uses `text-primary`, inactive uses `text-muted-foreground`; active detection via `pathname.startsWith(href)` + +### DRAWER — More Sheet drawer (spec §3.2) + +- [ ] **DRAWER-01**: Fifth bottom-bar control labeled "More" with `Menu` icon opens a shadcn `Sheet` +- [ ] **DRAWER-02**: Sheet uses a single consistent side (`right` or `bottom`) — pick one and stay consistent +- [ ] **DRAWER-03**: Drawer top section "Mobile sections" lists Engagement (`/mobile/engagement`) +- [ ] **DRAWER-04**: Drawer middle section "Full site" lists desktop-only pages (Quotes, Configuration Items, Backup Status, Ticket Digest, Admin / Sync) each with `ExternalLink` icon +- [ ] **DRAWER-05**: Drawer bottom section "Account" shows current user (avatar + email, read-only) and a Sign out action that calls `signOut()` then `router.push('/auth/sign-in')` +- [x] **DRAWER-06**: `app/mobile/nav/page.tsx` is deleted in the same change that ships the drawer + +### DASH — Mobile Dashboard (spec §6.1) + +- [ ] **DASH-01**: 2×2 KPI grid with four primary metric cards drawn from desktop dashboard hero stats +- [ ] **DASH-02**: "Needs Attention" horizontal-scroll strip of compact cards (overdue tickets, failed backups, stalled workflows); tapping a card opens its detail view +- [ ] **DASH-03**: Compact backup/worker status row showing analyzer worker, RMM worker, and backup-success-rate; read-only; tap opens desktop admin page +- [ ] **DASH-04**: No charts/recharts on the mobile Dashboard + +### TICK — Mobile Tickets list (spec §6.2) + +- [ ] **TICK-01**: Collapsible filter strip at top (`Collapsible` from shadcn), default collapsed; expanded shows status, priority, queue, assigned-to-me toggle +- [ ] **TICK-02**: Filter state syncs to URL query string for deep-linking +- [ ] **TICK-03**: List rows have left-edge color stripe by priority (Critical/High/Medium/Low → red/orange/amber/slate); body shows ticket #, title, company, age, assignee +- [ ] **TICK-04**: Single-tap on a row opens detail page +- [ ] **TICK-05**: Cursor-based infinite scroll (~25 per page) replaces pagination; next page triggers when last row enters viewport via IntersectionObserver +- [ ] **TICK-06**: "Load more" fallback button present for accessibility +- [ ] **TICK-07**: Detail page (`/mobile/tickets/[id]`) header reskinned to match new shell (Wulf mark, breadcrumb back); body kept largely as-is + +### FIN — Mobile Finance (spec §6.3) + +- [ ] **FIN-01**: Page restyled with new Card and typography scale; spacing fixed for small phones +- [ ] **FIN-02**: Wide tables on mobile widths replaced with stacked lists; no new data, no new sections + +### ANL — Mobile Analyzer feed (spec §6.4) — NEW PAGE + +- [ ] **ANL-01**: `/mobile/analyzer` route exists (read-only feed, most-recent-first stream of AI ticket analyses) +- [ ] **ANL-02**: Each list row shows ticket #, title, analyzer one-line summary, confidence badge, stage indicator (Triage → Analyze → Deep Review) +- [ ] **ANL-03**: Tapping a row opens a mobile summary view rendering Summary, Next Step, Next Step Rationale (all already produced by the analyzer pipeline) +- [ ] **ANL-04**: Summary view includes "View full analysis" link out to the desktop analyzer page +- [ ] **ANL-05**: No editing, no re-run, no prompt tuning on mobile +- [ ] **ANL-06**: Source data via `/api/mobile/analyzer/feed` (or reuse an existing list endpoint if it returns the right shape) reading from `analyzer_analyses` + +### ENG — Mobile Engagement (spec §6.5) — NEW PAGES + +- [ ] **ENG-01**: `/mobile/engagement` overview page (real refactor, not a thin adaptation of the ~1300-line desktop page) +- [ ] **ENG-02**: Period selector chip row (today / 7d / 30d) sticky just below the page H1 +- [ ] **ENG-03**: Summary cards stacked single-column (active users, total Graph hours, total Autotask hours, hours-per-active-user) — no 4-up grid on phone widths +- [ ] **ENG-04**: Per-employee list as stacked rows (avatar/initials, name, role, hours bar) with sort control above (sort by hours, name, utilization) and search input +- [ ] **ENG-05**: Top of list shows compact "hours trend" sparkline scoped to the selected period; no multi-series chart on mobile +- [ ] **ENG-06**: User profile is `/mobile/engagement/[userId]` (segment form preferred for shareable URLs); single-column layout: identity header → period selector → key metrics (compact) → activity breakdown list → recent items +- [ ] **ENG-07**: User profile is a real page, not a modal — replaces desktop user-detail modal pattern on mobile so back gesture works +- [ ] **ENG-08**: Profile reuses existing engagement profile data endpoints; no new data +- [ ] **ENG-09**: Engagement is reachable from the More drawer, NOT the bottom bar + +## v2 Requirements + +Acknowledged but deferred. Not in this milestone's roadmap. + +### NOTIF — Notifications + +- **NOTIF-01**: Real notification list behind the Bell icon (replaces SHELL-03 placeholder) +- **NOTIF-02**: Notification badge logic on the Bell icon + +### TABLET — Tablet breakpoint + +- **TABLET-01**: `md:max-w-2xl mx-auto` wrapper for tablet widths + +### OFFLINE — Offline support + +- **OFFLINE-01**: Service worker for offline cache +- **OFFLINE-02**: Push notifications (requires SW) + +### EDIT — Mobile editing + +- **EDIT-01**: Mobile editing on Engagement user detail +- **EDIT-02**: Mobile re-run / prompt edits on Analyzer + +## Out of Scope + +Explicitly excluded for v1. Documented to prevent scope creep. + +| Feature | Reason | +|---------|--------| +| Service worker / offline cache / push notifications | No clear offline use-case yet — defer until one lands (spec §4, §7) | +| Tablet breakpoint (`md:max-w-2xl`) | Noted as follow-up; keep `max-w-lg` for v1 (spec §4, §7) | +| Real notification list behind the Bell | Placeholder only this iteration; future phase wires it (spec §5.1, §7) | +| Mobile editing on Engagement user detail | Read-only on mobile by design (spec §6.5, §7) | +| Mobile re-run / prompt tuning on Analyzer | Read-only on mobile by design (spec §6.4, §7) | +| Charts / recharts on mobile Dashboard | Not earning their weight on small widths (spec §6.1, §7) | +| Restyling/replacing desktop pages reachable from More drawer | Desktop pages stay as-is (spec §7) | +| Multi-series chart on mobile Engagement overview | Replaced by single sparkline (spec §6.5) | +| Modal-based user detail on mobile | Replaced by real page so back gesture works (spec §6.5) | +| `/mobile-v2` parallel directory | Rebuild `/mobile` in place — keep canonical URLs (spec §2) | + +## Traceability + +Updated during roadmap creation. + +| Requirement | Phase | Status | +|-------------|-------|--------| +| PWA-01 | Phase 1 | Pending | +| PWA-02 | Phase 1 | Pending | +| PWA-03 | Phase 1 | Pending | +| PWA-04 | Phase 1 | Pending | +| SHELL-01 | Phase 2 | Complete | +| SHELL-02 | Phase 2 | Pending | +| SHELL-03 | Phase 2 | Pending | +| SHELL-04 | Phase 2 | Pending | +| SHELL-05 | Phase 2 | Complete | +| SHELL-06 | Phase 2 | Pending | +| NAV-01 | Phase 2 | Pending | +| NAV-02 | Phase 2 | Pending | +| NAV-03 | Phase 2 | Pending | +| DRAWER-01 | Phase 2 | Pending | +| DRAWER-02 | Phase 2 | Pending | +| DRAWER-03 | Phase 2 | Pending | +| DRAWER-04 | Phase 2 | Pending | +| DRAWER-05 | Phase 2 | Pending | +| DRAWER-06 | Phase 2 | Complete | +| DASH-01 | Phase 3 | Pending | +| DASH-02 | Phase 3 | Pending | +| DASH-03 | Phase 3 | Pending | +| DASH-04 | Phase 3 | Pending | +| TICK-01 | Phase 4 | Pending | +| TICK-02 | Phase 4 | Pending | +| TICK-03 | Phase 4 | Pending | +| TICK-04 | Phase 4 | Pending | +| TICK-05 | Phase 4 | Pending | +| TICK-06 | Phase 4 | Pending | +| TICK-07 | Phase 4 | Pending | +| FIN-01 | Phase 5 | Pending | +| FIN-02 | Phase 5 | Pending | +| ANL-01 | Phase 6 | Pending | +| ANL-02 | Phase 6 | Pending | +| ANL-03 | Phase 6 | Pending | +| ANL-04 | Phase 6 | Pending | +| ANL-05 | Phase 6 | Pending | +| ANL-06 | Phase 6 | Pending | +| ENG-01 | Phase 7 | Pending | +| ENG-02 | Phase 7 | Pending | +| ENG-03 | Phase 7 | Pending | +| ENG-04 | Phase 7 | Pending | +| ENG-05 | Phase 7 | Pending | +| ENG-09 | Phase 7 | Pending | +| ENG-06 | Phase 8 | Pending | +| ENG-07 | Phase 8 | Pending | +| ENG-08 | Phase 8 | Pending | + +**Coverage:** +- v1 requirements: 47 total +- Mapped to phases: 47 +- Unmapped: 0 ✓ + +**Per-phase counts:** +- Phase 1 (PWA Scaffolding): 4 requirements +- Phase 2 (Mobile Shell + More Drawer): 15 requirements +- Phase 3 (Dashboard Restyle): 4 requirements +- Phase 4 (Tickets Restyle): 7 requirements +- Phase 5 (Finance Restyle): 2 requirements +- Phase 6 (Analyzer Feed): 6 requirements +- Phase 7 (Engagement Overview): 6 requirements +- Phase 8 (Engagement User Profile): 3 requirements + +--- +*Requirements defined: 2026-05-03* +*Last updated: 2026-05-03 — traceability filled in at roadmap creation* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000..d742777 --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,160 @@ +# Roadmap: Pulse Mobile Shell Redesign + +## Overview + +Eight phases mirror the deliberate build order in the source spec +(`docs/superpowers/specs/2026-05-03-mobile-shell-design.md` §8). Each phase +ships independently to `master` — no big-bang merge. Phase 1 lays PWA +metadata and safe-area utilities. Phase 2 rebuilds `app/mobile/layout.tsx` +with the new header, 5-cell bottom nav, and More drawer (deleting +`/mobile/nav` in the same change). Once the shell lands, Phases 3–7 are +independent restyles/new pages and may be executed in parallel; Phase 8 +follows Phase 7 because the user profile is reached from the Engagement +overview. All work happens in place under `/mobile/*` — no `/mobile-v2`, +no parallel routes. + +## Phases + +**Phase Numbering:** +- Integer phases (1, 2, 3): Planned milestone work +- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED) + +Decimal phases appear between their surrounding integers in numeric order. + +- [ ] **Phase 1: PWA Scaffolding** — Manifest, viewport meta, and safe-area utilities so the shell installs and paints under the home indicator +- [ ] **Phase 2: Mobile Shell + More Drawer** — New `app/mobile/layout.tsx` (header + 5-cell bottom nav) and Sheet drawer that replaces `/mobile/nav` +- [ ] **Phase 3: Dashboard Restyle** — 2×2 KPI grid, Needs Attention strip, worker/backup status row (no charts) +- [ ] **Phase 4: Tickets Restyle** — Collapsible URL-synced filters, priority-bar rows, cursor-based infinite scroll, detail header reskin +- [ ] **Phase 5: Finance Restyle** — Adopt new Card + typography scale, swap wide tables for stacked lists +- [ ] **Phase 6: Analyzer Feed (NEW)** — `/mobile/analyzer` read-only stream + `/api/mobile/analyzer/feed` +- [ ] **Phase 7: Engagement Overview (NEW)** — `/mobile/engagement` phone-first overview reachable from the More drawer +- [ ] **Phase 8: Engagement User Profile (NEW)** — `/mobile/engagement/[userId]` real-page profile that replaces the desktop modal pattern + +## Phase Details + +### Phase 1: PWA Scaffolding +**Goal**: A manager who taps "Add to Home Screen" gets a standalone Pulse icon that opens to the mobile shell with content respecting the device safe areas. +**Depends on**: Nothing (first phase) +**Requirements**: PWA-01, PWA-02, PWA-03, PWA-04 +**Success Criteria** (what must be TRUE): + 1. Visiting `/manifest.json` returns valid JSON with `name: "Pulse"`, `display: "standalone"`, `start_url: "/mobile"`, and theme/background colors matching the app shells + 2. The root `app/layout.tsx` references the manifest via `` and the viewport meta includes `viewport-fit=cover` + 3. A safe-area utility (Tailwind arbitrary values or shared class) is available so any sticky top/bottom bar can opt into `env(safe-area-inset-top)` / `env(safe-area-inset-bottom)` padding + 4. Installing Pulse to a phone home screen launches a chromeless app pointed at `/mobile` (no service worker, no offline) +**Plans**: 2 plans +- [x] 01-01-PLAN.md — Web App Manifest + viewport-fit=cover (PWA-01, PWA-02, PWA-03) +- [x] 01-02-PLAN.md — Safe-area `pt-safe` / `pb-safe` @utility blocks in brand.css (PWA-04, gap closure) +**UI hint**: no + +### Phase 2: Mobile Shell + More Drawer +**Goal**: Every `/mobile/*` page renders inside a new layout — sticky header (Wulf mark + Bell placeholder + avatar), scrollable content, and a 5-cell bottom nav whose fifth control opens a Sheet drawer that fully replaces `/mobile/nav`. +**Depends on**: Phase 1 +**Requirements**: SHELL-01, SHELL-02, SHELL-03, SHELL-04, SHELL-05, SHELL-06, NAV-01, NAV-02, NAV-03, DRAWER-01, DRAWER-02, DRAWER-03, DRAWER-04, DRAWER-05, DRAWER-06 +**Success Criteria** (what must be TRUE): + 1. On any `/mobile/*` route the user sees a sticky header with the Wulf wordmark linking to `/mobile/dashboard`, a Bell icon button (keyboard-focusable, no menu), and a compact avatar — no page title in the header + 2. A fixed bottom bar exposes four primary tabs (Dashboard, Tickets, Finance, Analyzer) plus a More cell; tapping a tab routes to its page and the active tab uses `text-primary` based on `pathname.startsWith(href)` + 3. Tapping More (or the header avatar) opens a single Sheet drawer with three sections — Mobile sections (Engagement), Full site (Quotes, Configuration Items, Backup Status, Ticket Digest, Admin/Sync — each with an `ExternalLink` hint), and Account (current user read-only + Sign out) + 4. Tapping Sign out in the drawer signs the user out and lands them on `/auth/sign-in` + 5. `app/mobile/nav/page.tsx` no longer exists; visiting `/mobile/nav` does not render the old standalone nav page + 6. Page content scrolls under the sticky header and is not hidden behind the bottom nav (bottom padding accounts for nav height + safe-area inset) +**Plans**: 2 plans +- [x] 02-01-PLAN.md — Build mobile shell components (HeaderBar, BottomNav, MoreDrawer) + analyzer placeholder (SHELL-02..04, SHELL-06, NAV-01..03, DRAWER-01..05) +- [x] 02-02-PLAN.md — Wire new components into app/mobile/layout.tsx, delete app/mobile/nav/page.tsx (SHELL-01, SHELL-05, DRAWER-06) +**UI hint**: yes + +### Phase 3: Dashboard Restyle +**Goal**: A manager opening `/mobile/dashboard` sees the state of the business at a glance — four KPIs, items needing attention, and a worker/backup status row — with no charts. +**Depends on**: Phase 2 +**Requirements**: DASH-01, DASH-02, DASH-03, DASH-04 +**Success Criteria** (what must be TRUE): + 1. Dashboard renders a 2×2 grid of four primary KPI cards drawn from desktop hero stats (no 1×4 row, no charts) + 2. Below the grid, a "Needs Attention" horizontally-scrollable strip surfaces overdue tickets, failed backups, and stalled workflows; tapping a card opens its detail view + 3. A compact status row shows analyzer worker, RMM worker, and backup-success-rate; tapping any element opens the corresponding desktop admin page + 4. The page contains no recharts/chart components on phone widths +**Plans**: 2 plans +- [x] 03-01-PLAN.md — /api/mobile/dashboard reshape + KpiCardMobile/NeedsAttentionStrip/WorkerStatusRow components (DASH-01, DASH-02, DASH-03) +- [x] 03-02-PLAN.md — Replace /mobile/dashboard page body with 3-section layout, no charts (DASH-01, DASH-02, DASH-03, DASH-04) +**UI hint**: yes + +### Phase 4: Tickets Restyle +**Goal**: A manager triages tickets on a phone with a collapsible filter bar that deep-links via URL, priority-coloured rows, and infinite scroll — and the detail page header matches the new shell. +**Depends on**: Phase 2 +**Requirements**: TICK-01, TICK-02, TICK-03, TICK-04, TICK-05, TICK-06, TICK-07 +**Success Criteria** (what must be TRUE): + 1. The Tickets page opens with the filter strip collapsed; expanding it reveals status, priority, queue, and an assigned-to-me toggle, and changing any filter updates the URL query string (deep link works on reload) + 2. Each list row has a left-edge stripe matching priority (Critical/High/Medium/Low → red/orange/amber/slate) and shows ticket #, title, company, age, and assignee + 3. Single-tapping a row navigates to `/mobile/tickets/[id]` + 4. Scrolling to the bottom of the list automatically loads the next ~25 rows (no Next button); a "Load more" fallback button is also visible/focusable for accessibility + 5. The detail page header uses the new shell styling (Wulf mark, breadcrumb back) while the body remains largely unchanged +**Plans**: 3 plans +- [ ] 04-01-PLAN.md — /api/mobile/tickets cursor rewrite + TicketFilterStrip + TicketRowSkeleton components (TICK-01, TICK-02, TICK-05) +- [ ] 04-02-PLAN.md — Replace app/mobile/tickets/page.tsx with URL-synced filters, priority-stripe rows, IntersectionObserver infinite scroll (TICK-01..TICK-06) +- [ ] 04-03-PLAN.md — Reskin in-page header of app/mobile/tickets/[id]/page.tsx (back chevron + breadcrumb + ExternalLink) (TICK-07) +**UI hint**: yes + +### Phase 5: Finance Restyle +**Goal**: A manager reading AR / invoice / payment status on a phone sees properly spaced cards and stacked lists instead of squished wide tables — same data, new shell. +**Depends on**: Phase 2 +**Requirements**: FIN-01, FIN-02 +**Success Criteria** (what must be TRUE): + 1. `/mobile/finance` adopts the new Card and typography scale — no horizontal overflow, spacing legible on small phones + 2. Sections that previously rendered wide tables on phone widths now render as stacked lists (no new sections, no new data sources) +**Plans**: TBD +**UI hint**: yes + +### Phase 6: Analyzer Feed (NEW) +**Goal**: A manager taps the Analyzer tab and skims a most-recent-first stream of AI ticket analyses, opening any one to a phone-friendly summary view that links out to desktop for full details. +**Depends on**: Phase 2 +**Requirements**: ANL-01, ANL-02, ANL-03, ANL-04, ANL-05, ANL-06 +**Success Criteria** (what must be TRUE): + 1. Tapping the Analyzer tab in the bottom nav lands on `/mobile/analyzer` and shows a most-recent-first list of AI ticket analyses + 2. Each row shows ticket #, title, the analyzer's one-line summary, a confidence badge, and a stage indicator (Triage → Analyze → Deep Review) + 3. Tapping a row opens a mobile summary view rendering Summary, Next Step, and Next Step Rationale, with a "View full analysis" link out to the desktop analyzer page + 4. The mobile feed never exposes editing, re-run, or prompt-tuning controls (read-only by design) + 5. The list reads from `analyzer_analyses` via `/api/mobile/analyzer/feed` (or a reused list endpoint that already returns the right shape) +**Plans**: TBD +**UI hint**: yes + +### Phase 7: Engagement Overview (NEW) +**Goal**: A manager reaches Engagement from the More drawer and sees a phone-first overview — period chips, stacked summary cards, a sortable per-employee list, and one compact sparkline. +**Depends on**: Phase 2 +**Requirements**: ENG-01, ENG-02, ENG-03, ENG-04, ENG-05, ENG-09 +**Success Criteria** (what must be TRUE): + 1. The Mobile sections row in the More drawer links to `/mobile/engagement`; the Analyzer is on the bottom bar but Engagement is not + 2. The overview page shows a period selector (today / 7d / 30d) sticky just below the H1, with active period clearly indicated + 3. Summary cards (active users, total Graph hours, total Autotask hours, hours-per-active-user) render single-column stacked — no 4-up grid on phone widths + 4. The per-employee list renders as stacked rows (avatar/initials, name, role, hours bar) with a search input and a sort control above (sort by hours, name, utilization) + 5. A single compact "hours trend" sparkline renders at the top of the list, scoped to the selected period — no multi-series chart +**Plans**: TBD +**UI hint**: yes + +### Phase 8: Engagement User Profile (NEW) +**Goal**: From the Engagement overview, a manager taps an employee row and arrives at a real, shareable profile page — single-column phone-first — and the device back gesture returns them to the overview. +**Depends on**: Phase 7 +**Requirements**: ENG-06, ENG-07, ENG-08 +**Success Criteria** (what must be TRUE): + 1. Tapping a row in the per-employee list navigates to `/mobile/engagement/[userId]` (segment form, shareable URL) + 2. The profile is a real page (not a modal) — the device/browser back gesture returns to the overview at the same scroll position + 3. The profile renders single-column: identity header → period selector → key metrics (compact) → activity breakdown list → recent items, sourced from the existing engagement profile data endpoints (no new data) +**Plans**: TBD +**UI hint**: yes + +## Progress + +**Execution Order:** +Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, parallelizable). Phase 8 follows Phase 7. + +| Phase | Plans Complete | Status | Completed | +|-------|----------------|--------|-----------| +| 1. PWA Scaffolding | 1/2 | Executing | - | +| 2. Mobile Shell + More Drawer | 0/TBD | Not started | - | +| 3. Dashboard Restyle | 0/2 | Not started | - | +| 4. Tickets Restyle | 0/3 | Not started | - | +| 5. Finance Restyle | 0/TBD | Not started | - | +| 6. Analyzer Feed | 0/TBD | Not started | - | +| 7. Engagement Overview | 0/TBD | Not started | - | +| 8. Engagement User Profile | 0/TBD | Not started | - | + +--- +*Roadmap created: 2026-05-03* +*Source spec: `docs/superpowers/specs/2026-05-03-mobile-shell-design.md`* diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000..86f631e --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,85 @@ +--- +gsd_state_version: 1.0 +milestone: v1.0 +milestone_name: milestone +status: executing +stopped_at: Completed 02-mobile-shell-more-drawer/02-02-PLAN.md +last_updated: "2026-05-03T21:09:09.113Z" +last_activity: 2026-05-03 +progress: + total_phases: 8 + completed_phases: 3 + total_plans: 6 + completed_plans: 6 + percent: 100 +--- + +# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-05-03) + +**Core value:** A manager can open Pulse on their phone and, in under 30 seconds, see the state of the business and triage tickets — without ever needing to switch to desktop for read-only awareness. +**Current focus:** Phase 03 — dashboard-restyle + +## Current Position + +Phase: 4 +Plan: Not started +Status: Executing Phase 03 +Last activity: 2026-05-03 + +Progress: [░░░░░░░░░░] 0% + +## Performance Metrics + +**Velocity:** + +- Total plans completed: 6 +- Average duration: — +- Total execution time: 0.0 hours + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| 01 | 2 | - | - | +| 02 | 2 | - | - | +| 03 | 2 | - | - | + +**Recent Trend:** + +- Last 5 plans: — +- Trend: — + +*Updated after each plan completion* +| Phase 02-mobile-shell-more-drawer P02 | 8 | 4 tasks | 2 files | + +## Accumulated Context + +### Decisions + +Decisions are logged in PROJECT.md Key Decisions table. +Recent decisions affecting current work: + +- Roadmap: Phases mirror the spec's 8-step build order so each step ships independently to `master` (spec §8) +- Phase 2 unblocks Phases 3–7; Phases 3–7 are mutually independent and can be parallelized; Phase 8 depends on Phase 7 +- All work happens in place under `/mobile/*` — no `/mobile-v2`, no parallel routes (spec §2) +- [Phase 02-mobile-shell-more-drawer]: Single useState in mobile layout.tsx for drawer open state — no Zustand/Context per CLAUDE.md constraint +- [Phase 02-mobile-shell-more-drawer]: Tailwind 4 pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))] arbitrary value works without inline-style fallback +- [Phase 02-mobile-shell-more-drawer]: No redirect on /mobile/nav deletion — standard 404 per DRAWER-06 spec + +### Pending Todos + +None yet. + +### Blockers/Concerns + +None yet. + +## Session Continuity + +Last session: 2026-05-03T20:12:16.872Z +Stopped at: Completed 02-mobile-shell-more-drawer/02-02-PLAN.md +Resume file: None diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..cffe369 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,211 @@ +# 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/.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 `isConfigured()` 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 `isConfigured()` 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* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 0000000..d2cefa1 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,226 @@ +# 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* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 0000000..a947a2f --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,263 @@ +# 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()`) +- 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` for optional object shapes + +**Return Values:** +- Async functions always return `Promise` 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: `` + +**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 `
` wrapper from shadcn + +**Charts:** +- Use `recharts` for data visualization (e.g., ``, ``) + +## 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* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..e724fd3 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,267 @@ +# 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* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 0000000..c4876a2 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,139 @@ +# 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* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 0000000..ed6768a --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,418 @@ +# 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 ``) + +**`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 `` +- Wrapper types: `*Modal`, `*Panel`, `*Dialog`, `*Card` (e.g., `DetailModal`, `related-tickets-panel`) + +**Functions & Variables:** +- camelCase: `requireAuth()`, `postgresClient.query()`, `getAutotaskClient()` +- Factories: `getClient()`, `isConfigured()` (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/-client.ts` + `lib/services/-factory.ts` + `lib/services/-sync-service.ts` +- Types: `lib/types/.ts` +- API routes: `app/api//route.ts` +- Pages: `app//page.tsx` +- Components: `components//*.tsx` +- Tests: `lib/services//*.test.ts` (if logic is testable) + +**New Component/Module:** +- Implementation: `components//.tsx` (or `components/ui/` if it's a primitive) +- Usage: Import via `@/components//` + +**New Page:** +- File: `app//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/.ts` (e.g., `lib/utils/env.ts` for type-safe env access) +- Service-local helpers: Inline in `lib/services/.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* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 0000000..982b501 --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,438 @@ +# 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; + +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 { + 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; +``` + +*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; +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 { + 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; +} + +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* diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 0000000..040e7b2 --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,38 @@ +{ + "model_profile": "balanced", + "commit_docs": true, + "parallelization": true, + "search_gitignored": false, + "brave_search": false, + "firecrawl": false, + "exa_search": false, + "git": { + "branching_strategy": "none", + "phase_branch_template": "gsd/phase-{phase}-{slug}", + "milestone_branch_template": "gsd/{milestone}-{slug}", + "quick_branch_template": null + }, + "workflow": { + "research": false, + "plan_check": true, + "verifier": true, + "nyquist_validation": true, + "auto_advance": false, + "node_repair": true, + "node_repair_budget": 2, + "ui_phase": true, + "ui_safety_gate": true, + "text_mode": false, + "research_before_questions": false, + "discuss_mode": "discuss", + "skip_discuss": false + }, + "hooks": { + "context_warnings": true + }, + "project_code": null, + "phase_naming": "sequential", + "agent_skills": {}, + "mode": "yolo", + "granularity": "standard" +} \ No newline at end of file diff --git a/.planning/phases/01-pwa-scaffolding/01-01-SUMMARY.md b/.planning/phases/01-pwa-scaffolding/01-01-SUMMARY.md new file mode 100644 index 0000000..b652ff6 --- /dev/null +++ b/.planning/phases/01-pwa-scaffolding/01-01-SUMMARY.md @@ -0,0 +1,142 @@ +--- +phase: 01-pwa-scaffolding +plan: 01 +subsystem: pwa-shell +tags: [pwa, manifest, viewport, mobile] +requires: + - app/layout.tsx (existing root layout with metadata export) + - public/wulff-logo.png, public/favicon.png, public/branding/wulf-mark.png (existing icon assets) +provides: + - public/manifest.json (Web App Manifest at /manifest.json) + - app/layout.tsx exports `viewport: Viewport` with viewportFit: "cover" + - app/layout.tsx exports `metadata.manifest = "/manifest.json"` (Next.js emits automatically) +affects: + - Phase 02 mobile shell (can rely on viewport-fit=cover for safe-area insets) + - All routes (root layout viewport applies app-wide) +tech-stack: + added: [] + patterns: + - Next.js 16 separate `viewport` export (replaces deprecated metadata.viewport) + - Next.js 16 metadata.manifest field (auto-emits ) +key-files: + created: + - public/manifest.json + modified: + - app/layout.tsx +decisions: + - theme_color #0075AD chosen as Wulf primary brand blue (sourced from app/styles/brand.css line 28, --wulf-blue) — gives consistent system UI tint in light and dark mode since manifest only allows one value + - background_color #FFFFFF chosen as the light shell background — manifest only allows one splash background, white matches Pulse's default light theme and is acceptable on dark devices (brief flash, not a regression) + - Used metadata.manifest field over hand-rolled — Next.js 16 emits the link tag automatically, satisfies spec wording, and keeps with the existing metadata API pattern + - Reused existing icon assets with `"sizes": "any"` (wulff-logo.png, branding/wulf-mark.png, favicon.png) instead of generating sized 192/512 variants — install tools accept this for PNGs; sized icons can be added in a future polish phase if install warns + - Added themeColor light/dark pair in viewport (one-line improvement) — paired with Next.js helper, emits per-scheme tags. Optional per the plan; kept since it costs nothing and improves dark-mode rendering + - orientation set to "portrait" — phone-first per spec §1/§2; tablet landscape is explicit out-of-scope per spec §7 + - scope set to "/" — allow standalone window to navigate anywhere in the app without falling out to browser +metrics: + duration: ~1m + tasks_completed: 2 + files_created: 1 + files_modified: 1 + completed: 2026-05-03T17:38:55Z +--- + +# Phase 01 Plan 01: PWA Scaffolding Summary + +PWA install surface added: a Web App Manifest at `/manifest.json` plus a Next.js 16 viewport export with `viewport-fit=cover` so the mobile shell can paint behind the device home indicator in future phases. + +## What Shipped + +### Task 1: `public/manifest.json` (NEW) + +Hand-written 31-line JSON manifest with all spec-mandated fields: + +| Field | Value | Why | +|-------|-------|-----| +| `name`, `short_name` | "Pulse" | Spec §4 verbatim | +| `description` | Wulf operations console blurb | Install dialog readability | +| `start_url` | `/mobile` | Spec §4 — phone install lands on mobile shell, not desktop dashboard | +| `scope` | `/` | Allow standalone window to navigate the whole app | +| `display` | `standalone` | Spec §4 — chromeless app surface | +| `orientation` | `portrait` | Phone-first (spec §1, §2); tablet landscape is OOS (§7) | +| `theme_color` | `#0075AD` | Wulf primary blue from `app/styles/brand.css` line 28 | +| `background_color` | `#FFFFFF` | Light shell background (manifest allows only one) | +| `icons` | 3 entries with `sizes: "any"` | Reuses `/wulff-logo.png`, `/branding/wulf-mark.png`, `/favicon.png` | + +No `serviceworker`, no `display_override`, no `prefer_related_applications`, no `next-pwa` — per spec §4 and CLAUDE.md. + +**Commit:** `3e3df24` + +### Task 2: `app/layout.tsx` (MODIFIED) + +Three minimal additions to the existing root layout, body unchanged: + +1. Import upgraded: `import type { Metadata, Viewport } from "next";` +2. `metadata.manifest = "/manifest.json"` added alongside the existing `icons` field — Next.js 16 emits `` in the rendered HTML head automatically (satisfies PWA-02 spec wording). +3. New `viewport` export: + + ```ts + export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#FFFFFF" }, + { media: "(prefers-color-scheme: dark)", color: "#0A0A0A" }, + ], + }; + ``` + + `viewportFit: "cover"` is the load-bearing field for PWA-03 — Next.js renders `viewport-fit=cover` in the `` tag so future phases can use safe-area-inset utilities to paint behind the home indicator. `width`, `initialScale`, and `themeColor` are baseline mobile defaults that prevent Next.js viewport warnings. + +**Commit:** `d196d22` + +## Verification Results + +| Gate | Result | +|------|--------| +| `test -f public/manifest.json` | PASS | +| `jq -e '.name == "Pulse" and .display == "standalone" and .start_url == "/mobile"' public/manifest.json` | PASS (true) | +| `jq -e '.theme_color == "#0075AD" and .background_color == "#FFFFFF"' public/manifest.json` | PASS | +| `jq -e '.icons \| length >= 1' public/manifest.json` | PASS (3 icons) | +| `jq -e '.serviceworker == null' public/manifest.json` | PASS | +| `jq empty public/manifest.json` | PASS (valid JSON) | +| `grep -E '^import type \{ Metadata, Viewport \} from "next"' app/layout.tsx` | PASS | +| `grep -E 'manifest:\s*"/manifest\.json"' app/layout.tsx` | PASS | +| `grep -E '^export const viewport: Viewport = \{' app/layout.tsx` | PASS | +| `grep -E 'viewportFit:\s*"cover"' app/layout.tsx` | PASS | +| `grep -E 'width:\s*"device-width"' app/layout.tsx` | PASS | +| `grep -E 'initialScale:\s*1' app/layout.tsx` | PASS | +| `grep -E 'apple:\s*"/wulff-logo\.png"' app/layout.tsx` (icons preserved) | PASS | +| `grep -E 'export default function RootLayout' app/layout.tsx` (body intact) | PASS | +| `! grep -E "^'use client'" app/layout.tsx` | PASS | +| `npx tsc --noEmit --pretty` | exit 0 | +| `test ! -f public/sw.js && test ! -f public/service-worker.js` | PASS | +| `! grep '"next-pwa"' package.json` | PASS | + +**Dev-server-only checks** (`curl http://localhost:3100/manifest.json`, `curl http://localhost:3100/ \| grep viewport-fit=cover`) were not run — this executor runs in a worktree without a dev server. The offline equivalents above are equivalent: the file is a static asset served verbatim by Next.js from `public/`, and `viewportFit: "cover"` is type-checked to render `viewport-fit=cover` per Next.js 16's documented metadata API. + +## Requirements Satisfied + +- **PWA-01:** `public/manifest.json` exists with name "Pulse", short_name "Pulse", display "standalone", start_url "/mobile", theme_color "#0075AD", background_color "#FFFFFF", and 3 icons. +- **PWA-02:** `app/layout.tsx` references the manifest via `metadata.manifest = "/manifest.json"` — Next.js 16 emits the `` tag automatically. +- **PWA-03:** `app/layout.tsx` exports `viewport: Viewport` with `viewportFit: "cover"` — Next.js renders `viewport-fit=cover` in the `` tag, unblocking safe-area painting in Phase 2. + +## Deviations from Plan + +None - plan executed exactly as written. + +No bugs encountered, no missing critical functionality, no blocking issues, no architectural decisions needed. + +## Threat Surface Scan + +No new threat surface introduced beyond the plan's ``. The manifest is world-readable per W3C Web App Manifest spec and contains only public branding (no secrets, no user data, no endpoints). The viewport export is server-rendered with no user input flow. ASVS-L1 baseline preserved. + +## Known Stubs + +None. All values are real (brand colors sourced from `app/styles/brand.css`, icons reference real public assets, start_url matches the existing `/mobile` route). + +## Self-Check: PASSED + +- `[ -f public/manifest.json ]` → FOUND +- `[ -f app/layout.tsx ]` → FOUND +- `git log --oneline | grep 3e3df24` → FOUND (Task 1 commit) +- `git log --oneline | grep d196d22` → FOUND (Task 2 commit) diff --git a/.planning/phases/01-pwa-scaffolding/01-02-SUMMARY.md b/.planning/phases/01-pwa-scaffolding/01-02-SUMMARY.md new file mode 100644 index 0000000..631e33e --- /dev/null +++ b/.planning/phases/01-pwa-scaffolding/01-02-SUMMARY.md @@ -0,0 +1,174 @@ +--- +phase: 01-pwa-scaffolding +plan: 02 +subsystem: pwa-scaffolding +gap_closure: true +tags: [css, tailwind4, mobile, pwa, safe-area] +requirements_satisfied: [PWA-04] +roadmap_criteria_satisfied: ["Phase 1 SC #3 — safe-area utility available"] +dependency_graph: + requires: [] + provides: + - "@utility pt-safe (padding-top: env(safe-area-inset-top))" + - "@utility pb-safe (padding-bottom: env(safe-area-inset-bottom))" + affects: + - "Phase 2 (mobile shell) — sticky header (SHELL-05) and fixed bottom nav (SHELL-06) consume these utilities" +tech_stack: + added: [] + patterns: + - "Tailwind 4 @utility blocks (already in use across brand.css)" + - "CSS env(safe-area-inset-*) — browser-native, falls back to 0" +key_files: + created: [] + modified: + - app/styles/brand.css +decisions: + - "Named utilities (pt-safe / pb-safe) over arbitrary values (pt-[env(safe-area-inset-top)]) — single source of truth, clearer JSX, easy future tweak if iOS rules change" + - "brand.css over globals.css — co-located with all other named project utilities (num, metric-label, surface-brand, tagline, etc.); already imported by globals.css line 125" + - "Top + bottom only (no pl-safe / pr-safe) — manifest pins orientation to portrait; left/right insets only matter in landscape on notched devices; speculative until a consumer asks" + - "Plain env() (not max(env(), 0px)) — env() already returns 0 on devices without insets; max() wrapper is a no-op" +metrics: + duration: "~5 min" + completed: 2026-05-03 + tasks_completed: 1 + files_modified: 1 + commits: 1 +--- + +# Phase 01 Plan 02: PWA-04 Safe-Area Utility Gap Closure Summary + +**One-liner:** Adds shared `pt-safe` / `pb-safe` Tailwind 4 `@utility` blocks to `app/styles/brand.css`, closing the orphaned PWA-04 requirement so Phase 2's sticky header and fixed bottom nav can opt into iOS notch / Android home-indicator padding via `env(safe-area-inset-*)`. + +## Requirements Satisfied + +- **PWA-04** — Header and bottom tab bar respect `env(safe-area-inset-top/bottom)` (Tailwind arbitrary values or shared utility class). **Closed** by shipping `@utility pt-safe` and `@utility pb-safe` in `app/styles/brand.css`. This restores the orphaned-requirement state flagged by `01-VERIFICATION.md` (where 01-01 had declared `requirements: [PWA-01, PWA-02, PWA-03]` only and silently deferred PWA-04 to Phase 2). +- **ROADMAP Phase 1 Success Criterion #3** — "Shared safe-area utility class available" — satisfied by the same two `@utility` blocks. + +## What Changed + +### Files Modified + +- `app/styles/brand.css` — appended one section comment block + two `@utility` definitions between the existing `@utility tagline` (ends line 140) and the `/* === Wolf-mark watermark === */` section header (now line 165). Net: **+23 lines, 0 deletions.** + +### Exact Diff (additive only) + +```css +/* === Safe-area insets ================================================= + * + * Opt-in padding helpers for sticky top / fixed bottom bars on devices + * with notches, dynamic islands, or gesture home indicators. Pair with + * the viewport-fit=cover viewport meta (set in app/layout.tsx) — without + * that, env(safe-area-inset-*) resolves to 0 and these utilities are + * no-ops, which is the desired fallback on non-PWA / non-mobile contexts. + * + * Usage: + *
// header clears notch + *