Commit graph

165 commits

Author SHA1 Message Date
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
7f4ffa0fb6 feat(09-06): add /admin/workflow/event-keys CRUD page and API routes
- GET/POST /api/admin/notify-event-keys: list ordered by sort_order/key, create with key regex validation (^[a-z][a-z0-9_]*$/i), 409 on conflict
- PUT/DELETE /api/admin/notify-event-keys/[key]: update via COALESCE, hard delete with 404 guard
- app/admin/workflow/event-keys/page.tsx: list with inline edit, Switch for is_active toggle, + New event key form, sonner toasts
- All routes gated by requireAdmin()
2026-05-10 07:41:27 -04:00
da13caf9cb feat(09-04): page shell, drawer wiring, skeleton helper, channels placeholder
- app/mobile/profile/page.tsx: server-component shell gated by requireAuth() + redirect('/auth/sign-in')
- ProfileSectionSkeleton.tsx: generic 3-row pulsing skeleton Card
- ProfileChannelsSectionPlaceholder.tsx: stub Channels card (Plan 05 swaps real component)
- MoreDrawer.tsx Account section: identity row wrapped in Link, new Profile & preferences row above Sign-out
2026-05-10 07:40:40 -04:00
47cab788fc feat(09-06): owner column + role-scoped reads on notification channels
- GET /api/notification-channels: requireAuth(), admin sees all rows with owner_email JOIN, non-admin sees global-only
- GET accepts ?owner=global|personal|all filter parameter
- POST /api/notification-channels: requireAdmin(); preserves all four channel_type values (teams/telegram/ntfy/webhook); adds owner_user_id column
- [id] routes: requireAuth() + per-row authorization (isAdmin || isOwner); global rows require admin
- Admin channels page: Owner badge (Global vs Personal: email), Show filter select, disclaimer text for personal channels
2026-05-10 07:39:53 -04:00
55a80a07ad feat(09-02): GET + PUT /api/me/notification-subscriptions (matrix endpoint)
- GET returns { eventKeys, channelTypes, matrix } where matrix defaults to
  true when no row exists (D-15 opt-out model)
- PUT UPSERTs single row via composite PK ON CONFLICT
- Validates: event_key (non-empty, <=128 chars), channel_type via
  isPersonalChannelType, enabled as typeof boolean
- Write target always session.user.id (T-09-02-01, T-09-02-03)
2026-05-10 07:30:51 -04:00
c35b968522 feat(09-02): personal channels service + /api/me/channels routes
- lib/services/personal-channels.ts: isValidTeamsWebhookUrl, isValidNtfyTopic,
  mintNtfyTopic, sendChannelTest, TEST_MESSAGE_BODY, isPersonalChannelType,
  PERSONAL_CHANNEL_TYPES
- GET /api/me/channels: returns user's personal channels (owner_user_id scoped)
- PUT /api/me/channels/[type]: WITH-CTE UPSERT + best-effort test send
- DELETE /api/me/channels/[type]: removes user's channel, 404 if missing
- POST /api/me/channels/[type]/test: re-sends test to existing channel
- SSRF mitigation via Teams URL hostname allowlist (T-09-02-06)
- Race window closed by partial unique index from Plan 01 (T-09-02-10)
2026-05-10 07:30:05 -04:00
dc5dc913bd feat(09-02): GET + PUT /api/me/theme
- ALLOWED_THEMES allowlist for light/dark/system
- GET returns { theme, source: 'user'|'default' }
- PUT validates against allowlist, writes session.user.id only
- Uses updated_at (snake_case) — matches migration 012 schema
- No userId from body (T-09-02-01 mitigation)
2026-05-10 07:28:03 -04:00
435051ddc8 fix(08-02): retry scroll restoration across frames until layout finalizes
The window scrolls (not <main>) on this layout, and the document content
height isn't fully laid out by the first rAF after rows render — so
window.scrollTo gets clamped to maxScroll, leaving the user near top.
Retry up to 30 frames (~500ms) until the actual scroll position matches
the target within 4px.
2026-05-07 23:01:22 -04:00
8834db981d fix(08-02): scroll restoration — save/restore both window and <main> scrollTop, defer to rAF after rows render 2026-05-07 22:23:48 -04:00
6bdc937861 fix(08-02): preserve engagement list scroll across profile navigation (D-04)
The mobile shell's <main> is overflow-y-auto, so Next.js's built-in
scrollRestoration (window-only) doesn't restore the list's inner scroll
when returning from /mobile/engagement/[userId]. Persist the scroll
position to sessionStorage on scroll (rAF-throttled) and restore once
after the first users page loads.

