Commit graph

196 commits

Author SHA1 Message Date
e057255f4f fix(24): address code-review findings — PATCH identity guard, empty-array tombstone, health-check timeout, record-key normalization
Two critical issues from the post-phase code review:

- PATCH /api/route53/zones/[zoneId]/records/[recordId] never verified the
  request body's name/type/setIdentifier matched the record identified by
  the URL. A mismatch would silently UPSERT a brand-new AWS recordset
  (leaving the original live and untouched) while corrupting the mirror's
  record_key invariant. Now rejects with 400 if any of those three fields
  differ from the existing record — renaming/retyping is delete-plus-create,
  not an update.

- route53-sync-service.ts's syncZones()/syncRecords() tombstone queries used
  "id <> ALL(seenIds)" style queries with no empty-array guard — a
  successful-but-empty AWS response would soft-delete every previously
  synced zone/record in one shot. Same bug class already fixed in
  pax8-sync-service.ts; now guarded the same way here.

Two smaller fixes:

- checkRoute53()'s AWS auth probe had no timeout, unlike every other
  integration's liveCheck() (8s AbortController). Added the same bound via
  the SDK's abortSignal option.
- buildRecordKey() relied on every caller to pre-normalize name/type case
  before calling it. Now normalizes internally (lowercase name, uppercase
  type) so the record_key invariant holds regardless of caller discipline.

Full REVIEW.md findings in 24-REVIEW.md. Two remaining Warnings (alias
records un-editable/undeletable, no admin-UI surface for route53_audit_log)
deliberately left as backlog items for a follow-up phase — out of scope for
a post-execution fix pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 23:15:27 -04:00
b3048be769 feat(24-07): zones, records, and history page shell for /admin/sync/route53
- Four-tab detail page (Zones / Records / History / Schedule) following
  the veeam/pax8 sync-detail-page shape
- Sync Now trigger with bounded polling and 409/503 handling
- Records tab: zone selector, type/search filters, actions cell gated on
  the D-01 writable-type allowlist (NS/SOA render read-only)
- History tab: per-record change ledger with pulse_crud vs
  sync_detected_drift source badges (D-06)
- Zone/history detail drill-downs via the existing DetailModal component
2026-08-05 20:52:25 -04:00
c47de2a91c chore: merge executor worktree (worktree-agent-a8fd5f9961335cf8d) — plan 24-05 2026-08-05 20:38:35 -04:00
a7d6a04110 feat(24-05): CRUD write routes with pending/committed/failed audit lifecycle
- app/api/route53/zones/[zoneId]/records/route.ts: add POST (create)
- app/api/route53/zones/[zoneId]/records/[recordId]/route.ts: PATCH (update), DELETE
- All three write handlers: requireAdmin() first (D-04), validateRecordWrite() before
  any AWS command (D-01), createPendingAuditLog() before submitRecordChange() (D-07/SC-3)
- Committed path: markAuditCommitted -> insertPulseCrudHistory (pulse_crud, SC-4) ->
  mirror refresh; failed path: sanitizeAwsError -> markAuditFailed -> 502, no history row
- DELETE submits the exact mirror-read recordset (name/type/ttl/resourceRecords), never
  client-supplied values, per Route 53's exact-match delete requirement
- recordId zone-prefix mismatch guard (T-24-17): 400 before any audit row or AWS call
- No staged-approval mechanism anywhere (D-03) — mutation executes on first request
- tsc clean; npm test 554/556 passing (2 pre-existing itglue-search failures, unrelated,
  logged in deferred-items.md, already documented by plans 24-01/24-03)
