The SSE + in-page toast/sound system only works while the tab's JS is
actually running — Chrome (and other browsers) freeze a backgrounded
tab's JS after a stretch of inactivity to save power, so notifications
silently stop regardless of how correct the SSE/toast code is. A Service
Worker is the only mechanism that keeps receiving events independent of
the tab's own lifecycle, so this adds a real Web Push pipeline:
- New `push_subscriptions` table (one row per browser/device a user has
subscribed from; endpoint is unique so re-subscribing overwrites
rather than accumulating stale rows).
- VAPID keypair config (lib/push/vapid.ts) — reads
VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT from the environment;
push is silently disabled (never throws) if they're unset, matching
this codebase's existing "an optional integration outage must never
break the core flow" pattern (see the LDAP auth comment).
- public/sw.js: a minimal service worker — `push` -> showNotification,
`notificationclick` -> focus or open the ticket.
- API routes: GET /api/push/vapid-public-key (client needs it to call
pushManager.subscribe), POST/DELETE /api/push/subscribe.
- lib/push/client.ts: registers the service worker and subscribes,
called from the notification bell after granting permission and again
on mount for returning users who already granted it.
- lib/tickets/service.ts: appendMessage() now also fires a push (fire-
and-forget, never awaited by the caller) to every admin plus the
ticket's assignee whenever a *customer* message arrives — same "who
should know" rule as the in-page toast (lib/tickets/visibility.ts).
Failed sends are inspected: a 404/410 (push service no longer
recognizes the subscription) prunes the row; anything else is just
logged, since it might be transient.
Verified server-side end-to-end on this VM: saved a subscription via the
API, created a customer ticket, and confirmed the push attempt actually
fires (web-push validated and rejected a deliberately-malformed test
key, proving the send path is wired correctly) without blocking or
crashing ticket creation. Couldn't verify the full real-browser
subscribe-and-receive path or an actual OS popup from here — this VM has
no desktop/notification service, and headless Chromium's Notification
permission can't be reliably granted in this sandbox (unrelated to the
app code); that last mile needs verifying on a real machine.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
Admins can now create local agent/admin accounts and configure LDAP
directly from the UI (Настройки → Аккаунты / LDAP), with login trying
LDAP first and falling back to the local password. This is the first
place users.role is actually enforced (requireAdminSession).
Fixed a bug in the generated 0005 migration: the INSERT into __new_users
selected auth_source from the old users table, which doesn't have that
column yet — caused drizzle-kit migrate to fail silently.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
Attachments: file storage on the same data volume, serving route with
dual auth (session or portal/widget token, internal-note attachments
never token-servable), inbound ingestion from email (mailparser) and
Telegram (photo/document handlers), outbound delivery via SMTP
attachments and Telegram sendDocument/sendPhoto, upload UI (paperclip +
pending-file chip) on all three reply surfaces. Extracted
lib/tickets/delivery.ts so the text-only and attachment-upload admin
routes share one channel-delivery code path instead of duplicating it.
Canned responses: CRUD + a popover picker in the reply box that inserts
a saved template into the draft.
Tags: fixed 6-color palette reusing existing soft-badge tokens, a picker
on the ticket page, colored chips + a filter row on the dashboard.
Search: LIKE-based (not FTS5 — simpler and sufficient at this volume)
across ticket subject, customer name, and message bodies; a debounced
search box on the dashboard.
Desktop notifications: permission toggle + a background subscriber that
fires for new tickets and customer messages while the tab is hidden,
clicking one navigates to the ticket.
Password change: new account settings page, verifies the current
password before updating.
Nav: gear-menu settings list now includes Шаблоны ответов/Теги/Аккаунт.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
Design: Onest+Unbounded font pairing (Cyrillic-verified, replacing plain
Golos Text), manual light/dark toggle wired to the data-theme cascade
already built in MVP-1, refreshed palette + new chart-mark tokens
(CVD-validated via the dataviz skill's validator), colored avatar
initials, per-channel colored badges, favicon, nicer empty states.
Stats: /stats page — status/channel breakdown, 14-day ticket volume,
avg first-response time, per-agent open-ticket workload. Colors and
chart forms follow the dataviz skill's procedure (categorical order
re-stepped to clear the CVD adjacency check in both themes).
Internal notes: messages.visibility ("public"/"internal") column.
Agent-only notes never reach customer-facing reads (getTicketForCustomer)
or the customer SSE stream (/api/portal/events, reused by the widget) —
fixed at both the initial-fetch and live-delivery layers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
Reuses the existing portal-token/SSE mechanism (customers.portalToken,
/api/portal/events) for anonymous widget visitors instead of building a
parallel auth system. Public API: session bootstrap + messages endpoint,
both scoped by a non-secret siteKey with optional origin allowlist.
Embeddable script (public/widget.js) is a small self-contained vanilla JS
file: floating button + iframe kept mounted for a live SSE connection,
with a postMessage bridge for the unread badge. Admin UI at
Settings -> Виджет manages sites and shows the embed snippet.
Also generalized recordTelegramInboundMessage -> recordChatInboundMessage
(channel param) since Telegram and the widget need the identical
find-or-create-open-ticket heuristic.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
Inbound mail on support@top-sysops.ru creates/updates tickets in realtime
(IMAP IDLE, not polling). Replies thread via In-Reply-To/References against
stored Message-IDs, falling back to the customer's open ticket. Mailbox
credentials configured and encrypted via Settings -> Почта, same pattern as
the Telegram channel. TLS verification is opt-in-skippable per mailbox
(mail.top-sysops.ru's cert is currently expired; LAN-only server).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH