Commit Graph
59 Commits
Author SHA1 Message Date
ogrechkoandClaude Sonnet 5 b92d9801ff Surface channel delivery failures on agent replies instead of swallowing them
The previous fix made a broken SMTP/Telegram account non-fatal — the
reply gets recorded either way now — but that also made delivery
failures completely invisible: an agent sees their reply "sent"
successfully with no indication the customer never actually received it.
Added messages.deliveryFailed, set whenever the outbound send throws, and
a warning badge on the message bubble so this is now visible instead of
silent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-10-07 08:26:54 +00:00
ogrechkoandClaude Sonnet 5 8d1f91187a Fix dark-mode email readability, silent reply failures, and add an IMAP connection watchdog
Dark theme: inbound email HTML intentionally keeps its own inline colors
for fidelity (sanitize-html.ts), but a sender's hardcoded dark text (the
common case) was unreadable once the surrounding chrome went dark. Now
forced to the theme's own text color in dark mode only; light mode keeps
full fidelity as before.

Send button: deliverAgentMessage awaited the outbound email/Telegram send
*before* recording the reply, so a broken mail/bot account (e.g. bad SMTP
creds) made the reply vanish entirely — not sent, not saved, no error
shown — with the API's catch-all masking the real cause as a generic
"Ticket not found". Channel delivery is now best-effort: the reply is
always recorded even if outbound delivery fails, and real errors surface
to the compose box instead of failing silently.

IMAP watchdog: a live IDLE connection has gone quietly unresponsive in
production more than once, with mail landing server-side but never
noticed here and no error ever firing. Added a periodic forced NOOP
round-trip with its own deadline, independent of ImapFlow's internal
socket timeout, that tears down and reconnects a connection that stops
answering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-10-07 07:24:14 +00:00
ogrechkoandClaude Sonnet 5 93e17c37c7 Fix LDAP directory import: paginate past server size limits, stop leaking bind password into error messages
The "import accounts" browse was shelling out a single unbounded
ldapsearch call, which failed outright with "Size limit exceeded (4)"
once the directory grew past whatever sizeLimit the LDAP server enforces
for this bind account. Added RFC 2696 paged results (-E pr=500/noprompt)
so ldapsearch transparently walks the whole base DN in pages instead of
one request that trips the limit.

Separately: execFile's rejection .message is "Command failed: <full
argv>\n<stderr>", and the full argv includes the bind password passed via
-w — that was reaching the admin UI verbatim in the error banner shown
after a failed search. Only stderr (ldapsearch's own diagnostic text,
which never echoes its invocation) is now surfaced.

Also fixes an unrelated image-build flake: drizzle-kit's migrate step
leaves db.sqlite in SQLite's default (non-WAL) journal mode, and next
build's parallel page-data-collection workers then race to perform the
first-ever journal_mode=WAL switch — a mode change that, unlike ordinary
statements, doesn't reliably honor busy_timeout, so one worker's pragma
call throws SQLITE_BUSY regardless of the configured timeout. Switching
to WAL once, single-process, right after migration avoids the race
entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-10-07 07:14:07 +00:00
ogrechkoandClaude Sonnet 5 d54107d722 Pin an explicit Docker network subnet
Docker's auto-assigned default bridge range can land on the same
block as a real LAN/VLAN on the host it's deployed to, breaking
routing to that network. Same fix already applied to zportal
(10.199.0.0/24) and kkpab (10.200.0.0/24); this one takes
10.201.0.0/24.

Not applied to the currently running deployment on this host (no
`docker compose up -d` run here) — takes effect on the next redeploy
or on a fresh clone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-09 06:42:45 +00:00
ogrechkoandClaude Sonnet 5 ec3ab6a25f Pin explicit Compose project name
Without this, Compose derives the project name from the directory
basename — fine today, but a silent trap if this repo is ever checked
out under a differently-named sibling path/host and someone reuses
"top-tickets" elsewhere. Value matches the current derived name
exactly, so this is a no-op for the running deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-09 06:20:52 +00:00
ogrechkoandClaude Sonnet 5 e42fd4697f Add mobile bottom nav + PWA installability
Same shell pattern as top-buh/top-finance/thbd-portal: desktop top nav
stays >=lg, a fixed bottom tab bar takes over below it. Only 2 nav
links here so both fit directly, no overflow menu needed.

Also adds manifest.webmanifest + 192/512 icons (rendered from the
existing icon.svg) and appleWebApp/viewport metadata so the app can be
installed to a phone's home screen.

