Skip to content

agency — client-portal: client ticket references, search, and unified ordering

As of 2026-08-06, the client-facing views in client-portal (role: client) fix four related defects: no ticket reference in the client list, no client search, list-drag order not reflected on the board, and clients unable to drop cards into custom board columns. No Prisma migration. Team/admin views are unchanged throughout.

ClientRequestList’s ClientRequest interface gained ticketNumber: number | null; RequestRow renders formatTicketId(ticket.ticketNumber, ticket.tenant.name) next to the title, the same helper and font-mono tabular-nums styling the team list and board card already used. Purely a render change — TicketsClient already carried ticketNumber through.

The search <Input> in TicketsClient.tsx was previously wrapped in {!isClient && …}; that gate is now dropped for the input only (the filter/tab bar below it stays team-only). The API already scoped search correctly for clients (GET /api/tickets?search= matches title, description, and non-internal comments, and excludes internal comments and other tenants’ tickets) — this was a UI-only gate.

When a client has a non-empty debouncedSearch, TicketsClient renders the full unfiltered tickets result through a new ClientSearchResults component instead of ClientRequestList (which only ever receives activeClientTickets, i.e. not done/closed). Search results therefore include done/closed requests — clients search for past requests too.

ClientSearchResults is deliberately not draggable: client-reorder renumbers clientSortOrder to 1..N over exactly the ids posted, so dragging within a filtered subset would renumber those rows and corrupt the relative order of everything off-screen. It has its own empty state (“No requests match your search.”).

clientSortOrder as the single ordering source

Section titled “clientSortOrder as the single ordering source”

Previously the client list persisted clientSortOrder via PATCH /api/tickets/client-reorder, while the board ordered each column by sortOrder and a client board drag PATCHed /api/tickets/reorder with a new sortOrder — two independent fields that could disagree, and a client drag was overwriting the team’s sortOrder.

Now, for isClient, BoardClient.getColumnTickets orders each column with orderClientRequests() (rank ascending, unranked falls back to createdAt desc) instead of sortOrder. BoardTicket gained clientSortOrder and createdAt (both already returned by GET /api/tickets). Team ordering is untouched — it still sorts by sortOrder.

On the write side, a client board drag no longer touches sortOrder at all. BoardClient.handleDragEnd, for a client, computes the new global client order: it takes orderClientRequests(tickets) (every loaded client ticket, in current rank order) and splices the dragged id to its new position with moveInClientOrder(orderedIds, activeId, overId) — a new pure helper in src/lib/client-priority.ts built on @dnd-kit/sortable’s arrayMove, unit tested independently of any drag/DOM logic. It no-ops (returns a copy) when either id is unmatched or they’re equal, so callers can diff the result against the input to detect whether a real move happened. The resulting orderedIds is PATCHed to /api/tickets/client-reorder, same endpoint the list view already used — so a list drag and a board drag now write the exact same ranking, and each view reflects the other on next load.

Consequence worth knowing: clientSortOrder is one global per-tenant ranking, not per-column — both client views always post their full visible set, so ranks for rows outside the posted set can go stale. This is harmless: the next post renumbers everything from 1 again.

Clients dragging into custom board columns

Section titled “Clients dragging into custom board columns”

“Custom column” = isDefault === false. The four built-in columns (mapped to open / waiting_client / done / closed) are isDefault: true; anything an admin adds via “Add Column” is isDefault: false. Scoping client moves to non-default columns stops a client from marking their own ticket done/closed by dragging it into a status-mapped column.

New pure helper src/lib/board-permissions.ts:

canClientMoveToColumn(col) => col.clientVisible && !col.isDefault
  • Client (optimistic UI): BoardClient.handleDragOver no longer bails unconditionally on isClient — it now allows the cross-column move in local state only when canClientMoveToColumn(targetCol) passes, and never touches status for a client move. handleDragEnd for a client: if the drop still targets a column different from where the card started (meaning handleDragOver never accepted it), it’s a no-op — the card springs back. Otherwise it PATCHes { items: [{ id, boardColumnId }] } to /api/tickets/reorder (no status, no sortOrder) if the column changed, then always follows with the client-reorder order-sync call from above. Both fetches roll the local optimistic state back and set a reorderError message (“Could not save your move/order. Please try again.”) on failure.
  • Server (PATCH /api/tickets/reorder): the previous client rule was a blanket 403 on any boardColumnId at all. It’s replaced with: load every referenced ticket by id and 403 unless all of them belong to the client’s own tenant and are clientVisible — this tenant/visibility check is new; previously the route trusted the posted id list with no ownership check once boardColumnId wasn’t involved. Then, if any item carries a boardColumnId, load those columns and 403 unless every one passes canClientMoveToColumn. A client-supplied status is now always stripped (if (item.status && !isClient)), regardless of whether boardColumnId is present — closing a gap where a client sortOrder-only reorder could previously carry a status through. sortOrder on ReorderItem became optional (number | undefined) since a client cross-column move sends only boardColumnId.

ClientColumn (the client’s board column chrome) gained isOver styling (border-primary/30 bg-primary/5) matching the admin DroppableColumn, so a valid drop target highlights during drag.

board-permissions.test.ts and the moveInClientOrder additions to client-priority.test.ts cover the two new pure helpers directly. A new reorder.test.ts covers the route’s client-permission branches (own-tenant + clientVisible required, default-column rejection, status always stripped for clients, unchanged admin/team behavior with boardColumn.findMany never called for a non-client role). Component tests (ClientRequestList/ClientSearchResults/TicketsClient) use renderToStaticMarkup per repo convention (no jsdom/RTL) and assert on rendered HTML.

Source: agency PR #398.