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.
- 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
- 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
- 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
- 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.
- 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).
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.
- 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
- 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
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>
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>
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>
- 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
- 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
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.
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.
- 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()
- 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
- 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)
- 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
- 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)
- 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
- 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)
- 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