Nudged ticket-toasts.tsx's fixed bottom offset so it clears the new
bottom nav bar on mobile instead of overlapping it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-07 07:58:52 +00:00
ogrechkoandClaude Sonnet 5 f5e49b5c53 Let users see and unsubscribe their own push devices
Fixes duplicate/triple OS notifications on machines where more than
one browser (e.g. both Chrome and Edge on Windows) independently got
notification permission for the site — each held its own valid
subscription, so every event pushed to both.

Adds a userAgent column (captured client-side at subscribe time,
display-only) and a Settings > Account section listing a user's own
subscribed browsers with a friendly label plus an unsubscribe button,
backed by new GET/DELETE /api/push/subscriptions endpoints scoped to
the caller's own rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-07 06:49:34 +00:00
ogrechkoandClaude Sonnet 5 4d393aec91 Add bulk status change for tickets on the board
Select multiple tickets (any column) and open/pend/close them in one
action via a floating selection toolbar, backed by a new
PATCH /api/tickets/bulk-status endpoint. Scoped by the same
per-ticket visibility rule agents already get everywhere else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-07 05:42:10 +00:00
ogrechkoandClaude Sonnet 5 99927aea0b Stop merging unrelated emails into a customer's other open ticket
Inbound email with no In-Reply-To/References match against a tracked
message now always opens a new ticket, instead of falling back to the
customer's most recent open ticket. That fallback made sense for
Telegram/website chat (no subject, single running stream) but was
wrong for email, where an unrelated new request from a repeat
customer got silently appended to whatever ticket happened to still
be open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-07 05:22:29 +00:00
ogrechko d96e354e63 Prevent horizontal overflow on mobile (overflow-x: hidden on html/body) 2026-08-27 18:59:52 +00:00
ogrechkoandClaude Sonnet 5 d9eaed41e0 Dashboard: show ticket assignee on cards, widen layout to use the screen better
TicketDTO now carries assigneeName alongside the existing assigneeId — the
board's TicketCard shows it as a small avatar+name line ("Не назначено"
when nobody's assigned), so it's actually visible which admin/agent owns a
ticket without opening it. listTickets/listTicketsForCustomer resolve it
via a LEFT JOIN on users (avoids N+1 across a whole board); the handful of
single-ticket call sites (setTicketStatus, assignTicket, setTicketTags,
appendMessage, getTicketWithMessages) use a small getAssigneeName() lookup.

Also widened the admin layout's max-width from 7xl (1280px) to 1800px —
the 4-column kanban board was leaving a lot of unused space on wide
screens, cards were narrower than they needed to be. Verified visually via
Playwright screenshots at both 1920px and 1440px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-21 06:01:52 +00:00
ogrechkoandClaude Sonnet 5 1a31f72dd8 Extend TOTP 2FA to LDAP accounts; disable now requires a code, not a password
2FA was previously local-accounts-only, gated on the assumption that LDAP
already has its own MFA story — that's not actually guaranteed (depends on
what's behind the directory), so app-level TOTP is now available for LDAP
accounts too, as a second factor independent of whatever the directory does
or doesn't enforce. The login route's LDAP branch now checks
user.totpEnabled the same way the local branch already did, routing through
the same login-challenge flow before minting a session.

This forced a change to disabling 2FA: it used to require the current
password, but an LDAP-provisioned user has passwordHash: null — there's no
password to check. Disable now requires a live TOTP code or a recovery
code instead (same "prove you still hold the factor" idea, just checking
the right thing), which works identically for local and LDAP accounts.

Verified: re-ran the full local-account Playwright flow with the new
code-based disable (still passes end-to-end). For the LDAP path — no real
LDAP server available to log in through — flipped a throwaway test account
to authSource="ldap"/passwordHash=null directly in the DB and exercised the
setup/confirm/disable logic functions directly (same technique used
earlier in this session for testing mail ingestion without a live IMAP
server): setup no longer rejects it, confirm and disable both work correctly
with no password present. Test account fully deleted after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-20 08:53:51 +00:00
ogrechkoandClaude Sonnet 5 4ecb0e7698 Add opt-in TOTP 2FA for local accounts
New card in Настройки → Аккаунт: scan a QR code (otplib + qrcode), confirm
with a live code, get 8 one-time recovery codes shown once. Only offered
for authSource="local" — LDAP accounts already have their own MFA story at
the directory level and are turned away with a clear message if they somehow
hit the setup endpoint directly.