Restoration is gated to the first load only, so changing period/sort
doesn't yank the viewport — and uses a single sessionStorage key, so
returning to the list later still lands where you were.
2026-05-07 22:10:43 -04:00
0be0c1f7f8 feat(08-02): activity breakdown + recent entries + meetings + page wiring (Task 2)
- New EngagementProfileBreakdown: Time/Communication/Meetings subsections, after-hours
  and Zoom conditional rows, py-2 per UI-SPEC override
- New EngagementRecentEntries: collapsible list up to 10, Billable badge, Set<string>
  expand state, empty-state copy
- New EngagementRecentMeetings: collapsible list up to 10, matched entries + attendees
  in expanded view, (no subject) fallback, Set<string> expand state
- Page updated: 3 new component imports + breakdown/entries/meetings mounted in order
- No dangerouslySetInnerHTML; D-01/D-22 guard rails untouched
2026-05-07 20:50:56 -04:00
df78ab8fa5 feat(08-02): identity header + 2x2 metric grid + page wiring (Task 1b)
- New EngagementProfileHeader: avatar (photo/initials fallback), name, jobTitle,
  department, mailto link, last-active relative/absolute label
- New EngagementProfileMetricGrid: 2x2 grid of Hours/Billable/Days/Meetings cards
- Page updated: imports Header+MetricGrid, placeholder div removed, real components mounted
- D-01/D-22 guard rails: EngagementUserRow.tsx and data endpoint untouched
2026-05-07 20:48:24 -04:00
3247c92486 feat(08-02): page shell + skeleton + period/fetch wiring (Task 1a)
- New app/mobile/engagement/[userId]/page.tsx with fetch + error states + retryNonce
- New EngagementProfileSkeleton with header/metric/breakdown/list skeletons
- 404 renders 'User not found' + back link; 500 renders sonner toast + Retry
- D-04 comment: relies on App Router default scrollRestoration
- D-01/D-22 guard rails: EngagementUserRow.tsx and data endpoint untouched
2026-05-07 20:46:28 -04:00
4978780962 feat(08-01): add /api/mobile/engagement/user/[userId]/photo proxy route
- Proxies Microsoft Graph user photo bytes to authenticated mobile clients
- requireAuth() is first call — unauthenticated requests get 401 before Graph
- 503 when MSGRAPH_* env not configured (isMsgraphConfigured gate, D-26)
- 400 for malformed userId (path traversal denylist, permissive per VARCHAR(255))
- 404 neutral response when user has no photo (no userId oracle)
- 200 with Cache-Control: private, max-age=3600 on success (D-25)
- 502 neutral response on Graph upstream errors (no token/user leakage)
2026-05-07 20:41:07 -04:00
660d039b80 fix(07.1-02): allowlist UTC, Etc/UTC, GMT in IANA validator 2026-05-07 17:21:26 -04:00
d31fd48cad fix(07.1-02): use updatedAt camelCase in user timezone UPDATE 2026-05-07 16:45:52 -04:00
91b876310e feat(07.1-05): user-tz on dashboard, quotes, veeam-analysis
- dashboard/page.tsx: thread tz into PageHeader description's
  toLocaleDateString call.
- quotes/page.tsx: thread tz into formatDate arrow helper inside the
  default export.
- veeam-analysis/page.tsx: thread tz into the summary footer's
  generated-at toLocaleString call.

Migrates 3 of 81 audit leak callsites.
2026-05-07 08:36:09 -04:00
96edfb4444 feat(07.1-05): user-tz on analyzer pages
- itglue/applications, applications/[id], configurations,
  configurations/[id], sites/[companyId], queue, ticket/[ticketNumber],
  tickets, reports, reports/[id]: useUserTimezone() in default export;
  thread tz into every inline toLocale*String call.
- analyzer/tickets/page.tsx converts module-scope formatRelative(iso)
  helper to formatRelative(iso, tz); updates 1 callsite.

Migrates 16 of 81 audit leak callsites.
2026-05-07 08:34:58 -04:00
23b179f2a7 feat(07.1-05): user-tz on admin operational pages
- zabbix-wan, rmm-overshell, itglue-writes, ticket-digest,
  device-link-conflicts, workflow/history, workflow/pipelines/[id]:
  each gets useUserTimezone() at the component entry; threads tz into
  every inline toLocaleString call.

