Internal notes ("Внутренняя заметка") were visible to any agent who
opened the ticket — the compose toggle and the SSE-delivered live
updates had no role check. Agents now never see the internal/public
toggle (they only ever reply publicly) and internal messages are
filtered out of both the initial server-rendered load and live SSE
message.created events.
Fixing that surfaced a bigger gap: the ticket detail page and every
per-ticket API route (GET/PATCH /api/tickets/[id], POST .../messages,
POST .../attachments, PUT .../tags) had no ownership check at all — an
agent could open, reply to, tag, reassign, or read the full message
history of *any* ticket by URL/API, not just their own, regardless of
the dashboard-level filtering added earlier. All five now reuse
isTicketVisibleTo() to 404/redirect for tickets an agent doesn't own.
Verified live: an agent opening a ticket assigned to them sees public
messages but not an admin's internal note or the note-vs-reply toggle;
opening a ticket assigned to someone else redirects to /dashboard on
the page and returns 404 from the API. Test accounts/data removed after.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
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
Nav overflowed the viewport on phones (confirmed via screenshot) — header
now wraps, nav row scrolls horizontally instead of pushing user/theme/
logout off-screen, ticket-thread's status/assignee selects stack full-width
below the subject on narrow screens.
Dark theme got a real pass instead of reusing near-identical values:
deeper/richer surfaces, punchier accent (#8b5cf6, matches the already-
validated dark chart-1), a subtle violet glow behind the page, and a
dark-appropriate card shadow (inset highlight + soft shadow — the flat
black shadow from light mode was invisible on a dark surface).
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