- 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
- 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).
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>
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)
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.
- 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)
- 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()
- 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
- 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)
- 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)
- 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)
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.
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.
- 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
- 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
- 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)
- 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.
- 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.
- 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.
- /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.
- 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.
- 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)
- 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
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>
- 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
- 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
- Replaces 'coming soon' placeholder with full read-only feed
- useState/useEffect/fetch only (no SWR/react-query per CLAUDE.md D-38)
- IntersectionObserver sentinel with rootMargin 200px for auto-load
- Load more fallback button with aria-label, min-h-[44px] touch target
- 5 AnalyzerRowSkeleton instances on initial load (D-28)
- Empty state with dashed border, Sparkles icon, Open desktop Analyzer link
- toast.error on load failures; Load more flips to Retry on error
- No edit/re-run/prompt-tuning controls (ANL-05)
- Create cursor-paginated analyzer feed endpoint for mobile
- Export AnalyzerFeedRow and AnalyzerFeedResponse types (D-26)
- Implement DISTINCT ON CTE for latest-per-ticket analysis (D-02)
- Apply kiosk_settings company scoping via getMobileCompanyFilter() (D-04)
- Cursor keyset pagination on (completed_at, id) with base64 JSON encoding (D-06)
- Server-side limit cap at 25 (D-05); LIMIT n+1 trick for hasMore detection
- Ordering: completed_at DESC NULLS LAST, id DESC (D-03)
- Manual snake_case to camelCase transform per CLAUDE.md conventions
- Payload whitelist: only 12 AnalyzerFeedRow fields; no model_traces, itglue_docs_referenced, or human_review_reasons (T-06-05)
- requireAuth() gate before any DB query (T-06-01)
- Replace AR Hero gradient + standalone Revenue YTD with 4 KpiCardMobile tiles (2x2 grid)
- Drop bar chart; add monthly-revenue stacked list (D-09 / DASH-04 precedent)
- Add shadcn Collapsible for Open Invoices and Recent Payments (D-15, D-16)
- Aging row uses locked amber/orange/destructive palette (D-08)
- Top AR by Customer rendered as stacked list with proportion bars (D-07)
- toast.success + toast.error on sync outcomes (D-17, D-18)
- FinanceSkeleton on initial load; destructive retry card on error (D-17, D-18)
- D-19 empty state when total_ar===0 and open_invoices.length===0
- D-23: no page H1; header controls row with aria-labels (D-14)
- No font-medium, no font-bold, no raw red/yellow/green Tailwind classes
Cherry-picked from 04-02 worktree (b27db7d, 4ba89a0). The full worktree merge
included unrelated regressions of phase 2/3 work — only the intended changes
(app/mobile/tickets/page.tsx + SUMMARY.md) were kept.
- Suspense wrapper for useSearchParams (Next.js 16 requirement)
- URL-synced filter state via router.replace
- TicketFilterStrip wired with controlled props
- Priority-stripe rows (border-l-4 with red/orange/amber/slate)
- IntersectionObserver infinite scroll + Load more fallback
- toast.error() in load catch blocks per D-21
- Empty states per D-20
The soft reset to 77073ba inadvertently staged deletions of all phase 2
and 3 artifacts. This commit restores them from their source commits so
subsequent task commits build on the complete prior-phase foundation:
- components/mobile/{BottomNav,HeaderBar,KpiCardMobile,MoreDrawer,NeedsAttentionStrip,WorkerStatusRow}
- app/mobile/layout.tsx, dashboard/page.tsx, analyzer/page.tsx
- app/api/mobile/dashboard/route.ts
- All .planning/** files from phases 01-04
- CLAUDE.md, app/layout.tsx, app/styles/brand.css, public/manifest.json
- Exports MobileDashboardResponse, KpiResponse, AttentionResponse, WorkerResponse interfaces
- Single Promise.all with 6 parameterless queries (KPI, failed backups, stalled workflows, analyzer, RMM, backup success)
- Ticket KPIs exclude out-of-scope companies via company_scope filter
- SLA breaches tone='attention' when value > 0
- Worker status rules: down if fail_1h>0 and in_flight=0, warn if fail_1h>0, otherwise ok
- Backup status: ok >= 95%, warn >= 80%, down otherwise