Skip to content

feat: support ticketing dashboard with live chat and department RBAC#150

Open
vendz wants to merge 19 commits into
devfrom
feat/support-ticketing
Open

feat: support ticketing dashboard with live chat and department RBAC#150
vendz wants to merge 19 commits into
devfrom
feat/support-ticketing

Conversation

@vendz

@vendz vendz commented Jul 5, 2026

Copy link
Copy Markdown
Member

Summary

  • New admin dashboard (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.
  • Department RBAC: TICKET_ROLE_SERVICE_MAP mirrors 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 into adminhome.html for the relevant roles.
  • SSE is consumed via a hand-rolled fetch + ReadableStream reader (vanilla JS admin panel can't use browser EventSource since 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 — shared getRoles() helper (previously each page parsed sessionStorage roles independently).
  • style/js/htmlEscape.js — shared escapeHtml() (previously duplicated across 3 files).
  • admin/adminhome.html — added "Support Tickets" button for relevant department roles.

Testing

  • Manual end-to-end pass via local static server + local backend: opening tickets, live message delivery, status changes reflecting instantly on the app side, role-based filtering per department, reconnect after simulated drops.
  • Went through a full /code-review pass (xhigh effort, 15 verified findings) — all fixed. Followed by a /simplify cleanup pass — all findings applied or explicitly skipped as false positives.

Test plan

  • Confirm each department admin role is actually assigned to the right prod accounts (ops task, not code)
  • Smoke test in a real browser against the deployed backend (local testing used a temporary local API override, reverted before commit)

vendz and others added 11 commits July 4, 2026 14:59
…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

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @vendz's task in 5m 47s —— View job


Code Review — Support Ticketing Dashboard

Nice work — this is a well-structured feature. The SSE handling (fetch + ReadableStream with watchdog/reconnect), the stale-response race guards when switching tickets quickly, scroll-position preservation, message dedup by data-msg-id, and the consistent escapeHtml() on all innerHTML sinks (with textContent for the debug panel) are all done carefully, and the inline comments explaining why are genuinely helpful. XSS surface looks well covered.

The main theme in my findings is HTTP error handling: several fetch calls don't check response.ok, which both introduces bugs and deviates from the repo convention (existing code checks response.ok + surfaces result.message — e.g. admin/food/plateCount.js, admin/maintenance/updateRequest.js, admin/travel/updateBookingStatus.js).

Findings (posted inline):

  • 🔴 sendReply treats a failed send as success — clears the textarea + reloads on 4xx/5xx, silently losing the admin's message. → inline
  • 🟠 updateStatus reports success even when the PATCH fails. → inline
  • 🟠 Stream never checks resp.ok — a 401/403/500 causes an indefinite 3s reconnect storm (and a TypeError if resp.body is null). Consider not retrying on 4xx. → inline
  • 🟠 loadTicketDetails has no res.ok/data guard — an error response makes populateDrawer(undefined) throw and opens an empty drawer with no error. → inline
  • 🟠 Status dropdown is missing closed — opening a Closed ticket renders a blank select and Update Status can PATCH status: "". → inline
  • 🟡 Unread-tracking key desync — the URI-encoded id becomes currentTicketId while the table reads unread state with the raw id; harmless for int/UUID ids, fragile otherwise. → inline

Minor notes (no inline comment needed):

  • serviceFilter has an "IT Support" option (tickets.html:289) that has no entry in TICKET_ROLE_SERVICE_MAP and isn't in the map you say mirrors the backend — worth confirming it's a real backend service value, otherwise it's a dead option only superAdmin ever sees.
  • In the stream loop, a single malformed frame makes JSON.parse (tickets.js:484) throw out of the read loop → reconnect + buffer loss. Wrapping the per-frame parse in try/catch would make one bad frame non-fatal.
  • getRoles() / escapeHtml() extraction and reuse across pages is a good cleanup. 👍

None of these are blocking on their own, but the sendReply data-loss one is worth fixing before merge. I did not verify the backend role→service map or endpoints (separate PR) — the frontend-side mirror looks internally consistent aside from the "IT Support" note.


  • Gather context (diff, changed files, repo conventions)
  • Review admin/ticket/tickets.js (list/detail/SSE stream logic)
  • Review admin/ticket/tickets.html
  • Review style/js/roleCheck.js + style/js/htmlEscape.js
  • Review admin/adminhome.html
  • Post inline findings + summary

Comment thread admin/ticket/tickets.js Outdated
Comment on lines +387 to +394
await fetch(`${CONFIG.basePath}/tickets/${currentTicketId}/messages`, {
method: 'POST',
headers: authHeaders(true),
body: JSON.stringify({ message })
});

messageBox.value = '';
await loadTicketDetails();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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();

Comment thread admin/ticket/tickets.js Outdated
Comment on lines +411 to +415
await fetch(`${CONFIG.basePath}/tickets/${currentTicketId}/status`, {
method: 'PATCH',
headers: authHeaders(true),
body: JSON.stringify({ status })
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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');

Comment thread admin/ticket/tickets.js
Comment on lines +462 to +467
const resp = await fetch(`${CONFIG.basePath}/tickets/${ticketId}/stream`, {
headers: authHeaders(),
signal: controller.signal
});

const reader = resp.body.getReader();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();

Comment thread admin/ticket/tickets.js
Comment on lines +211 to +216
const res = await fetch(`${CONFIG.basePath}/tickets/${targetTicketId}`, {
headers: authHeaders()
});

const result = await res.json();
const ticket = result.data;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.idTypeError, 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.

Suggested change
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;

Comment thread admin/ticket/tickets.html
Comment on lines +342 to +346
<select id="statusUpdateSelect" class="form-control">
<option value="open">Open</option>
<option value="in progress">In Progress</option>
<option value="resolved">Resolved</option>
</select>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
<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.

Comment thread admin/ticket/tickets.js
<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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown

Re-review after new commits

The PR was updated (latest commit fix(tickets): address PR review findings). I'm re-reviewing the current state to confirm the earlier findings are resolved and to check for anything new.

  • Gather context (diff vs origin/dev, changed files, repo conventions)
  • Verify the 6 prior findings were addressed
  • Check for any new issues introduced by the fixes
  • Post inline findings (if any) + summary

View job run

vendz and others added 6 commits July 6, 2026 17:22
…+ 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

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Claude finished @vendz's task in 4m 30s —— View job


Re-review after new commits

The 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.

  • Gather context (diff vs origin/dev, current file state, repo conventions)
  • Verify the 6 prior findings were addressed
  • Check for any new issues introduced by fixes/new commits
  • Post inline findings + summary

Prior findings — all confirmed fixed:

  • sendReply now checks res.ok before clearing the composer (tickets.js:857)
  • updateStatus now checks res.ok before reloading (tickets.js:893)
  • ✅ Stream fetch now checks resp.ok || !resp.body and stops retrying on 4xx instead of storming every 3s (tickets.js:954, 1036)
  • loadTicketDetails now guards on res.ok and missing ticket data, and surfaces a failure via alert() in openTicket instead of opening an empty drawer (tickets.js:438, 410)
  • ✅ Status dropdown now has a disabled closed option so a closed ticket's select shows correctly instead of blank, and the whole composer/status control is locked when status === 'closed' (tickets.html:572, tickets.js:513)
  • ✅ Unread-tracking desync fixed — openTicket now decodeURIComponents the id before assigning currentTicketId, so it matches the raw id used by the table's getLastSeen(t.id) (tickets.js:384)
  • ✅ Both minor notes from the first pass are also resolved: the stream's per-frame JSON.parse is now wrapped in try/catch (tickets.js:979), and the dead "IT Support" filter option is gone, replaced by an IT: [] entry in the new 12-department map that's correctly superAdmin-only.

New finding (posted inline):

  • 🟡 tickets.js:828sendReply() captures pendingReplyFiles by reference, not a copy. Since attachImageBtn stays enabled during an in-flight send, a file picked while a previous send is still uploading gets swept into that in-flight send's attachment list with no uploaded key, which either 400s the send or silently drops the image the admin meant for their next message. Fix is a one-line .slice().

Everything else in the new media/RBAC code (auth'd fetch → object URL lifecycle, render-generation guards against stale re-renders, Promise.all-parallelized uploads with idempotent retry via _uploadedKey, department→role service map mirroring, escapeHtml/textContent usage on all attachment-related DOM writes) held up under review — no other correctness or XSS issues found.

Comment thread admin/ticket/tickets.js Outdated
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Claude Code is working…

I'll analyze this and get back to you.

View job run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants