Commit graph

383 commits

Author SHA1 Message Date
4f7e9be059 docs: capture exploration — PAX8 integration scope
Plant two seeds (PAX8 sync + Autotask mapping, general Pulse data
assistant) and one research question (PAX8 company identifier
alternatives to fuzzy name matching), surfaced while scoping a
future PAX8 integration.
2026-07-10 11:45:11 -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
078e087d5f docs(quick-260521-foj): Fix weekly-full FK error - widen Companies filter + defensive ticket company_id validation 2026-05-21 11:24:34 -04:00
62c529fb91 fix(quick-260521-foj-02): defensively nullify ticket company_id for missing companies
- Add getValidCompanyIds() helper mirroring getValidResourceIds()
- Add a new TICKETS validation block that nullifies ticket.company_id when
  the referenced company is not present (is_deleted=false) in the Pulse
  mirror, instead of letting tickets_company_id_fkey roll back the
  bulkUpsert transaction
- Block runs after the existing recordsWithoutCompany filter and before
  the existing resource-FK nullification block (correct ordering)
- Belt-and-suspenders on top of Task 1: covers hard-deleted-in-Autotask
  companies that Task 1's widening still won't fetch
2026-05-21 11:22:33 -04:00
1ecaefe85a fix(quick-260521-foj-01): widen Companies full sync to fetch all companies
- Add buildCompaniesFilter() returning id > 0 in lib/utils/sync-helpers.ts
- Route COMPANIES through buildCompaniesFilter on full sync instead of
  the generic buildActiveFilter (which applied isActive=true and missed
  inactive companies with tickets, causing tickets_company_id_fkey on
  weekly-full and full syncs since 2026-05-15)
- hasAppliedFilters stays true (filter is non-empty), so soft-delete of
  companies is not triggered
2026-05-21 11:21:48 -04:00
d02796e863 docs(quick-260521-fci): Stopgap nightly reconciliation for stale open tickets in postgres mirror 2026-05-21 11:11:33 -04:00
badd718194 feat(260521-fci-02): wire tickets-reconcile schedule + migration 090
- sync-scheduler.ts: extend sync_type union with 'tickets-reconcile', add a
  default schedule entry (disabled, 30 4 * * *), and a dispatch case using
  the device-link-reconcile / integration-health dynamic-import pattern.
- migrations/090_ticket_reconcile_schedule.sql: idempotent INSERT (ON CONFLICT
  DO NOTHING) so existing installs pick up the row without disturbing the
  fresh-DB default-seed path.
2026-05-21 11:09:36 -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
1a488e5d1d docs(quick-260519-0oz): Add QBO createPayment + createDeposit + .FH reconciliation script 2026-05-19 00:42:32 -04:00
54974584b1 fix(260519-0oz-02): defer QBO client construction in dry-run to avoid credential requirement
In dry-run mode the script makes zero QBO writes, so QboClient should not
be instantiated (which would throw if QBO_CLIENT_ID etc are unset).
Replace eager getQboClient() call with a lazy getClient() wrapper that
constructs QboClient only when the first live POST is about to be made.
2026-05-19 00:38:47 -04:00
4745de1bce feat(260519-0oz-02): add apply-fh-deposit.ts CLI script and 5/18 batch mapping JSON
- scripts/apply-fh-deposit.ts: parse .FH, validate per-check + total against mapping,
  resolve invoices + deposit account from Postgres, post Payments + Deposit to QBO
- Idempotency via sibling .applied.json written after each successful QBO post
- --dry-run flag prints all payloads and makes zero QBO writes
- Check number normalization (strip leading zeros) handles bank-vs-remittance mismatch
- dev/fin/4183_mapping_20260518.json: 11 checks, $18,962.12 total, 2026-05-18 batch
2026-05-19 00:37:31 -04:00
ef9b31e7c2 feat(260519-0oz-01): add QboPaymentCreatePayload, QboDepositCreatePayload types and createPayment/createDeposit methods to QboClient
- Add QboPaymentCreatePayload + QboDepositCreatePayload interfaces to lib/types/qbo.ts
- Add createPayment(payload) and createDeposit(payload) public methods to QboClient
- Both methods use existing private this.request<T>() with POST + minorversion=65
- Both methods throw descriptively if QBO returns no Id in response
2026-05-19 00:36:01 -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
f32403ee35 fix(theme): guard ThemeSessionBridge sync with hasSynced ref
The dep-only fix wasn't enough: better-auth's useSession (nanostores) can
re-emit on focus / store refresh, transitioning session.user.id through
undefined and back. Each transition re-fires the effect, which then calls
setTheme(session.user.theme) with the stale cached value and reverts the
user's selection.

