feat: support ticketing dashboard with live chat and department RBAC#150
feat: support ticketing dashboard with live chat and department RBAC#150vendz wants to merge 19 commits into
Conversation
…ket page custom.js calls .clockpicker()/.datepicker() immediately on load, which throw since this page doesn't include clockpicker.js/bootstrap-datepicker.js. Neither script is needed by the tickets page, so drop both includes.
Ticket description/service and chat messages are submitted by app users and were rendered into the admin DOM via innerHTML unescaped, allowing stored HTML/script injection to execute in an authenticated admin session. Escape these values (debug panel already used textContent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Admins can no longer set a ticket directly to closed (backend now rejects it) — only open/in-progress/resolved. 'Closed' stays in the list filter so admins can still view already-closed tickets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hdog
Two gaps: (1) when the fetch stream ended without an error (server restart,
graceful close), the read loop just exited silently — no error, no fallback,
no reconnect, leaving the drawer stuck until manually reopened. (2) there was
no way to detect a connection gone silently stale. Both fixed: a stream end
(with or without an exception) now schedules a reconnect with the polling
fallback running in the meantime, and a watchdog (paired with the backend's
new {type:'ping'} heartbeat frame) force-reconnects if no activity arrives
for 40s. Verified by killing and restarting the backend mid-conversation —
the drawer recovered and delivered a new message with no manual refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Handles the backend's new status_update SSE frame so the admin sees status changes made from the other side (e.g. the user closing their own ticket) without needing to reopen the ticket. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- tickets.html: allow housekeepingAdmin/foodAdmin/travelAdmin/officeAdmin onto the page (backend now enforces the same scoping per-request). - tickets.js: lock the service filter to the logged-in admin's own service (mirrors backend's TICKET_SERVICE_ROLE_MAP — UX only, not the actual enforcement, which lives server-side). - adminhome.html: add the Support Tickets nav link for the newly-admitted department roles. Verified in-browser: a housekeepingAdmin session sees only its Housekeeping ticket, with the filter locked and other services absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- wasEverConnected was function-local to openTicketStream(), resetting to false on every reconnect call, so the 'stop polling once the stream is back' branch could never fire across a real reconnect. Moved to module scope (streamEverConnected), reset only when opening/closing a ticket. - restrictServiceFilterByRole() unconditionally disabled the service filter, permanently locking a multi-department admin (e.g. foodAdmin + travelAdmin) to whichever service came first in DOM order. Now only disables when there's nothing to choose between (allowedServices.length <= 1). - loadTicketDetails() didn't guard against a slow response for a ticket the admin has since navigated away from overwriting the now-current ticket's drawer and unread-tracking state. Captures the target ticket id up front and bails if currentTicketId has moved on by the time the fetch resolves. - openTicket() had no re-entrancy guard — two fast invocations (double-click) could each start their own SSE stream, leaking the first (untrackable, unabortable) connection. Added an isOpeningTicket guard. - JSON.parse(sessionStorage roles) was unguarded in both tickets.js and roleCheck.js — malformed session data would throw, in roleCheck.js's case skipping the access-check/redirect entirely (failing open instead of closed). Both now fail closed to an empty roles array on parse failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Extracted escapeHtml() into a shared style/js/htmlEscape.js, matching how roleCheck.js/sessionstorage.js are shared via script include, instead of a 4th independent copy (three other pages already each had their own identical copy — left those as pre-existing duplication, out of scope here). - Wired up the existing style/js/tableSortFilter.js enhanceTable() utility (search + sort) instead of shipping a bare table with none, matching nearly every other admin list page. Row-numbering is disabled since the first column is the ticket id, not a row index. Only bound once (not on every 10s poll) since its search-listener registration isn't idempotent. - Added an authHeaders() helper instead of hand-building the Authorization header inline 5 separate times. - populateDrawer's wasAtBottom scroll check was computed and then explicitly discarded (void wasAtBottom) — wired it up to actually preserve scroll position on background refreshes instead of yanking the admin back to the bottom every 10-60s while they're reading earlier messages. - Removed the dead currentTicket variable (set/cleared, never read). Deferred (per review guidance, correctness over cosmetic risk): - restrictServiceFilterByRole's dropdown-restriction pattern duplicates a per-page pattern already used elsewhere (manageBulkFood.js, maintenance.js) but neither is a shared export — left as consistent with existing (repeated) convention rather than refactoring unrelated pages. - Consolidating the 8 module-level mutable variables into one state object is a pure refactor touching most of the file; left as-is to avoid risking the timing-sensitive reconnect/watchdog logic just fixed above. - No automated check ties this repo's TICKET_ROLE_SERVICE_MAP to the backend's TICKET_SERVICE_ROLE_MAP — would need cross-repo infra, out of scope for this fix pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ncy guard, minor cleanups
/simplify pass on the ticket management page:
- style/js/roleCheck.js: extracted a shared getRoles() (fail-closed JSON.parse
of sessionStorage roles) used by checkRoleAccess and getHomePageForRole,
which each had their own copy. tickets.js's restrictServiceFilterByRole now
calls this too instead of a third independent copy — confirmed by three of
the four review angles independently.
- admin/ticket/tickets.js: extracted teardownStream() (abort the in-flight
stream + clear its timers) used by both openTicket() and closeDrawer(),
which had the identical 4-step sequence copy-pasted.
- admin/ticket/tickets.js: replaced the isOpeningTicket re-entrancy guard
with the same "supersession" pattern already used elsewhere in this file
(loadTicketDetails' currentTicketId-still-matches check). The old guard
blocked ANY second click while the first was loading, including a click on
a different ticket — which was silently dropped with no error. Now a
second click properly takes over: openTicket() checks currentTicketId
still matches after its await, just like loadTicketDetails() already does,
so the newer request wins instead of being ignored. As part of this,
fetching a ticket's details and opening its live stream now start
concurrently instead of sequentially, since they don't depend on each
other.
- admin/ticket/tickets.js: renderMessage() no longer does a wasted
container.querySelector dedup-check when called from populateDrawer's
full-rebuild loop (the container was just cleared, so the check can never
find anything) — added a skipDedupCheck option for that call site, and
removed a dead `container = container || document.getElementById(...)`
fallback parameter now that both call sites always pass one explicitly.
- admin/adminhome.html: the literal { href: 'ticket/tickets.html', text:
'Support Tickets' } was copy-pasted into 6 different role arrays; the file
already has an established const-for-repeated-entry convention
(shortLinkButton) that this didn't follow — added supportTicketsButton
and referenced it from all 6 places instead.
Skipped: making the shared enhanceTable() utility's search-listener
idempotent at its source (tickets.js's local one-time flag already handles
its own repeated-render case correctly; the shared file is unmodified by
this diff and used by several unrelated pages, so fixing it there is a
separate, broader change than this pass warrants). Also skipped removing
the reload-after-send/reload-after-status-change calls even though the
admin's own action is likely echoed back over the live stream anyway —
removing the explicit reload would trade a small inefficiency for relying
entirely on stream delivery being timely and reconnected, which is a
behavior change (and risk) beyond what a quality-only pass should make.
Verified: node --check clean on both changed .js files; manually re-tested
in a browser end-to-end (open a ticket via the refactored openTicket() flow,
receive a live SSE message) after the changes — both work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @vendz's task in 5m 47s —— View job Code Review — Support Ticketing DashboardNice work — this is a well-structured feature. The SSE handling (fetch + The main theme in my findings is HTTP error handling: several Findings (posted inline):
Minor notes (no inline comment needed):
None of these are blocking on their own, but the
|
| await fetch(`${CONFIG.basePath}/tickets/${currentTicketId}/messages`, { | ||
| method: 'POST', | ||
| headers: authHeaders(true), | ||
| body: JSON.stringify({ message }) | ||
| }); | ||
|
|
||
| messageBox.value = ''; | ||
| await loadTicketDetails(); |
There was a problem hiding this comment.
Bug (high): a failed send is treated as success and silently loses the admin's message.
fetch only rejects on network errors, not on HTTP 4xx/5xx. If the POST returns e.g. 403/500, execution still falls through to messageBox.value = '' and loadTicketDetails(), so the textarea is cleared and no error is shown — the typed reply is gone. The catch only fires for network failures.
This also deviates from the repo convention of checking response.ok and surfacing result.message (see admin/maintenance/updateRequest.js, admin/food/plateCount.js).
| await fetch(`${CONFIG.basePath}/tickets/${currentTicketId}/messages`, { | |
| method: 'POST', | |
| headers: authHeaders(true), | |
| body: JSON.stringify({ message }) | |
| }); | |
| messageBox.value = ''; | |
| await loadTicketDetails(); | |
| const res = await fetch(`${CONFIG.basePath}/tickets/${currentTicketId}/messages`, { | |
| method: 'POST', | |
| headers: authHeaders(true), | |
| body: JSON.stringify({ message }) | |
| }); | |
| if (!res.ok) throw new Error('Request failed'); | |
| messageBox.value = ''; | |
| await loadTicketDetails(); |
| await fetch(`${CONFIG.basePath}/tickets/${currentTicketId}/status`, { | ||
| method: 'PATCH', | ||
| headers: authHeaders(true), | ||
| body: JSON.stringify({ status }) | ||
| }); |
There was a problem hiding this comment.
Bug (medium): status update reports success even when the PATCH fails.
Same issue as sendReply — an HTTP 4xx/5xx doesn't reject the promise, so the code proceeds to reload as if the status changed. The admin sees no error and believes the status was updated. Guard on res.ok to keep it consistent with the rest of the admin panel.
| await fetch(`${CONFIG.basePath}/tickets/${currentTicketId}/status`, { | |
| method: 'PATCH', | |
| headers: authHeaders(true), | |
| body: JSON.stringify({ status }) | |
| }); | |
| const res = await fetch(`${CONFIG.basePath}/tickets/${currentTicketId}/status`, { | |
| method: 'PATCH', | |
| headers: authHeaders(true), | |
| body: JSON.stringify({ status }) | |
| }); | |
| if (!res.ok) throw new Error('Request failed'); |
| const resp = await fetch(`${CONFIG.basePath}/tickets/${ticketId}/stream`, { | ||
| headers: authHeaders(), | ||
| signal: controller.signal | ||
| }); | ||
|
|
||
| const reader = resp.body.getReader(); |
There was a problem hiding this comment.
Bug (medium): the stream never checks resp.ok, causing a reconnect storm on auth/permission errors.
If the stream request comes back non-2xx (expired token → 401, forbidden ticket/department → 403, server error → 500), the response still has a body, so resp.body.getReader() reads the small error JSON, finds no data: frame, hits done, and calls scheduleStreamReconnect(). Since currentTicketId still matches, this retries every 3s indefinitely against a request that will never succeed — hammering the backend with an expired/forbidden token. Also, if resp.body is ever null, line 467 throws TypeError (then reconnect-loops the same way).
Minimal guard below. Beyond this, consider not retrying on client (4xx) errors in scheduleStreamReconnect so an expired token or forbidden ticket stops the loop instead of storming.
| const resp = await fetch(`${CONFIG.basePath}/tickets/${ticketId}/stream`, { | |
| headers: authHeaders(), | |
| signal: controller.signal | |
| }); | |
| const reader = resp.body.getReader(); | |
| const resp = await fetch(`${CONFIG.basePath}/tickets/${ticketId}/stream`, { | |
| headers: authHeaders(), | |
| signal: controller.signal | |
| }); | |
| if (!resp.ok || !resp.body) throw new Error(`Stream failed: ${resp.status}`); | |
| const reader = resp.body.getReader(); |
| const res = await fetch(`${CONFIG.basePath}/tickets/${targetTicketId}`, { | ||
| headers: authHeaders() | ||
| }); | ||
|
|
||
| const result = await res.json(); | ||
| const ticket = result.data; |
There was a problem hiding this comment.
Bug (medium): no res.ok/result.data guard — an error response makes populateDrawer(undefined) throw.
On any non-2xx (or a 200 without data), result.data is undefined, and populateDrawer(ticket) immediately dereferences ticket.id → TypeError, swallowed by the outer catch. Net effect: openTicket() still awaits this successfully and opens an empty drawer with no error shown. The list fetch (fetchTickets, line 103) has the same missing check but degrades to an empty table instead of throwing.
| const res = await fetch(`${CONFIG.basePath}/tickets/${targetTicketId}`, { | |
| headers: authHeaders() | |
| }); | |
| const result = await res.json(); | |
| const ticket = result.data; | |
| const res = await fetch(`${CONFIG.basePath}/tickets/${targetTicketId}`, { | |
| headers: authHeaders() | |
| }); | |
| if (!res.ok) throw new Error('Request failed'); | |
| const result = await res.json(); | |
| const ticket = result.data; |
| <select id="statusUpdateSelect" class="form-control"> | ||
| <option value="open">Open</option> | ||
| <option value="in progress">In Progress</option> | ||
| <option value="resolved">Resolved</option> | ||
| </select> |
There was a problem hiding this comment.
Bug (medium): the status dropdown is missing closed, so closed tickets misbehave.
The list filter (line 283) lets an admin filter to Closed tickets and open one. When populateDrawer runs statusUpdateSelect.value = ticket.status (tickets.js:251) with ticket.status === 'closed', there is no matching <option>, so the select renders blank (selectedIndex -1). If the admin then clicks Update Status, document.getElementById('statusUpdateSelect').value returns '' and the code PATCHes status: "".
Add the missing option so closed tickets display correctly:
| <select id="statusUpdateSelect" class="form-control"> | |
| <option value="open">Open</option> | |
| <option value="in progress">In Progress</option> | |
| <option value="resolved">Resolved</option> | |
| </select> | |
| <select id="statusUpdateSelect" class="form-control"> | |
| <option value="open">Open</option> | |
| <option value="in progress">In Progress</option> | |
| <option value="resolved">Resolved</option> | |
| <option value="closed">Closed</option> | |
| </select> |
If admins are intentionally not allowed to (re)set closed, instead guard updateStatus() to bail when the selected value is empty.
| <td>${new Date(t.createdAt).toLocaleString()}</td> | ||
| <td>${t.last_message_at ? new Date(t.last_message_at).toLocaleString() : '-'}</td> | ||
| <td> | ||
| <button class="btn" onclick="openTicket('${encodeURIComponent(t.id)}')">View</button> |
There was a problem hiding this comment.
Latent bug (low, id-format dependent): the encoded id becomes currentTicketId, desyncing the unread-tracking keys.
openTicket sets currentTicketId = ticketId from this URI-encoded value, and setLastSeen/getLastSeen in loadTicketDetails/appendMessage key off it. But renderTicketTable reads getLastSeen(t.id) with the raw id (line 125). If a ticket id ever contains a character encodeURIComponent changes (space, #, /, etc.), the "seen" timestamp is stored under one key and read under another, so the unread dot never clears.
Harmless for plain integer / UUID ids (no-op encoding), but fragile. Cleaner: keep currentTicketId raw and apply encodeURIComponent only where it's interpolated into a fetch URL. If you keep it as-is, at least add a note that ids must be encoding-safe.
- sendReply / updateStatus / loadTicketDetails / fetchTickets: check res.ok (fetch doesn't reject on 4xx/5xx) so a failed send no longer silently clears the textarea, a failed status update no longer reports success, and a failed detail load surfaces an error instead of opening a blank drawer - loadTicketDetails now returns success so openTicket can show an error rather than pop an empty drawer; stays silent on background poll/reconnect reloads - stream: guard resp.ok/resp.body and stop retrying on 4xx (was a 3s reconnect storm on an expired/forbidden token); wrap per-frame JSON.parse so one bad frame doesn't drop the connection - status dropdown: add a display-only disabled 'closed' option and lock the status control/button for closed tickets (was rendering blank + could PATCH status:'') - openTicket: decode the id so unread-tracking keys stay consistent Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ RBAC Replace the old 6-service ticket taxonomy with the backend's 12-department model (Part A of the ticket-departments-and-media design): - tickets.js: swap the role->single-service TICKET_ROLE_SERVICE_MAP for a mirror of the backend's service->roles TICKET_SERVICE_ROLE_MAP, and compute a role's allowed services by scanning it (a role can now map to multiple services, e.g. officeAdmin -> Raj Sharan + Others). Purely UX; the backend still enforces access. - tickets.html: serviceFilter options now list the 12 departments in the spec order; checkRoleAccess includes every participating role. - adminhome.html: show the "Support Tickets" button for all participating roles (adds accountsAdmin, adhyayanAdmin, electricalAdmin, utsavAdmin, wifiAdmin). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part B (media attachments) of the ticket-departments-and-media design, admin side. The serve endpoint is Bearer-only, so media is auth-fetched into object URLs rather than loaded via a plain <img src>: - Render ticket-level (creation) attachments and per-message image/video attachments as thumbnails; click to open full size / play in a media modal. - Auth-fetch each attachment (follows the backend 302 to the presigned S3 GET), create tracked object URLs, and revoke them on drawer close / re-render to avoid leaks. Expired media (60-day retention) shows a placeholder instead of fetching. - Add an image attach control to the reply composer: pick image(s) (client pre-checks: images only, <=5MB, <=5), presign -> PUT direct to S3 with the signed Content-Type -> send the message with attachments[]. Admins attach images only (video is user-only); message text is optional when >=1 image. tickets.html gains the attach control, preview strip, a media modal, and the supporting CSS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes five verified findings in the support-ticket drawer (all in admin/ticket/tickets.js, + one CSS rule in tickets.html): 1. sendReply() snapshots currentTicketId (and the file batch) before any await and re-checks after the upload/POST, so a mid-upload ticket switch/close can no longer POST to the wrong ticket (or tickets/null) or wipe the now-current ticket's composer. 2. populateDrawer() no longer closes the media modal, so a 60s poll / SSE status_update / reconnect reload can't yank away the full-size media the admin is viewing. The modal now owns its OWN object URL (minted from the blob) so it survives background thumbnail re-renders; it's closed only on explicit drawer close / ticket switch. 3. Added a render-generation counter (bumped in populateDrawer and closeDrawer). loadAuthedMedia captures it and, if the generation changed by the time its fetch resolves, revokes the just-minted object URL and skips attaching it — fixing object-URL leaks from overlapping re-renders and close-mid-fetch. 4. A failed authed-media fetch now swaps the blank thumbnail for an inline "media failed to load" placeholder instead of silently logging. 5. uploadReplyImages() stamps each file's S3 key on success and, on a retry after a partial-batch failure, only presigns/PUTs the not-yet- uploaded files and reuses the stamped keys — no more orphaned re-uploads. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ets, declutter actions - Clear the reply textarea (not just staged images) when switching tickets or closing the drawer — a draft no longer bleeds from one ticket into another. - Closed tickets are terminal: disable the textarea/attach/send + show a note, matching the backend which rejects replies on a closed ticket. - Declutter the drawer actions: move drawer-close to a header X, group the status control (labelled), and give the composer a clear Attach (ghost) + Send (primary) hierarchy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…incoming media renders live Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… rendering Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @vendz's task in 4m 30s —— View job Re-review after new commitsThe PR grew substantially since the last review pass — 12 new commits added a media-attachments feature (image/video upload + display) and remapped the RBAC model to a 12-department taxonomy, on top of the fixes for the prior findings. Verified both.
Prior findings — all confirmed fixed:
New finding (posted inline):
Everything else in the new media/RBAC code (auth'd fetch → object URL lifecycle, render-generation guards against stale re-renders, |
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
|
I'll analyze this and get back to you. |

Summary
admin/ticket/tickets.html+tickets.js) for the chat-based support ticketing system: ticket list with polling refresh, live chat drawer over SSE, status updates, department-scoped filtering.TICKET_ROLE_SERVICE_MAPmirrors the backend's role→service mapping so each department admin (maintenanceAdmin, housekeepingAdmin, foodAdmin, travelAdmin, officeAdmin) only sees their own tickets; superAdmin sees everything. Server-side enforcement lives in the backend PR — this is UX-level filtering plus the shared "Support Tickets" home button wired intoadminhome.htmlfor the relevant roles.EventSourcesince it can't set the Authorization header), with reconnect/watchdog handling for dropped connections.Key pieces
admin/ticket/tickets.js— list/detail/stream logic, scroll-position-preserving message rendering, stale-response race protection when switching tickets quickly.style/js/roleCheck.js— sharedgetRoles()helper (previously each page parsedsessionStorageroles independently).style/js/htmlEscape.js— sharedescapeHtml()(previously duplicated across 3 files).admin/adminhome.html— added "Support Tickets" button for relevant department roles.Testing
/code-reviewpass (xhigh effort, 15 verified findings) — all fixed. Followed by a/simplifycleanup pass — all findings applied or explicitly skipped as false positives.Test plan