2026-08-05 20:36:54 -04:00
53ec51c5e0 feat(24-05): read routes for zones, records, history, and sync status
- app/api/route53/sync/route.ts: POST (requireAdmin, fire-and-forget) + GET (requireAuth, status/history)
- app/api/route53/zones/route.ts: GET (requireAuth) list mirrored hosted zones
- app/api/route53/zones/[zoneId]/records/route.ts: GET (requireAuth) list records with type/search filters
- app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts: GET (requireAuth) append-only change ledger
- None gated on integration_settings disable toggle (D-10 — route53 is not a PAX8-style exception)
- /api/route53 confirmed absent from middleware.ts public-route list
- tsc clean
2026-08-05 20:33:58 -04:00
6627cee925 feat(24-06): add Route 53 tile to /admin/sync integration list
- Hand-authored public/logos/route53.svg (globe/DNS glyph, primitive
  shapes only, no script/external refs/raster data)
- New INTEGRATIONS entry linking to /admin/sync/route53 (built in
  plan 24-07), color: orange (already used by datto-rmm)
2026-08-05 20:31:26 -04:00
672f17b7f9 chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts
Bundles several in-progress efforts that were sitting uncommitted:
- User queue-preferences (migration 087, API route, popover component)
- QBO invoice soft-delete (migration 088) and AR diagnostics route
- Dashboard/mobile engagement route and page adjustments
- Docker Compose log-rotation config
- One-off ticket/RMM investigation scripts (scripts/)
- Planning docs: phase verification/pattern notes, mobile shell design spec
- .gitignore: exclude local scratch financial/inventory data and Claude Code
  worktree/local-settings runtime state (never meant for version control)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