Track "have we synced this tab session" with a ref. After the first
successful sync, no subsequent effect fire can revert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:58:35 -04:00
10c68657a7 fix(theme): ThemeSessionBridge no longer reverts user theme selections
Effect deps included `theme`, causing the bridge to re-fire on every
client-side theme change and call setTheme(session.user.theme). Because
the PUT to /api/me/theme does not refresh the better-auth session,
session.user.theme stays at the pre-change value and clobbers the new
selection — toast says "Theme updated" but UI stays on the prior theme.

Sync only on session identity change (sign-in / sign-out). Matches the
planning intent ("on session load and after sign-in").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:04:10 -04:00
7b644e475f docs(planning): sync STATE/ROADMAP + capture untracked phase plans before master merge
- STATE/ROADMAP/config updated to reflect Phase 09.1 execution
- 09-01 plan refreshed (gap-closure detail)
- 09-02..09-05 plans updated during execution
- Add untracked 09-06 plan + 01-01/01-02 PWA scaffolding plans (orphaned from earlier sessions)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:30:51 -04:00
1de6c02c3b test(09.1): persist human verification items as UAT 2026-05-11 06:50:10 -04:00
c4fb6e22a4 docs(09.1-01): complete ntfy backend fix plan — pulse-me- prefix, company server, bearer auth
- SUMMARY documents gap closure mapping each 09-HUMAN-UAT.md missing item to file/line
- All 4 backend ntfy publish sites updated; ProfileChannelsSection QR + help line updated
- tsc --noEmit + vitest notify.test.ts both pass
2026-05-11 06:44:51 -04:00
985728abb6 fix(09.1-01): ProfileChannelsSection QR + subscribe link target company ntfy server
- Add NTFY_BASE const (NEXT_PUBLIC_NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud') at module scope
- Subscribe link href and rendered text use NTFY_BASE instead of ntfy.sh
- QRCodeSVG value uses NTFY_BASE — ntfy.sh no longer referenced in file
- Add muted help line 'Topic must start with pulse-me-' between custom-topic Input and error paragraph
2026-05-11 06:43:42 -04:00
2ff2dc9904 fix(09.1-01): pulse-me- prefix, company ntfy server + bearer auth for personal channels
- NTFY_TOPIC_RE tightened to ^pulse-me-[A-Za-z0-9-]{6,64}$ (rejects noc-*, soc-*, bare pulse-)
- mintNtfyTopic() now returns pulse-me-XXXXXXXX (8 hex chars, same entropy)
- sendChannelTest (ntfy): forced to NTFY_BASE_URL + NTFY_PULSE_TOKEN; drops channel.config.auth_token path
- sendNtfy (notify.ts): personal/global branch on owner_user_id; personal -> company server + bearer NTFY_PULSE_TOKEN
- approval.ts ntfy branch: same personal/global split (soft fallback when token missing)
- ticket-digest-service.ts: deliver() + getAvailableChannels() SELECTs now include owner_user_id; ntfy branch applies same split
2026-05-11 06:42:52 -04:00
1ab3bfe2b3 docs(phase-09.1): create gap-closure plan for ntfy backend fix 2026-05-10 23:21:14 -04:00
26b51ae2a2 test(09): complete UAT - 8 passed, 1 issue diagnosed (ntfy backend gap) 2026-05-10 23:15:41 -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
2a3e89c753 fix(09): ThemeSessionBridge ignores server 'system' to preserve existing client theme
Migration 084 backfilled theme='system' for all existing users. The bridge
was then calling setTheme('system') on every signed-in client, clobbering
existing localStorage values (e.g. user previously chose 'dark', got reset
to OS preference). Treat server 'system' as 'no explicit opinion' — only
enforce explicit 'light'/'dark' from the server.

Tradeoff: explicit 'system' selection on one device won't propagate to a
device that has 'light'/'dark' cached. Acceptable — users can re-select.
2026-05-10 21:54:04 -04:00
58816bbdfe docs(phase-09): complete phase execution + evolve PROJECT.md 2026-05-10 21:52:09 -04:00
26e581d350 test(09): persist human verification items as UAT 2026-05-10 07:58:24 -04:00
6010d0b012 docs(09-05): complete channels UI + theme bridge plan — ProfileChannelsSection, ThemeSessionBridge, ThemeToggle write-through 2026-05-10 07:50:18 -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
7238c97a98 docs(09-06): complete admin-surfaces plan — channels owner column, event-keys CRUD, pipeline-executions ROUTE-07 2026-05-10 07:44:19 -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
5a9ec0f5df docs(09-04): complete mobile profile UI plan
- 6 new files: page shell, 3 section cards, skeleton, channels placeholder
- MoreDrawer Account section routes to /mobile/profile
- TSC: exit 0
2026-05-10 07:42:19 -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
577e236eb5 feat(09-04): ProfileThemeSection + ProfileNotificationMatrix
- ProfileThemeSection: 3-option radio rows (Light/Dark/System), immediate setTheme + PUT /api/me/theme, rollback on error
- ProfileNotificationMatrix: skeleton loading, empty state, single/multi-column switch grid, 400ms debounced PUT per cell
2026-05-10 07:40:53 -04:00
5aef2559dd feat(09-04): ProfileTimezoneSection (Combobox + live current time)
- Combobox from Popover + Command with Intl.supportedValuesOf + EXTRA_ALLOWED_TIMEZONES
- debounced 400ms PUT /api/me/timezone on selection
- live clock via setInterval 60s re-tick
- toast.success/error; inline destructive error on save failure
2026-05-10 07:40:48 -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
1bce661648 chore: merge 09-02 worktree commits (Wave 2) 2026-05-10 07:36:15 -04:00
17a189e564 docs(09-02): complete per-user API surface plan
- Theme GET/PUT, channels CRUD + test, notification-subscriptions matrix
- All routes requireAuth(), session.user.id only write target
- updated_at (not updatedAt) confirmed
- tsc exits 0
2026-05-10 07:31:58 -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
486f27eb77 docs(09-03): complete notify per-user routing plan
- Type additions: RouteToUser, ResolvedRecipient, NotifyResolver,
  UserRouteFallback, UserRouteFallbackReason in lib/types/pipeline.ts
- New resolver registry: notify-resolvers.ts with 3 v1 resolvers
- executeNotify rewrite with backward-compat path + user-route decision tree
- Behavioral vitest guarantee: muted user does not fall back to global channel
2026-05-10 07:30:36 -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
fd19a5d997 test(09-03): add vitest unit test for muted user route behavior
- Asserts muted user (enabled=false in user_event_subscriptions) returns
  success:true, notified:false, skipped_reason='user_muted'
- Asserts no notification_channels SQL is issued on the mute path
- Asserts no outbound fetch() is called (no personal or global send)
- Asserts user_route_fallback is absent from output (mute != fallback)
- Uses _INTERNALS test seam to call dispatchUserRoute directly
- Behavioral guarantee for D-12/ROUTE-05 enforced by CI, not just grep
2026-05-10 07:29:20 -04:00
86acc06b16 feat(09-03): rewrite executeNotify with route_to_user branch and fallback semantics
- Extract dispatchToGlobalChannel helper (backward-compat path unchanged)
- Add dispatchUserRoute: field lookup, resolver dispatch, email→user_id
  resolution, mute check, personal channel lookup, send with fallback
- Add fallbackToGlobal: annotates output with user_route_fallback reason
- Mute path (enabled=false) returns success:true/notified:false, no fallback
- Default channel-type order when omitted: ntfy then teams (ROUTE-06)
- Export _INTERNALS test seam following link-discovery.ts precedent
- All five fallback reasons: no_channel, send_failed, user_not_found,
  no_field_value, resolver_unknown (ROUTE-03..06)
2026-05-10 07:28:21 -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
d27462f713 feat(09-03): add RouteToUser types and resolver registry
- Add RouteToUser, ResolvedRecipient, NotifyResolver, UserRouteFallback,
  UserRouteFallbackReason types to lib/types/pipeline.ts (ROUTE-01)
- Create lib/services/pipeline-steps/notify-resolvers.ts with three v1
  resolvers: direct_email, pulse_user_id, autotask_resource_email (ROUTE-02)
- Resolver registry as Map<string, NotifyResolver> with registerResolver()
  and resolveRecipient() dispatcher with try/catch error handling
2026-05-10 07:27:00 -04:00
485053c639 docs(09-01): complete schema and types foundation plan
- SUMMARY.md for plan 09-01: theme column, personal channels, event-key and subscription tables
2026-05-10 07:24:23 -04:00
b4f8ccac8b feat(09-01): add owner_user_id to notification_channels + event-key and subscription tables
- migrations/085: ALTER TABLE notification_channels ADD COLUMN owner_user_id TEXT REFERENCES user(id) ON DELETE CASCADE
- migrations/085: partial unique index notification_channels_owner_user_id_channel_type_uniq WHERE owner_user_id IS NOT NULL (UPSERT race defense)
- migrations/086: CREATE TABLE notify_event_keys (key PK, display_label, description, sort_order, is_active) with seed row
- migrations/086: CREATE TABLE user_event_subscriptions composite PK (user_id, event_key, channel_type) opt-out model
- lib/types/pipeline.ts: NotificationChannel gains owner_user_id: string | null
- lib/types/pipeline.ts: exports NotifyEventKey and UserEventSubscription interfaces
2026-05-10 07:23:24 -04:00
4fc4a3d3b9 feat(09-01): add theme column to user table + Better Auth additionalField
- migrations/084_add_user_theme.sql: ALTER TABLE user ADD COLUMN theme TEXT NOT NULL DEFAULT 'system'
- Defensive backfill UPDATE for in-flight NULL rows on managed Postgres
- COMMENT ON COLUMN documents allowed values (light|dark|system)
- lib/auth.ts: adds theme additionalField with defaultValue 'system' after timezone
- session.user.theme now exposed via Better Auth same as session.user.timezone
2026-05-10 07:22:26 -04:00