Login flow: a 2FA account's password check now creates a short-lived
"login challenge" (separate table from `sessions`, 5-minute TTL, capped at
6 verify attempts) instead of a real session, and the login page swaps to a
second screen asking for a code — TOTP or a recovery code, either works.
The LDAP branch of the login route is untouched; it returns before ever
reaching the 2FA check.

totpSecret is encrypted at rest via the existing lib/crypto/credentials.ts
helper (same one already used for mailbox/LDAP bind passwords) rather than
adding a second encryption scheme. Recovery codes are hashed, not stored
plaintext, and each is single-use (marked usedAt, not deleted).

Verified against the real deployment end-to-end with Playwright against a
throwaway test account (created via the real admin-users API, fully
deleted after): setup → confirm → recovery-code issuance → second login
correctly prompted for a code → wrong code rejected → correct code and a
recovery code both worked → a reused recovery code was correctly rejected
→ disable (password-gated) → subsequent login went straight through again.
Also added unit tests for the TOTP/recovery-code helpers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-20 08:25:15 +00:00
ogrechkoandClaude Sonnet 5 95522fdedd Fix data-URI screenshots not opening on click (Chrome blocks data: URL navigation)
The click-to-zoom feature from the previous commit worked for cid:-referenced
images but silently failed for images embedded directly as a data: URI —
confirmed with a real Playwright click: Chrome refuses to navigate a tab
(even a new one, even from a direct user click) to a data: URL, so
<a href="data:...' target="_blank"> just does nothing. The cursor still
showed zoom-in on hover since that's plain CSS, which is exactly the "лупа
появляется, но не нажимается" symptom reported.

Fix: imap.ts now extracts every data:image src out of an inbound email's
HTML into a real attachment file (deduping identical images embedded more
than once), the same way cid: images already were, and rewrites the HTML to
point at that attachment's normal /api/attachments/... URL instead. That
also shrinks messages.body_html (data URIs can be hundreds of KB sitting in
a DB column) and gets data-URI images the same "no duplicate chip below"
treatment cid: images already had.

attachments.isInline is now its own real column (backfilled from the
existing content_id-based cases) instead of being derived from content_id,
since a data-URI-derived attachment is inline but was never cid-referenced.
sanitizeEmailHtml's link-wrapping step now skips any residual data: src
defensively (unwrapped-but-visible beats a link that looks clickable but
isn't).

Verified against the real deployment with actual browser clicks (Playwright):
both a data-URI image and a cid: image now open their full-resolution
attachment in a new tab; before this fix the data-URI one silently did
nothing. Added a vitest.config.ts (needed for the new test file's @/ import
aliases) and unit tests for the extraction/dedup logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 09:07:40 +00:00
ogrechkoandClaude Sonnet 5 1a09903326 Make inline email images clickable — open full resolution in a new tab
A bare <img> in a rendered email body wasn't wrapped in any link, so the
only way to see it full-size was the browser's right-click "open image in
new tab". sanitizeEmailHtml now wraps every image that isn't already inside
a real link (a sender-linked banner is left alone, no double-wrapping) in
<a href={its own src} target="_blank" rel="noopener noreferrer"> — a normal
left click, middle click, or ctrl-click all now do the expected thing.

The wrapping step (lib/mail/sanitize-html.ts, using the new cheerio dep)
builds the <a> via .attr() rather than string-interpolating the src into an
HTML template — sanitize-html only validates an img src's URL *scheme*, not
that a data: URI's declared content is actually image data, so a crafted
src could otherwise contain characters that break out of an attribute if
naively concatenated into HTML text for re-parsing. Covered by 3 new tests
in sanitize-html.test.ts, including that exact injection attempt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 08:43:19 +00:00
ogrechkoandClaude Sonnet 5 cabcc0b1d4 Fix inline email images showing twice (once in the body, once as a chip)
An inline image referenced via cid: is already rendered inside message.bodyHtml
(added in the previous commit) but was also still stored as a regular
attachment row, so it showed up a second time as an AttachmentChip thumbnail
underneath the message — the images at top were correct, the small chips
below were a duplicate of the same image.

AttachmentDTO now carries isInline (true when the attachment's contentId is
set), and the message thread — both the admin ticket view and the customer
portal — filters those out of the separate attachment-chip list. Verified
against a live inbound message with a cid-referenced image: previously
rendered as both an <img> and an <a>-wrapped chip, now only the <img>.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 08:28:06 +00:00
ogrechkoandClaude Sonnet 5 b11ec711c5 Dashboard tag filter now covers every tag, not just ones already in use; add a default tag
The dashboard's tag filter chips were derived from tags already attached to
a currently-loaded ticket — a brand-new tag (e.g. VIP was the only one ever
used) had nothing to filter by until someone happened to apply it. The
filter bar now lists every tag defined in Settings, fetched server-side
alongside the ticket list.

Also added a "default tag" concept: mark one tag as default in Settings
(star icon), and it's auto-attached to every new ticket the moment it's
created, across every channel (email/telegram/widget/manual) — matches the
request to have "В порядке очереди" applied by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 08:09:14 +00:00
ogrechkoandClaude Sonnet 5 5af360fd15 Support attaching a file / pasting a screenshot when creating a new ticket
The "New ticket" modal only took a plain-text message. It now matches the
existing reply box on an open ticket: a "Прикрепить файл" button, and Ctrl+V
paste of a clipboard screenshot attaches it directly. POST /api/tickets
switched from JSON to multipart/form-data to carry the optional file,
reusing the same saveAttachment/attachment-delivery path replies already use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 07:54:23 +00:00
ogrechkoandClaude Sonnet 5 955fdeee41 Preserve email formatting in ticket display; paste-to-attach screenshots in replies
Inbound HTML emails were being flattened through html-to-text for display,
which lost all formatting and — for images embedded as data: URIs — leaked
the raw base64 as visible "[data:image/png;base64,...]" link text (an
Outlook/webmail screenshot-paste artifact). Customer messages now also
store a sanitized HTML rendering (messages.body_html) that preserves the
sender's fonts/colors/layout and shows inline images (data: URIs render
natively; cid: references are rewritten to the matching attachment's
serving URL via a new attachments.content_id column). Sanitization is a
tag/style allowlist (lib/mail/sanitize-html.ts, covered by
sanitize-html.test.ts) — no script/event handlers/javascript: hrefs, no
layout-breaking CSS. The plain-text fallback (used for channels other than
email) also drops image/data-URI link text via html-to-text selectors, for
the same underlying bug on that path.

