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.
Ticket reference in the client list
Section titled “Ticket reference in the client list”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.
Client search
Section titled “Client search”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.handleDragOverno longer bails unconditionally onisClient— it now allows the cross-column move in local state only whencanClientMoveToColumn(targetCol)passes, and never touchesstatusfor a client move.handleDragEndfor a client: if the drop still targets a column different from where the card started (meaninghandleDragOvernever accepted it), it’s a no-op — the card springs back. Otherwise it PATCHes{ items: [{ id, boardColumnId }] }to/api/tickets/reorder(nostatus, nosortOrder) if the column changed, then always follows with theclient-reorderorder-sync call from above. Both fetches roll the local optimistic state back and set areorderErrormessage (“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 anyboardColumnIdat 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 areclientVisible— this tenant/visibility check is new; previously the route trusted the posted id list with no ownership check onceboardColumnIdwasn’t involved. Then, if any item carries aboardColumnId, load those columns and 403 unless every one passescanClientMoveToColumn. A client-suppliedstatusis now always stripped (if (item.status && !isClient)), regardless of whetherboardColumnIdis present — closing a gap where a clientsortOrder-only reorder could previously carry astatusthrough.sortOrderonReorderItembecame optional (number | undefined) since a client cross-column move sends onlyboardColumnId.
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.