2026-07-18 06:34:57 -04:00
97804f2e5b feat(260717-v6c): add POST /api/phishing/campaigns/[id]/mark-accidental-report route
Mirrors mark-false-positive route exactly (requirePermission phishing/approve,
UUID validation, optional reason body, campaign-existence check, 409/400/500
error mapping) but calls markCampaignAccidentalReport and returns the richer
result including notePosted/noteError.
2026-07-17 22:33:09 -04:00
160fe88253 feat(23-04): add admin index tile for Phishing Automation
- Adds a Phishing Automation tile to the Tools & Data section of /admin
- Links to /admin/phishing-automation, imports Bot icon from lucide-react
2026-07-16 21:01:08 -04:00
5dabaad722 feat(23-04): build /admin/phishing-automation page with per-stage toggles
- Company table with search + type filter (cloned from client-scope pattern)
- Three independent Switch toggles per row: auto-parse, auto-classify, auto-report
- toggle() PATCHes /api/admin/phishing-automation/{companyId} with all three current flags
- Helper caption clarifies stage dependency (informational, not enforced)
2026-07-16 19:46:16 -04:00
ea5047c80a feat(23-03): add admin phishing-automation GET/PATCH/DELETE routes
- GET /api/admin/phishing-automation: admin-gated list with COALESCE(...,false) gate defaults
- PATCH /api/admin/phishing-automation/[companyId]: upserts all three flags, actor+timestamp stamped
- DELETE /api/admin/phishing-automation/[companyId]: reverts company to all-OFF default
- Mirrors app/api/admin/company-scope/* route pattern
2026-07-16 19:37:06 -04:00
9951e53832 fix(260716-n46): clamp blast-radius date window and resolve per-company Mimecast tenant
Bug 1: clamp dateWindow.end to Math.min(createdAt + 24h, Date.now()) so a
freshly-detected campaign (<24h old primary report) never sends Mimecast a
future end-date -- previously rejected as err_track_and_trace_invalid_end_date
and swallowed internally as a false-clean zero-count result.

Bug 2 (D-05): add company_id to the reports SELECT and, when the reporting
company has its own enabled mimecast_tenants row, resolve a tenant-scoped
client via getMimecastClientForTenant() and thread it into getBlastRadius as
{ client, cacheScope: companyId }. Falls back to the global env-configured
client when no company-specific tenant is registered.
2026-07-16 16:47:19 -04:00
5f5d809050 feat(22-06): campaigns list page + Phishing nav entry (D-00, D-02)
- app/phishing/page.tsx: minimal DataTable-backed campaigns list, row click
  navigates to /phishing/tickets/{firstReportTicketId}, EmptyState when no
  campaigns exist yet
- components/navigation/app-navigation.tsx: add flat "Phishing" nav item
  (ShieldAlert icon) immediately after PAX8, visible to all roles (every
  role has phishing:read)
2026-07-16 14:58:52 -04:00
3761312f93 feat(22-06): ticket-scoped LiveLink review page (REVIEW-01,05,06)
- app/phishing/tickets/[ticketId]/page.tsx: resolves ticket->campaign via
  the plan-02 resolver route, drives a loading/not-triaged/ungrouped/ready/
  error state machine, branches ready into grouped-but-unclassified
  (Classify CTA, no ClassificationCard/ActionAreaCard) vs. classified (all
  four cards with explicit props), refetches after every action (D-04),
  session-only auth (no token/query-param scheme)
- app/api/phishing/reports/[report_id]/route.ts (new, additive): thin
  report-scoped evidence + fresh blast-radius lookup for the D-08
  ungrouped-report state, which has no campaignId to key the existing
  campaign-detail route on — added as a Rule 2 dependency since the plan's
  own D-08 truth ("standalone-report notice + evidence") has no other data
  source
2026-07-16 14:58:39 -04:00
c70b30a000 feat(22-02): add firstReportTicketId to campaigns list route
- Alias campaigns table as c, add correlated subquery for the earliest
  linked report's ticket_id so the list page can navigate a row click
  straight to /phishing/tickets/{firstReportTicketId}
- Additive only: count query, limit/offset, requirePermission gate, and
  the { items, total, limit, offset } envelope all unchanged
2026-07-16 14:43:43 -04:00
9e83ec09ee feat(22-02): extend campaign detail route with evidence, timeline, blast radius
- Widen messages query to include headers/urls/attachments/body_preview
- Widen classifications query to include reasons/recommendedActions/
  requiresApproval
- Add remediationActions (with completedAt derived from audit_events
  payload.actionId, no completion-timestamp column exists) and
  auditEvents to the response
- Add fresh per-request blastRadius via getBlastRadius(), sender/
  recipient/subject derivation copied from campaign-classifier.ts's
  gatherCampaignEvidence (not triage-note-service's empty-string call)
- Add mergeTimeline()-derived chronological timeline
- All additive — existing fields, UUID_RE guard, and auth gate unchanged
2026-07-16 14:42:56 -04:00
ca63910562 feat(22-02): add ticket->campaign resolver route
- New GET /api/phishing/tickets/{ticket_id}/campaign wraps
  resolveTicketToCampaign() from plan 22-01
- requirePermission('phishing','read') gate, Number.isFinite param
  validation, D-07: found:false at 200 (not 404) for untriaged tickets
2026-07-16 14:41:06 -04:00
e3a9cb5191 feat(21-02): add POST /api/phishing/campaigns/[id]/triage-note route
Structural twin of the classify route: requirePermission('phishing',
'analyze') gate, UUID guard, campaign-exists 404 check, delegates to
generateAndPostTriageNote and returns its result verbatim (note text +
per-ticket posted/error status, D-06). No audit-event write — deferred
per 21-CONTEXT.md.
2026-07-16 12:14:23 -04:00
1a126078d7 feat(20-02): add mark-false-positive route and classify audit event
- POST /mark-false-positive: phishing:approve gated (D-04 elevated tier),
  optional reason body, delegates to markCampaignFalsePositive, maps
  RemediationConflictError->409 (already remediated) and
  RemediationValidationError->400
- classify route now writes a 'campaign_classified' audit event after a
  successful classification, completing REMED-06's four-action audit
  coverage (classify/approve/remediate/mark-false-positive)
2026-07-16 10:44:30 -04:00
65c4253f98 feat(20-02): add approve and remediate routes for phishing campaigns
- POST /approve: phishing:approve gated, validates actions array (D-03),
  delegates to approveRemediationActions with actor from session
- POST /remediate: phishing:remediate gated, delegates to
  remediateApprovedActions (idempotent completion, REMED-03/04)
- Both UUID-guard the campaign id and map RemediationValidationError->400,
  RemediationConflictError->409
2026-07-16 10:43:53 -04:00
3e8d5b83c9 feat(19-02): add POST /api/phishing/campaigns/[id]/classify route
- requirePermission('phishing','analyze') early-return (same action as /analyze, Phase 18 D-06)
- UUID_RE guard on campaign id before any DB query (T-19-05)
- 404 when campaign id is well-formed but not found
- delegates to classifyCampaign(id) from lib/services/campaign-classifier.ts (Plan 01), returns flat ClassifyResult payload
2026-07-16 08:26:20 -04:00
650f9b8100 fix(18): decouple campaigns count-query params from list-query placeholder numbering
The count query reused statusFilter (built with $3 against the list query's
3-element params array) but only passed a 1-element params array, causing a
Postgres bind-parameter mismatch (500) on any `?status=` filtered request.
Pre-existing since 18-03; surfaced by the 18-04 gap-closure code re-review.
Gives the count query its own independent param array/placeholder numbering.
2026-07-15 22:35:47 -04:00
abe3d4b900 fix(18-04): clamp campaigns list limit param to [0, 200] (WR-02)
- Parse limit once with Number.isFinite instead of `|| 50`, so an explicit
  limit=0 is honored instead of silently replaced by the default
- Math.max/min clamps to [0, 200], preventing a negative limit from reaching
  the SQL LIMIT clause and raising an unhandled 500
2026-07-15 22:25:29 -04:00
701fea04cc chore: merge executor worktree (worktree-agent-a985b30606e37e1d0) 2026-07-15 19:33:32 -04:00
959907d63b feat(18-03): add GET /api/phishing/campaigns/[id] nested detail
- requirePermission('phishing','read') gate (ACCESS-01)
- UUID-validated id (400 on malformed), 404 when campaign absent
- bulk-fetch reports/messages/indicators via ANY($1::uuid[]) keyed by parent id array (device-link-conflicts pattern)
- requesterEmail derived via reports.requester_contact_id -> contacts join (campaigns has no recipients column)
- messages.subject pulled from headers->>'subject' JSONB (no subject column)
- classifications included in shape (Phase 19 stub, expected empty)
2026-07-15 19:31:35 -04:00
c852cfee13 feat(18-03): add GET /api/phishing/campaigns paginated list
- requirePermission('phishing','read') gate (ACCESS-01)
- limit/offset clamped, optional status filter via parameterized $n placeholder (never string-interpolated)
- camelCase response { items, total, limit, offset }
2026-07-15 19:31:19 -04:00
de013e6ec1 feat(18-02): add POST /api/phishing/tickets/[ticket_id]/analyze route
- Orchestrates detectPhishingTicket -> parseAndStoreMessage -> groupReportIntoCampaign
- requirePermission('phishing','analyze') gate first-line (D-06, 401/403)
- Validates ticket_id numeric (400), missing ticket (404), non-phishing ticket (400)
- No skipIfAlreadyGrouped (D-08) — always re-runs grouping on demand
2026-07-15 19:30:46 -04:00
6ed6c66810 feat(260712-ash): add PAX8 card to sync overview page
- Append PAX8 entry to INTEGRATIONS with logo, description, and detail link
- Fetch /api/pax8/sync in fetchAll, wire pax8 branches in getSummary/getStatusIcon
- Add PAX8 stats block (last sync, companies, subscriptions) to card render
- Add public/logos/pax8.ico as placeholder (copied from itglue.ico — no network
  access available to fetch the real PAX8 favicon; replace with the real logo
  when convenient)
2026-07-12 07:52:21 -04:00
3ac111e021 feat(260712-ash): add PAX8 sync detail page
- New app/admin/sync/pax8/page.tsx mirroring sentinelone/duo pattern
- Polls GET /api/pax8/sync every 10s, shows companies/subscriptions/products stats
- Sync Now button POSTs with triggeredBy, handles 403/409 via sonner toast, polls until complete
2026-07-12 07:51:23 -04:00
d56db023be fix(14): cast companies-list id to Number to match resolve route's z.number() schema
companies.id is BIGINT and node-postgres serializes it as a string. The
manual-search fallback (D-05) fetches from this route and sends the id
straight through to POST /company-matches/[id]/resolve, whose Zod schema
requires a JS number with no coercion — every manual-search resolution was
rejected with 400. Every other PAX8 route in this phase already casts
bigint columns via Number(); this route was the one omission.

Found by code review (14-REVIEW.md, CR-01).
2026-07-11 15:16:38 -04:00
13272f9a6f feat(14-05): manual company-search combobox fallback (D-05/D-09)
- Every review card gets a Command/Popover combobox fed by
  /api/data/companies-list, fetched once via ensureCompaniesLoaded()
  and shared across cards
- Selecting a company enables a "Link to selected company" button that
  calls the same resolve() handler as the candidate buttons
- Zero-candidate reviews (D-09) show only the manual picker; reviews
  with candidates show both candidate buttons and the manual picker
2026-07-11 14:43:56 -04:00
40efa1d3b6 feat(14-05): Needs Review tab review cards, candidate resolve, count badge
- Amber-bordered cards (border-amber-200) list unresolved PAX8 company
  match reviews, fetched from /api/pax8/company-matches on first tab
  activation
- Each candidate row offers a "Link company" button that POSTs to
  .../[id]/resolve; on success the card is optimistically removed,
  toast.success fires, and reviewTotal decrements
- Needs Review TabsTrigger shows a count badge when reviewTotal > 0
- Error/loading/empty states mirror device-link-conflicts' Alert/
  Skeleton/empty-state trio per UI-SPEC copy
- Zero-candidate reviews render the D-09 "No suggested matches" empty
  state; manual-search combobox insertion point left for Task 3
2026-07-11 14:43:05 -04:00
564be52b97 feat(14-05): harden companies-list route with requireAuth
- companies-list now feeds the manual-search fallback for authenticated
  UI (Needs Review tab), closes previously-unauthenticated gap
- response shape unchanged: [{ id, company_name }]
2026-07-11 14:42:10 -04:00
51470f8892 feat(14-04): /pax8 page shell + Companies tab (DataTable + DetailModal drill-down)
- New app/pax8/page.tsx client page with PageHeader + Companies/Needs Review Tabs shell
- Companies tab: DataTable of PAX8 companies (name, matched Autotask company or Unmatched badge, active subscription count, city/state/country) with sort/search/pagination against /api/pax8/companies
- Row click fetch-then-opens the extended DetailModal (kind="pax8_company") with subscriptions + cost breakdown from /api/pax8/companies/[id]
- Needs Review tab left as a marked placeholder for Plan 14-05
2026-07-11 14:36:31 -04:00
580e7ac508 chore: merge executor worktree (worktree-agent-a11c767c2a8721d5b) 2026-07-11 14:31:20 -04:00
a81e358be7 feat(14-02): add POST /api/pax8/company-matches/[id]/resolve route
- requirePermission('admin','access')-gated (D-08) — the write side of the
  asymmetric read/write auth split
- zod-validated body (companyId positive int, note <=500 chars)
- Delegates the two-table write to resolvePax8CompanyMatch inside
  postgresClient.transaction(); maps ResolveResult codes to HTTP status
  (ok->200, not_found->404, already_resolved->409, company_not_found->400)
2026-07-11 14:29:07 -04:00
2cc1abbf9a feat(14-01): add PAX8 company drill-down route with cost breakdown
- GET /api/pax8/companies/[id] with requireAuth gate (D-07), UUID validation, 404 on missing
- Per-subscription latest-billed cost via DISTINCT ON windowed query (Pitfall 2)
- Uses line_total not unit_price*quantity (Pitfall 3); fallback label chain (Pitfall 4)
- Surfaces tombstoned-subscription order-item rows with no matching subscription
2026-07-11 14:28:14 -04:00
443b6ce75b feat(14-01): add paginated PAX8 companies list route
- GET /api/pax8/companies with requireAuth gate (D-07)
- Whitelisted sort columns, parameterized search/limit/offset
- Joins pax8_companies to companies for matched name + active subscription count
2026-07-11 14:28:11 -04:00
a08664baae feat(14-02): add GET /api/pax8/company-matches review queue
- requireAuth-gated (D-07) list of unresolved pax8_company_match_review rows
- Bulk-fetches candidate Autotask company names in one ANY($1::bigint[]) query
- Returns items with pax8 company + zipped candidates (id/name/confidence)
2026-07-11 14:27:30 -04:00
fdc9919381 feat(13-02): gate POST /api/pax8/sync on the disabled toggle
- Return 403 when integration_settings.key='pax8' has disabled=true
- Check runs as the first statement, before isSyncInProgress()
- GET handler unchanged; no new imports (postgresClient already imported)
2026-07-11 09:46:20 -04:00
b168d44585 feat(appgate): add AppGate SDP integration health check and sync service
Registers AppGate as a checkConfigOnly integration-health row and public
sync route, matching the existing factory + is<Name>Configured() pattern.
Committed now so Phase 13's worktree-isolated executors fork from a HEAD
that includes this integration-health.ts entry, since Plan 13-02 inserts
the PAX8 row immediately after it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR
2026-07-11 09:43:48 -04:00
ad992f3f6d feat(11-02): add fire-and-forget POST/GET /api/pax8/sync route
- POST returns 409 if a sync is already in progress, otherwise starts
  Pax8SyncService.fullSync() without awaiting and returns immediately
- GET reports inProgress, non-deleted row counts across the three PAX8
  tables, and the last 10 sync_history rows for entity_type='pax8'
- Route stays behind the session-cookie check (not added to
  middleware.ts's public allowlist) — matches itglue/veeam sync routes
2026-07-10 19:46:34 -04:00
758b7e7f15 feat(engagement): replace Graph email counts with real-time mimecast data
- Broaden mimecast retention from 30 days to 18 months rolling
- Re-enable mimecast-sync schedule (was disabled since March 17)
- Full sync triggered: 35,559 messages loaded for last 30 days
- Users list API: LATERAL join on mimecast_messages for emails_sent/received
- User detail API: add emails{d7,d30,d90} field from mimecast
- Engagement page: prefer mimecast email counts in detail panel sub-label

Graph API has 48-72hr reporting lag; mimecast is same-day
2026-06-02 20:25:14 -04:00
51f0b32cb3 feat(260521-fci-01): add ticket reconciliation service + API route
- New lib/services/ticket-reconciliation-service.ts: reconcileStaleTickets()
  scans tickets where is_deleted=false AND status<>5 AND synced_at older than
  7 days (capped at 500), re-fetches each from Autotask, and either upserts
  via the webhook SQL pattern or soft-deletes when Autotask returns null.
- Returns { scanned, updated, statusFlippedToComplete, softDeleted, errors }.
- New POST /api/sync/reconcile-tickets — fire-and-forget trigger mirroring
  /api/sync/incremental (public per existing middleware allowlist).
2026-05-21 11:08:24 -04:00
5f4ccb9c56 fix(dashboard): correct NOW() timezone conversion for KPI/trend queries
NOW() returns TIMESTAMPTZ. The pattern
  (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $userTz)::date
double-converts: first strips the tz designation (keeping UTC wall-clock as
naive TIMESTAMP), then re-interprets that wall-clock as user-local
(pushing UTC into the user-tz's UTC equivalent). For non-UTC users this
gives the WRONG date — e.g. NY user at 9pm sees "today = tomorrow's UTC
date", so opened-today returns 0.

The column-side pattern ((col AT TIME ZONE 'UTC') AT TIME ZONE $userTz)
is correct because the columns are TIMESTAMP without TZ (stored as UTC) —
only the NOW() side was buggy. Replace with (NOW() AT TIME ZONE $userTz)
everywhere.

Affects: dashboard overview/trends, mobile dashboard/engagement/finance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:58:35 -04:00
3dd379de36 fix: "user" table writes use "updatedAt" not updated_at
Two more sites with the same bug as the theme route — Better Auth's "user"
table column is quoted camelCase. Caught via UAT after the theme PUT fix.

- app/api/settings/profile/route.ts:26 (PATCH admin profile name)
- lib/bootstrap.ts:97 (clearSetupFlag — first-login setup wizard)
2026-05-10 23:03:36 -04:00
041fb164e3 fix(09): theme PUT uses "updatedAt" not updated_at (Better Auth column is camelCase)
Verified via psql that the user table has quoted camelCase columns from
Better Auth ("updatedAt", "createdAt", "emailVerified"). The original
route comment claimed app/api/settings/profile as precedent — that route
is ALSO broken with the same bug; only app/api/me/timezone got it right.
Aligning theme route with the timezone precedent.
2026-05-10 22:49:47 -04:00
586c04ad2a feat(09-05): ThemeSessionBridge + ThemeToggle write-through to /api/me/theme
- Create ThemeSessionBridge.tsx: useEffect compares session.user.theme to
  next-themes value; calls setTheme(serverTheme) on mismatch; validates
  against 3-string allowlist ('light'|'dark'|'system'); renders null
- Mount <ThemeSessionBridge /> as first child of <AuthProvider> in app/layout.tsx
- Modify ThemeToggle: writeTheme() calls setTheme() then fire-and-forget
  PUT /api/me/theme; silent catch for network errors (best-effort desktop UX)
2026-05-10 07:48:57 -04:00
1b7c453c6d feat(09-05): ProfileChannelsSection (Teams + ntfy + QR code) + qrcode.react install
- Create ProfileChannelsSection.tsx with Teams + ntfy sub-sections
- Teams: URL input, inline 400 error (teamsError), save/clear buttons, inline test result
- ntfy: mint-on-first-save (State A → State B), QR code via QRCodeSVG, subscribe link
- ntfy: advanced disclosure with custom topic input + inline 400 error (customTopicError)
- ntfy: test-now and remove buttons
- Install qrcode.react ^4.2.0 (node_modules + package.json + package-lock.json updated)
- Delete ProfileChannelsSectionPlaceholder.tsx (replaced by real component)
- Update app/mobile/profile/page.tsx import to ProfileChannelsSection (not Placeholder)
2026-05-10 07:48:03 -04:00
23a8c7c5d9 feat(09-06): NEW /admin/workflow/executions page + pipeline-executions API (ROUTE-07)
- GET /api/admin/pipeline-executions: requireAdmin(), accepts fallbacks_only/pipeline_id/limit params
- Four complete parameterized SQL strings — no alias-in-WHERE bug (HIGH 4 fix)
- JSONB predicate: output_data ? 'user_route_fallback' inlined in EXISTS subquery in WHERE
- has_fallback boolean on every row (true constant in fallbacks-only branches, EXISTS in unfiltered)
- pipeline_id validated against /^\d+$/ before binding; limit capped at 500
- app/admin/workflow/executions/page.tsx: Switch 'Show only fallbacks', pipeline Select filter, per-row fallback badge, links to pipeline detail page
- Locked URL /admin/workflow/executions honored — fresh page over pipeline-engine tables only
2026-05-10 07:43:05 -04:00