Migrates 11 of 81 audit leak callsites.
2026-05-07 08:31:22 -04:00
8c56cafe0b feat(07.1-05): user-tz on admin sync pages
- duo, sentinelone, datto-rmm, veeam, itglue, mimecast: each gets
  useUserTimezone() in default export and threads tz through
  fmtDate/sub-component props.
- duo (1 callsite, closure inline), sentinelone (1, module-scope helper),
  datto-rmm (1 helper + StatusTab/HistoryTab props), veeam (1 helper +
  5 sub-components), itglue (1 helper + StatusTab/HistoryTab props),
  mimecast (1 helper + 6 sub-components incl. 2 dialogs with inline
  toLocaleString calls).
- Module-scope fmtDate(d) signatures converted to fmtDate(d, tz).

Migrates 13 of 81 audit leak callsites.
2026-05-07 08:28:42 -04:00
a709144685 feat(07.1-05): user-tz on engagement overview + profile pages
- app/engagement/page.tsx: useUserTimezone in EngagementPage; thread tz
  into 7 toLocale* callsites (lines 844, 1012, 1108, 1227, 1255 — last
  two have 2 calls per line for date+time).
- app/engagement/profile/page.tsx: useUserTimezone in EngagementProfilePage;
  add tz prop to ActivityHeatmap; convert module-scope monthLabel(m) to
  monthLabel(m, tz); update 2 callsites of monthLabel.

Migrates 9 of 81 audit leak callsites.
2026-05-07 08:23:24 -04:00
b417988ee6 feat(07.1-05): user-tz on admin data-browser DataTable columns
- Add useUserTimezone() to 6 admin/data-browser pages
- Thread { timeZone: tz } into 8 DataTable column render() calls
- Delete orphaned app/admin/data-browser/time-entries/page.tsx.backup
  (per audit footnote — never imported, contained 1 leak at line 166)

Files: contracts, projects, tasks, ticket-notes, tickets, time-entries

Migrates 8 of 81 audit leak callsites.
2026-05-07 08:21:18 -04:00
04d036ab78 feat(07.1-03): user-tz day buckets on /api/dashboard/trends
- volumeRes / resolutionRes generate_series and join keys converted from
  CURRENT_DATE / *_date::date = days.d to user-tz two-step idiom.
- engineersRes WHERE filter te.entry_date::date = CURRENT_DATE migrated
  to user-tz on both sides.
- queueHeatmap (open-only counts) preserved unchanged — no day-boundary
  math; comment added explaining why.
- requireAuth() session destructured; tz passed as $1 to all three
  migrated queries.