Also: pasting a screenshot (Ctrl+V) into the agent reply box now attaches
it directly, reusing the existing attachment-upload/email-delivery path —
no more "save to disk, then click attach" round trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 07:40:19 +00:00
ogrechko 019f4a1b53 Merge Gitea-restored history (identical tree) with local notification fix
# Conflicts:
#	src/app/(admin)/desktop-notifications.tsx
#	src/app/(admin)/ticket-toasts.tsx
#	src/lib/events/bus.ts
#	src/lib/tickets/service.ts
2026-08-14 05:52:07 +00:00
ogrechko b2534b7ce8 Fix duplicate new-ticket notification (toast + desktop push both fired)
appendMessage() publishes ticket.created and message.created for the
same first message of a new ticket. TicketToasts and DesktopNotifications
listened to both independently, so one new customer ticket produced two
toasts/two chimes/two OS notifications. message.created now carries
ticketIsNew so both subscribers can skip it when ticket.created already
covered the same event.
2026-08-14 05:50:29 +00:00
Claude Sonnet 5 52ca317e9c Restore from Gitea ZIP snapshot (12.08.2026) after full instance reinstall
Git history was lost when the previous Gitea instance was wiped and
reinstalled due to an unresolved corruption bug — this commit is the
last known-good file content, exported before the reinstall. Prior
commit history is not recoverable through this path.
2026-08-12 19:24:03 +00:00
ogrechko 87cf045715 Fix garbled ticket text from HTML emails (Outlook VML/entities)
extractBody() used a naive tag-stripping regex as a fallback, which
strips <style> tags but leaves their CSS content behind as visible
text, and never decodes HTML entities. Outlook's VML style block
(v\:* {behavior:url(#default#VML);} ...) and &nbsp;/&laquo; entities
were leaking straight into ticket bodies as a result.

Now uses html-to-text (already a transitive dep via mailparser,
promoted to direct) to convert the HTML part whenever present, which
correctly drops <head>/<style> content and decodes entities. Also
stopped trusting parsed.text unconditionally — some senders' plain-text
alternative isn't actually clean, so HTML is now the primary source
whenever available.
2026-08-10 13:53:19 +00:00
ogrechkoandClaude Sonnet 5 8844323cad Fix invisible bars in the 14-day tickets chart
The row container used items-end without an explicit height, so each
day column had an auto (indefinite) cross-axis size. Percentage
heights don't resolve against an indefinite-height ancestor, so every
bar collapsed to zero height regardless of its data — only the
hover-tooltip count was visible, not the bar itself. Removing
items-end lets columns stretch to the parent's fixed h-32, giving the
inner bar's height percentage something concrete to resolve against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-08 13:07:09 +00:00
ogrechkoandClaude Sonnet 5 bae653b7a9 Split agent workload chart on /stats into one chart per status
Previously showed a single combined bar chart across all ticket
statuses; now renders a separate sorted bar chart per status
(new/open/pending/closed), each scaled to its own max.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-08 12:27:54 +00:00
ogrechkoandClaude Sonnet 5 fcf8511ac7 Count agent workload stats across all statuses, sorted by count
The "agent workload" chart on the stats page only counted non-closed
tickets. Now it counts every ticket regardless of status and sorts
agents descending by count, so the busiest agent (by total ticket
volume, not just currently-open) shows first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-08 12:16:58 +00:00
ogrechkoandClaude Sonnet 5 3416d82d4f Add console logging to the push subscription flow for remote diagnosis
ensurePushSubscribed() was deliberately silent on every path (success
and failure alike) — reasonable for a background enhancement, but it
made "did the subscription actually work" impossible to tell apart from
"quietly no-op'd" when diagnosing this over chat instead of in person.
Every step now logs to the console: SW registration, new vs reused
subscription, and the save-to-server result, with console.error/warn on
the specific failure points (missing permission, VAPID not configured
server-side, save request failing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-08 00:25:12 +00:00
ogrechkoandClaude Sonnet 5 1dcf5a15a5 Fix light theme reverting to dark on full page reload
Reproduced with a headless browser: the <head> no-flash script correctly
sets data-theme from localStorage before first paint (confirmed present
immediately at navigation commit), but it was gone by ~50ms later and
never came back — something in this app's streaming SSR/hydration does
a second commit shortly after first paint that strips an attribute React
doesn't know about, and suppressHydrationWarning on <html> only silences
the mismatch warning for the *first* hydration diff, not a later one.
Client-side navigation was unaffected (the attribute survives once
already set post-mount), only a fresh full load hit this.

Fix: ThemeToggle now re-applies the stored theme from localStorage in a
useLayoutEffect on mount, which runs after the attribute-stripping
commit has already happened and reliably makes it stick. Verified with
the same repro: data-theme now stays "light" at commit, +50ms, +550ms,
and +2550ms after reload, both directly and through the original
toggle -> navigate -> reload sequence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-07 23:34:01 +00:00
ogrechkoandClaude Sonnet 5 71c0689af9 Fix server crashes from unhandled push-notification errors
Root cause of the regressions reported after the Web Push feature
landed (chat messages not appearing until refresh, notifications
stopping entirely — even in-page toast/sound, which has nothing to do
with push): the fire-and-forget push call in appendMessage() had no
.catch(). This app has no global unhandledRejection handler, and Node
terminates the whole process on an unhandled rejection by default — so
any error inside notifyPushForCustomerMessage() (e.g. the unguarded
db.query.users.findMany() call, which can hit SQLITE_BUSY under
concurrent load, something this exact codebase has already hit
elsewhere) would crash the entire server on every customer message,
not just fail that one push send. Docker's restart policy masked it as
"weird flakiness" rather than an obvious crash loop.

Two fixes: the push call now has a .catch() so a failure there can
never escape as an unhandled rejection, and — as a general safety net,
since this codebase had no equivalent to project-claude's
uncaughtException/unhandledRejection handlers — instrumentation.ts now
logs-and-continues on both, so a future bug in any other fire-and-forget
path can't take the whole server down either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-07 23:26:06 +00:00
ogrechkoandClaude Sonnet 5 b9a247c21c Add Web Push notifications for backgrounded/frozen browser tabs
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
2026-08-07 19:30:27 +00:00
ogrechkoandClaude Sonnet 5 6ddbc7e981 Keep re-unlocking audio, not just once, and actually await resume()
Sound stopped again after the previous fix — likely Chrome's power-saving
auto-suspend re-suspending an already-unlocked AudioContext after a
stretch of no audio activity, which is a separate mechanism from the
autoplay-gesture policy the earlier fix addressed. The unlock listeners
were {once: true}, so they'd already fired and detached themselves,
leaving nothing to re-resume the context once the browser suspended it
again. Now the listeners stay attached for the whole session (a resume()
call on an already-running context is a harmless no-op, so this costs
nothing), and playChime() actually awaits resume() before scheduling the
oscillators instead of assuming it completes synchronously.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-07 13:54:35 +00:00
ogrechkoandClaude Sonnet 5 e8bfbe4168 Force periodic SSE reconnection to bound silent-connection blind spots
Reported: sound/toast stop firing if the page has sat idle for ~30
minutes. EventSource only auto-reconnects when the connection actually
errors — if something on the network path (a NAT/router dropping a
long-lived connection without a proper close, which the 25s server-side
heartbeat can't reveal to client code, since heartbeat comments aren't
exposed via onmessage) kills it silently, the subscription can hang
indefinitely with no visible symptom until an event is missed. Now each
SSE connection is proactively closed and reopened every 4 minutes
regardless of apparent health, capping how long that blind spot can last
independent of the exact cause.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-07 13:29:23 +00:00
ogrechkoandClaude Sonnet 5 4a0f7960ff Fix silent chime: reuse one AudioContext, unlocked on first user gesture
Toast now works (confirmed after the event-bus fix), but the sound never
played. Cause: a fresh AudioContext created outside a real user gesture
(click/keypress) starts in "suspended" state per browser autoplay
policy — an SSE message arriving is a network callback, not a gesture,
so every chime was silently creating a context that never actually
produced sound (start()/stop() don't throw on a suspended context, so
nothing surfaced this in testing without watching for it specifically).
Now a single AudioContext is created once and reused, resumed on the
page's first pointerdown/keydown — after that one real interaction it
stays running for the rest of the session, so every subsequent chime
actually plays.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-07 12:26:25 +00:00
ogrechkoandClaude Sonnet 5 4424883496 Fix realtime notifications: event bus wasn't a real singleton in production
Root cause of "no toast, no sound, no live updates" reported in
production: the ticket-event EventEmitter was only stashed on globalThis
in development (to survive Next.js's dev-mode hot reload). The comment
explaining that assumed production only loads the module once — true for
a simple Node require() cache, but Turbopack's route-level bundling can
inline this small shared module separately into each route's own output
chunk instead of pointing them at one shared instance. So /api/events
(the SSE endpoint) and whatever publishes an event (ticket creation,
mail ingestion, agent replies, ...) were each getting their own private,
disconnected EventEmitter — the SSE connection itself worked fine
(heartbeats arrived reliably, confirmed via the browser's Network tab),
but no actual ticket.created/message.created event ever reached a
subscriber. Now globalThis is always used as the singleton home,
regardless of NODE_ENV — verified locally end-to-end: streamed
/api/events with curl while creating a ticket via the API, and the
ticket.created + message.created events arrived on the stream
immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-07 12:01:56 +00:00
ogrechkoandClaude Sonnet 5 681f89000f Make internal notes admin-only, close a direct-URL ticket access gap
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
2026-08-05 18:22:46 +00:00
ogrechkoandClaude Sonnet 5 ed636bdbd5 Restrict agent ticket visibility to strictly their own assigned tickets
Previous behavior also showed unassigned tickets to every agent (so new
tickets stayed discoverable to pick up), but the actual want here is
stricter: an agent sees only what's assigned to them, period. New
tickets are now only visible to admins until explicitly assigned to an
agent — an assign-then-work model rather than self-service pickup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 18:05:01 +00:00
ogrechkoandClaude Sonnet 5 69467239ed Header redesign, sound/toast notifications, and agent scoping
Four requested improvements:

1. Logo + wordmark now link to /dashboard.

2. Header reordered: nav links (Заявки/Статистика) centered, right side
   now reads notifications -> theme -> settings -> account name -> logout,
   matching the requested order.

3. New TicketToasts component: in-page toast + a short Web Audio chime for
   new tickets and customer messages, shown regardless of tab focus.
   Complements the existing DesktopNotifications, which only fires the
   native OS notification while the tab is backgrounded. Also added a 30s
   polling refresh on the dashboard as a plain safety net under the
   existing SSE push, in case an event is dropped by whatever's in front
   of this app on the network.

4. Agents now see only tickets assigned to them plus unassigned ones (not
   the whole board) — listTickets() takes an optional visibility filter,
   applied in both the dashboard page and the /api/tickets search route,
   and mirrored client-side so a live SSE update can't leak a
   just-reassigned-away ticket onto their board. Settings gear and the
   Статистика nav link are now hidden entirely for agents rather than
   just showing fewer items inside. Also closed a real gap this surfaced:
   /stats had no server-side role check at all (only the nav link was
   hidden), so any agent could load it directly — added the same
   session.user.role !== "admin" guard the other admin-only settings
   pages already have.

Verified end-to-end with a temporary Playwright pass (installed and fully
removed afterward): logo click navigation, header icon order, agent view
missing the gear/Статистика, direct-URL access to /stats and
/settings/accounts still redirecting non-admins, and ticket visibility
correctly disappearing from an agent's board the moment it's reassigned
to someone else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 12:50:54 +00:00
ogrechkoandClaude Sonnet 5 c32888981c Fix LDAP login hang by routing it through ldapsearch too
Reported symptom: logging in with an LDAP account got stuck on
"Входим..." forever. Cause: authenticateLdapUser's user lookup used the
same ldapjs search() that was already proven unreliable against this
AD (see previous commits) — except here the failure mode wasn't an
error, it was the bind/search promise never settling at all (neither
resolve nor reject), so the surrounding try/catch's `return null` never
ran and the login button just spun. Routes the lookup through the same
ldapSearchCli used by searchLdapDirectory, and adds an explicit 15s
execFile timeout so this path — which is now login-critical — can't
hang regardless of what ldapsearch itself does. Also deletes the now
fully-unused ldapjs-based search() helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 12:00:52 +00:00
ogrechkoandClaude Sonnet 5 b8ca595d34 Shell out to ldapsearch for the directory browse instead of ldapjs
Bisecting through attribute selection and paging kept reproducing the
same failure in different shapes ("Encoding too long" BER parser error,
then ECONNRESET) — all while a plain `ldapsearch` against the exact same
query, run from inside this same container, completed successfully every
single time. That's strong enough evidence of a bug somewhere in
ldapjs/@ldapjs-asn1's BER decoding against this AD's actual response
bytes, not in our query. Rather than keep chasing a third-party parser
bug, searchLdapDirectory() now shells out to the system `ldapsearch`
(added to the image via ldap-utils) and parses its LDIF output directly
— the same tool that's already proven reliable here. authenticateLdapUser
(the per-login lookup) is untouched: it's a narrow single-match query
that has shown no sign of this issue, and isn't worth the added latency
of spawning a process on every login.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 11:41:26 +00:00
ogrechkoandClaude Sonnet 5 cb20d9eea5 Drop RFC 2696 paging now that the response is attribute-limited
Paging was introduced to work around what looked like a response-size
problem, but the actual cause (previous commit) was @ldapjs/asn1's BER
decoder choking on a specific oversized attribute value, not overall
response size. With attributes now limited to mail/cn/displayName, the
whole response is tiny, and paging's extra sequential round-trips on the
same connection were themselves causing an ECONNRESET partway through.
An unpaged single request+response now matches what a plain ldapsearch
against this same AD does successfully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 11:32:21 +00:00
ogrechkoandClaude Sonnet 5 1e8133ee39 Request only mail/cn/displayName from LDAP instead of every attribute
The real error surfaced by the previous commit's diagnostics: "Parser
error ... Encoding too long" from @ldapjs/asn1's BER decoder — a parser
desync (it misreads some later byte as a new field's length prefix),
most likely triggered while decoding some AD attribute value on one of
the ~1000 objects that we never actually use (e.g. nTSecurityDescriptor,
msDS-ReplAttributeMetaData, or similar oversized/binary attributes AD
attaches to many objects by default). Every call site only ever reads
mail/cn/displayName, so there was never a reason to request the full
attribute set — doing so avoids decoding whatever was tripping the bug,
regardless of exactly which attribute it was.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 11:18:57 +00:00
ogrechkoandClaude Sonnet 5 2a505da975 Surface ldapjs's real parser/error cause instead of a generic "closed"
Neither raising the client timeout nor switching to paged search fixed
the directory-wide import browse — narrowing it down with a plain
ldapsearch (same filter, run from inside this exact container, both
dn-only and full-attribute) showed the network path and response
content are both fine; a standard LDAP client handles it without issue.
That leaves ldapjs's own response handling. Its client-level `error`
event (e.g. a BER/protocol parser failure) forcibly closes the socket,
but we were swallowing that event silently — so every failure surfaced
as the same uninformative "<id> closed" ConnectionError regardless of
what actually went wrong. Now the last captured client `error` is
attached to the bind/search rejection instead, so the UI's error
message will show the real underlying cause next time this fails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 11:04:22 +00:00
ogrechkoandClaude Sonnet 5 a40723b61b Use paged (RFC 2696) LDAP search for the directory-wide import browse
A plain ldapsearch against this AD with the identical broad filter
(objectClass=person) completed cleanly — 996 entries, exit 0 — ruling
out the network/server as the cause of the "<id> closed" ConnectionError
our app was hitting. The difference is response size: ~1000 LDAP protocol
messages in one unbounded response is apparently enough to trip up
ldapjs's client-side handling, even though our own busy_timeout increase
(previous commit) didn't help since the connection was being closed, not
timed out. Paging the search (200 entries per round-trip, handled
internally by ldapjs — callers still just see one continuous stream of
searchEntry events) sidesteps the issue regardless of its exact root
cause. Applies to both the admin "import accounts" browse and the
per-login user lookup, since they share the same search() helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 10:45:52 +00:00
ogrechkoandClaude Sonnet 5 12e547d71e Migrate before build, not just before start, to fix build-time SQLITE_BUSY
next build's page-data collection runs several parallel workers that each
import the db client at module-eval time. When data/db.sqlite doesn't
exist yet, every worker raced to create it from scratch — a race at the
file-creation level itself, before our code ever gets to run a
busy_timeout pragma, so pragma ordering alone (previous commit) wasn't
enough to fix it. Running db:migrate as its own isolated, single-process
build step first means the workers only ever see an already-existing,
already-stable file. The runtime volume-mounted DB is unaffected — it's
still migrated again (as a no-op once applied) by the existing CMD.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 10:26:34 +00:00
ogrechkoandClaude Sonnet 5 f9128499df Raise LDAP client timeout for the directory-wide import browse
ldapjs's `timeout` option is a single flat deadline for the whole
operation (set once when the request is sent, not reset per entry
received) — 5s is fine for a bind or a single-match search during login,
but the admin "import accounts" browse runs an unbounded
(objectClass=person) search across the entire base DN, which can take
longer than 5s against a real AD domain and was getting killed mid-stream
(surfaced as ldapjs's generic "<id> closed" ConnectionError). Bumped that
one call site to 30s; login-path calls keep the tighter 5s deadline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 10:19:39 +00:00
ogrechkoandClaude Sonnet 5 14fe8c0445 Fix silent login failure on infra without HTTPS yet, and build-time SQLITE_BUSY
Session cookie always set Secure (tied to NODE_ENV=production, hardcoded in
the Dockerfile), which the browser silently drops over plain HTTP — looked
like login accepted the password but bounced straight back to /login. Secure
now only drops when COOKIE_ALLOW_INSECURE=true, for temporary use before the
HTTPS reverse proxy is wired up.

Also fixed pragma order in db/client.ts: busy_timeout must be set before
journal_mode, since switching to WAL itself takes a momentary exclusive lock
that isn't covered by a busy_timeout set afterward — caused an intermittent
SQLITE_BUSY during `next build`'s parallel page-data collection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 07:47:22 +00:00
ogrechkoandClaude Sonnet 5 fa7bda7a0f Bump build-time SQLITE_BUSY timeout from 5s to 30s
Docker build started failing intermittently again with more routes now
collecting page data concurrently — 5s wasn't always enough for workers
racing to init the fresh build-time db.sqlite. This only costs time
during that one-off build step, not runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
2026-07-31 16:44:38 +00:00
ogrechkoandClaude Sonnet 5 904f09ff09 Add LDAP login for the customer portal as a backup to the personal link
Customers now have a second way in besides their email/Telegram link:
/portal-login, authenticating against the same LDAP directory used for
staff. On success it looks up (or creates) their customer record by
email — reusing findOrCreateCustomerByEmail, the same JIT pattern
already used for the email channel — so a lost link never strands them
as long as their LDAP account still resolves to the same email.

No local password for customers (LDAP only) — the personal link stays
the primary path, this is just resilience if it's lost or Telegram gets
blocked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
2026-07-31 16:43:29 +00:00
ogrechkoandClaude Sonnet 5 cb12711e91 Add account management and LDAP login
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
2026-07-27 08:56:08 +00:00
ogrechkoandClaude Sonnet 5 8121d8aa51 Add busy_timeout pragma to avoid SQLITE_BUSY during docker build
Next's build-time page-data collection evaluates route modules across
multiple worker processes concurrently. On a fresh (empty) data volume
— as in a clean docker build — two workers racing to create/open
db.sqlite for the first time hit SQLITE_BUSY instead of just waiting the
few ms for the other's lock. Reproduced by this session's docker build
after adding the attachments route; local builds never hit it since the
dev data/ directory already existed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
2026-07-26 17:34:48 +00:00