2026-05-07 08:05:32 -04:00
dc0b06b9c7 feat(07.1-03): user-tz boundaries on /api/mobile/finance + engagement; auth-gate finance
- /api/mobile/finance: add requireAuth() (aligns with all other /api/mobile/*
  handlers) + getUserTimezone(); migrate paid_mtd / paid_ytd to user-tz
  DATE_TRUNC, six aging-bucket comparisons to user-tz CURRENT_DATE, and
  days_overdue arithmetic. Preserved unchanged: 12-month rolling
  monthlyRevenue (rolling — not a calendar boundary).
- /api/mobile/engagement/summary: destructure session, resolve tz; migrate
  rolling time_entries WHERE clause to user-tz on both sides of >=. Added
  TZ-02 carve-out comment above the snapshot queries documenting why
  engagement_snapshots remain UTC-bucketed (deferred per REQUIREMENTS.md).
- /api/mobile/engagement/trend: replace every bare CURRENT_DATE with
  (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date; pass [tz] as params
  to postgresClient.query. Day buckets now align to user-tz days.
2026-05-07 08:04:47 -04:00
8a9887faa1 feat(07.1-03): user-tz day boundaries on /api/(mobile/)dashboard(/overview)
- Switch opened_today / resolved_today / yesterday / 7d-avg buckets from
  CURRENT_DATE to ((value AT TIME ZONE 'UTC') AT TIME ZONE $1)::date.
- Both routes destructure session from requireAuth() and resolve tz via
  getUserTimezone(); tz parameterized as $1 (no SQL interpolation).
- Preserved unchanged: due_date_time < NOW() (rolling SLA, tz-independent),
  the INTERVAL '24h/5min/1h' rolling-window queries (failed backups,
  stalled workflows, analyzer/RMM 1h fail counts, backup-success 24h).
- Added a code comment above the 24h failed-backups query explaining why
  it stays UTC-NOW relative.
2026-05-07 08:02:52 -04:00
14f4da3483 feat(07.1-04): migrate mobile finance + ticket detail to useUserTimezone
- app/mobile/finance/page.tsx: thread tz through fmtDate, setLastSync, monthLabel — 3 formatter callsites now pass timeZone
- app/mobile/tickets/[id]/page.tsx: thread tz through fmtDate (5 callsites) and TimelineCard prop
- All toLocaleDateString / toLocaleString calls in both files now render in user.timezone, not browser local zone
- Resolves TZ-02 on the directly-reported bug surface (mobile finance + ticket detail)
2026-05-07 07:54:21 -04:00
f50215f8fc feat(07.1-02): add GET/PUT /api/me/timezone endpoint
- New app/api/me/timezone/route.ts with GET + PUT handlers
- requireAuth() gate on both methods (401 unauthenticated)
- IANA whitelist via Intl.supportedValuesOf('timeZone') + 64-char cap
- PUT writes only session.user.id — no userId body/query param
- Updates audit column updated_at = NOW() on write
- Resolves TZ-03
2026-05-07 07:36:01 -04:00
bee35e0260 fix(auth): reduce mobile sign-in friction (PWA + auto-redirect)
Three independent changes that together stop the mobile re-auth churn:

- app/layout.tsx: add appleWebApp metadata so iOS "Add to Home Screen"
  launches Pulse in true standalone mode (own cookie jar, persists
  across Safari memory pressure)
- components/auth/sign-in-form.tsx: when /auth/sign-in mounts and
  ?callbackUrl starts with /mobile, auto-call authClient.signIn.social
  for Microsoft. With an active M365 browser session this redirect is
  silent — the user lands on /mobile/* with no tap.
- app/auth/sign-in/page.tsx: wrap SignInForm in <Suspense> (required
  by Next.js 16 because SignInForm now uses useSearchParams)

Pairs with operator-side env bump SESSION_TIMEOUT_SECONDS=2592000
(30 days, .env files are gitignored — applied on the running container
via docker compose up -d --force-recreate app).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:05:52 -04:00
5daf7f31e5 feat(07-03): create mobile engagement page (plan 01 endpoints + plan 02 components)
- New app/mobile/engagement/page.tsx ('use client', 378 lines)
- Period chips (D30 default), 3 independent fetches on mount/period change
- Sort chips (hours default), refetch users only on sort change
- Client-side search filter (useMemo, 300ms debounce via EngagementSearchInput)
- IntersectionObserver infinite scroll (rootMargin 200px) + Load more fallback
- 4 summary card skeletons + sparkline skeleton + 5 row skeletons on initial load
- Empty state (activeUsers === 0 + users.length === 0), not-configured banner, no-matches inline
- toast.error per failing fetch; Load more flips to Retry on error
- BottomNav and MoreDrawer unchanged (ENG-09 / D-01 / D-02)
2026-05-03 22:57:44 -04:00
00d0102168 chore: merge executor worktree (07-01) 2026-05-03 22:45:36 -04:00
c3d370c2f0 feat(07-01): add /api/mobile/engagement/trend endpoint
- GET handler with requireAuth() gate before any DB query (T-07-01)
- Period whitelist ['D7','D30','D90'] with 400 for invalid values (T-07-02)
- generate_series ensures continuous daily series (D-15: no gaps)
- Returns EngagementTrendResponse with D7→7, D30→30, D90→90 SparklinePoints
- Bounded result set: whitelist caps to max 90 rows (T-07-03)
- Exports SparklinePoint and EngagementTrendResponse for Plan 03 import
2026-05-03 22:44:10 -04:00
f4a9fd83db feat(07-01): add /api/mobile/engagement/summary endpoint
- GET handler with requireAuth() gate before any DB query (T-07-01)
- Period whitelist ['D7','D30','D90'] with 400 for invalid values (T-07-02)
- Returns MobileEngagementSummary: configured, activeUsers, totalGraphHours, totalAutotaskHours, hoursPerActiveUser
- Reuses notAutomatedFilter and wulfconsulting email scope from desktop summary
- Exports MobileEngagementSummary interface for Plan 03 page import
2026-05-03 22:43:36 -04:00