Class TicketsWebService
AUDIT_AS_WORKFLOW_RECCE.md). Read endpoints are open to any caller
the rest of the v2 surface accepts; write endpoints (POST/PUT/DELETE)
require an authenticated principal — anonymous callers get a 401/403 via
PreAuthorize.
DELETE is a SOFT close: the ticket is transitioned to
TicketState.CANCELLED and a CANCELLED event is appended. The
ticket row + its event log are preserved (Decision 4 of the recce:
append-only).
- Author:
- paul
-
Nested Class Summary
Nested ClassesModifier and TypeClassDescriptionstatic classBody ofPOST /tickets/{id}/targets.static classResult of adding targets: which ids landed and which were already there.static classstatic classBody forcreateTicket(CreateTicketRequest).static classstatic classstatic classstatic classResult of removing a target: what went, and what state it was in.static classThe 201 body ofcreateTicketFromAccession(CreateTicketFromAccessionRequest).static classstatic classstatic classBody forupdateTicket(Long, UpdateTicketRequest). -
Constructor Summary
ConstructorsConstructorDescriptionTicketsWebService(TicketService ticketService, UserManager userManager, UserReadService userReadService, ExpressionExperimentService expressionExperimentService, PreboardedExperimentService preboardedExperimentService) -
Method Summary
Modifier and TypeMethodDescriptionAdd a target to a ticket that is open to additions.jakarta.ws.rs.core.ResponseOpen a new ticket.jakarta.ws.rs.core.ResponseOpen a ticket over an experiment named by accession, resolving the accession to the dataset(s) in one call.jakarta.ws.rs.core.ResponsedeleteTicket(Long id, String reason) Soft-close a ticket: transition toTicketState.CANCELLEDand append a CANCELLED event.getMyQueue(int limit, int resolvedWithinDays) Calling admin's own ticket queue: assigned-to-me + the few cheap counters that back a "My Queue" card in the curation-UI.Lightweight counters about the calling admin's ticket workload.Global open-ticket roll-up for the admin dashboard's TicketsSection.The calling curator's scratchpad, provisioned on first access.Retrieve a single ticket, including its full event log.getTicketEvents(Long id, CursorArg cursorArg, LimitArg limitArg) Retrieve only the event log for a ticket.getTickets(boolean openOnly, Long assigneeId, TicketPriority priority, TicketType type, TicketState state, TicketTargetType targetType, Date updatedSince, OffsetArg offsetArg, LimitArg limitArg, CursorArg cursorArg) List tickets with optional filters and offset/limit or cursor pagination.Public hook forPlatformsWebServiceso the platform-tickets route can delegate here without duplicating the Ticket→VO mapping logic.openTicketsForArrayDesignByCursor(Long adId, Cursor cursor, int limit) Cursor-mode counterpart toopenTicketsForArrayDesign(Long)(step 1s ofCURSOR_PAGINATION_STEP1_PLAN.md).Public hook forDatasetsWebServiceso the dataset-tickets route can delegate here without duplicating the Ticket→VO mapping logic.openTicketsForExpressionExperimentByCursor(Long eeId, Cursor cursor, int limit) Cursor-mode counterpart toopenTicketsForExpressionExperiment(Long)(step 1p ofCURSOR_PAGINATION_STEP1_PLAN.md).Public hook forDatasetsWebService's bulkPOST /datasets/tickets: the same questionopenTicketsForExpressionExperiment(Long)answers for one dataset, asked about a page of them in one query.PATCH alias forupdateTicket(Long, UpdateTicketRequest).jakarta.ws.rs.core.ResponseremoveTicketTarget(Long id, TicketTargetType targetType, Long targetId) Remove a target from a ticket.searchTickets(String query, boolean openOnly, LimitArg limitArg) Ticket picker: "choose a ticket by typing".updateTargetStatus(Long id, Long targetRowId, TicketsWebService.UpdateTargetStatusRequest req) Update mutable fields of a ticket.
-
Constructor Details
-
TicketsWebService
@Autowired public TicketsWebService(TicketService ticketService, UserManager userManager, UserReadService userReadService, ExpressionExperimentService expressionExperimentService, PreboardedExperimentService preboardedExperimentService)
-
-
Method Details
-
getTickets
@GET @Produces("application/json") public Object getTickets(@QueryParam("openOnly") @DefaultValue("false") boolean openOnly, @QueryParam("assignee") @Nullable Long assigneeId, @QueryParam("priority") @Nullable TicketPriority priority, @QueryParam("type") @Nullable TicketType type, @QueryParam("state") @Nullable TicketState state, @QueryParam("targetType") @Nullable TicketTargetType targetType, @QueryParam("updatedSince") @Nullable Date updatedSince, @QueryParam("offset") @DefaultValue("0") OffsetArg offsetArg, @QueryParam("limit") @DefaultValue("20") LimitArg limitArg, @QueryParam("cursor") CursorArg cursorArg) List tickets with optional filters and offset/limit or cursor pagination.Step 1o of
CURSOR_PAGINATION_STEP1_PLAN.mdadds opt-in keyset (cursor) pagination alongside the legacy offset path. Legacy mode keeps thet.updatedAt descordering used for human-readable dashboards; cursor mode forces a single-component ascendingidsort because the cursor DAO restricts cursors to id-only sorts until the phase-B indexed-column audit lands. -
getMyQueue
@GET @Path("/mine") @Produces("application/json") @PreAuthorize("hasAuthority('GROUP_CURATOR')") public ResponseDataObject<TicketsWebService.MyQueueResponse> getMyQueue(@QueryParam("limit") @DefaultValue("50") int limit, @QueryParam("resolvedWithinDays") @DefaultValue("7") int resolvedWithinDays) Calling admin's own ticket queue: assigned-to-me + the few cheap counters that back a "My Queue" card in the curation-UI. Splits assigned tickets into open (OPEN + IN_PROGRESS) and recently-resolved buckets — both capped per request to keep the response compact.Carries no scratchpad filter and needs none: every list here is scoped by ASSIGNEE, and a scratchpad is provisioned with a reporter and no assignee, so it does not reach this queue. The scratchpad has its own handle at
GET /tickets/scratchpad; a curator who does assign one to themselves has asked for it in their queue. -
getMyQueueSummary
@GET @Path("/summary/me") @Produces("application/json") @PreAuthorize("hasAuthority('GROUP_CURATOR')") public ResponseDataObject<TicketsWebService.MyQueueSummaryResponse> getMyQueueSummary()Lightweight counters about the calling admin's ticket workload. Cheap (two count queries + one find for oldest-open); intended for top-of-page badges.Assignee-scoped like
getMyQueue(int, int), so a scratchpad — reported by the curator, assigned to nobody — is already out of these counts without a type filter. -
getOpenTicketSummary
@GET @Path("/summary") @Produces("application/json") @PreAuthorize("hasAuthority('GROUP_CURATOR')") public ResponseDataObject<TicketsWebService.OpenTicketSummaryResponse> getOpenTicketSummary()Global open-ticket roll-up for the admin dashboard's TicketsSection. SumsTicketService.countOpenByType()for the total, exposes the per-type breakdown for at-a-glance triage. Single DAO call; intended to fire on a dashboard refetch interval. -
getScratchpad
@GET @Path("/scratchpad") @Produces("application/json") @PreAuthorize("isAuthenticated()") public ResponseDataObject<TicketValueObject> getScratchpad()The calling curator's scratchpad, provisioned on first access.A scratchpad is one ticket per curator, kept open indefinitely, holding whatever they are currently looking at; finishing with a dataset means removing it with
DELETE /tickets/{id}/targets/{targetType}/{targetId}, not resolving the ticket (Paul, 2026-08-31). This route exists so a client has a direct handle on it without listing and filtering — the dashboard pins it first, which is the client's job, not this route's.⚠️ The literal
/scratchpadsegment beats/{id}: JAX-RS sorts candidate methods by literal character count before template count (JSR-370 §3.7.2), so this never reachesgetTicket(Long)to be parsed as aLong. The sibling/mine,/summaryand/summary/meroutes rely on the same rule. -
searchTickets
@GET @Path("/search") @Produces("application/json") public ResponseDataObject<List<TicketSearchHitValueObject>> searchTickets(@QueryParam("query") String query, @QueryParam("openOnly") @DefaultValue("true") boolean openOnly, @QueryParam("limit") @DefaultValue("20") LimitArg limitArg) Ticket picker: "choose a ticket by typing". Distinct fromGET /tickets?query=in one respect that matters — a hit carriestargetCount, not thetargetsarray, so drawing twenty rows does not pull several hundred target rows to do it. -
getTicket
@GET @Path("/{id}") @Produces("application/json") public ResponseDataObject<TicketValueObject> getTicket(@PathParam("id") Long id) Retrieve a single ticket, including its full event log. -
getTicketEvents
@GET @Path("/{id}/events") @Produces("application/json") public Object getTicketEvents(@PathParam("id") Long id, @QueryParam("cursor") CursorArg cursorArg, @QueryParam("limit") @DefaultValue("20") LimitArg limitArg) Retrieve only the event log for a ticket. Intended for client-side polling — cheaper than re-fetching the whole ticket.Step 1r of
CURSOR_PAGINATION_STEP1_PLAN.mdadds opt-in keyset (cursor) pagination alongside the legacy unpaginated path. Legacy mode (nocursor) returns the full event list as aResponseDataObject(occurredAt-asc ordering); cursor mode pages by ascendingid(the cursor DAO restricts cursors to single-component id sorts until the phase-B indexed-column audit lands — events on a ticket are appended monotonically so id-asc tracks occurredAt-asc in practice). -
createTicket
@POST @Consumes("application/json") @Produces("application/json") @PreAuthorize("isAuthenticated()") public jakarta.ws.rs.core.Response createTicket(TicketsWebService.CreateTicketRequest req) Open a new ticket. The current authenticated user is recorded as the reporter. Per Decision 3 ofAUDIT_AS_WORKFLOW_RECCE.md, any authenticated principal may create a ticket; anonymous callers are denied at thePreAuthorizelayer.- Returns:
- 201 Created with the new
TicketValueObject(event log included so the caller can see the seeded OPENED event without a follow-up GET).
-
createTicketFromAccession
@POST @Path("/from-accession") @Consumes("application/json") @Produces("application/json") @PreAuthorize("isAuthenticated()") public jakarta.ws.rs.core.Response createTicketFromAccession(TicketsWebService.CreateTicketFromAccessionRequest req) Open a ticket over an experiment named by accession, resolving the accession to the dataset(s) in one call.The seam the curation store has as
POST /tickets/from-accession, minus the half that does not apply here. In the store that route imports the experiment from Gemma and then opens the ticket; against Gemma the experiment is already the source, so what is left is the resolution step — which is exactly the part a caller holding an accession cannot do without a second round trip.- Returns:
- 201 Created with the new ticket, targets attached.
-
updateTicket
@PUT @Path("/{id}") @Consumes("application/json") @Produces("application/json") @PreAuthorize("isAuthenticated()") public ResponseDataObject<TicketValueObject> updateTicket(@PathParam("id") Long id, TicketsWebService.UpdateTicketRequest req) Update mutable fields of a ticket. Any combination of the following may be supplied in the body:state— transition; appends a STATE_CHANGED / RESOLVED / CANCELLED / REOPENED event.assigneeId— assign or re-assign (use anullJSON value to clear); appends an ASSIGNED event.comment— append a COMMENTED event with the supplied free-form body.priority,dueDate,title,body,mode— metadata; no TicketEvent log entry, but aTicketMetadataChangedEventis appended to the governance audit trail with the list of changed fields in NOTE.
-
patchTicket
@PATCH @Path("/{id}") @Consumes("application/json") @Produces("application/json") @PreAuthorize("isAuthenticated()") public ResponseDataObject<TicketValueObject> patchTicket(@PathParam("id") Long id, TicketsWebService.UpdateTicketRequest req) PATCH alias forupdateTicket(Long, UpdateTicketRequest). Same semantics — Gemma's PUT has always behaved as a partial update (only fields explicitly set in the request body are touched), which is exactly what PATCH expresses semantically. The alias exists so callers (notably gemma-curation-ui) can use the verb that matches their intent without forcing every existing PUT consumer to switch. -
addTicketTarget
@POST @Path("/{id}/targets") @Consumes("application/json") @Produces("application/json") @PreAuthorize("isAuthenticated()") public ResponseDataObject<TicketsWebService.AddTargetsResult> addTicketTarget(@PathParam("id") Long id, TicketsWebService.AddTargetRequest req) Add a target to a ticket that is open to additions.The sibling of the PATCH below, which can only address a row that already exists and so cannot create membership. Until this route a ticket's targets were fixed at creation.
Refused with 409 when the ticket does not accept additions, when it is RESOLVED, or when the target is already on the ticket. The last is deliberately a 409 rather than a silent success: "it was already there" is something the caller wants to know, and it matches how a duplicate experiment tag is refused on
POST /annotations/datasets/{id}/annotations. -
removeTicketTarget
@DELETE @Path("/{id}/targets/{targetType}/{targetId}") @Produces("application/json") @PreAuthorize("isAuthenticated()") public jakarta.ws.rs.core.Response removeTicketTarget(@PathParam("id") Long id, @PathParam("targetType") TicketTargetType targetType, @PathParam("targetId") Long targetId) Remove a target from a ticket.On a curator scratchpad this is what finishing looks like: the ticket stays open indefinitely and the dataset leaves it (Paul, 2026-08-31). Addressed by
(targetType, targetId)rather than by row id, because the caller knows the experiment it is looking at, not the row id the server minted. -
updateTargetStatus
@PATCH @Path("/{id}/targets/{targetRowId}") @Consumes("application/json") @Produces("application/json") @PreAuthorize("isAuthenticated()") public ResponseDataObject<TicketValueObject> updateTargetStatus(@PathParam("id") Long id, @PathParam("targetRowId") Long targetRowId, TicketsWebService.UpdateTargetStatusRequest req) -
deleteTicket
@DELETE @Path("/{id}") @Produces("application/json") @PreAuthorize("isAuthenticated()") public jakarta.ws.rs.core.Response deleteTicket(@PathParam("id") Long id, @QueryParam("reason") @Nullable String reason) Soft-close a ticket: transition toTicketState.CANCELLEDand append a CANCELLED event. The row itself is NOT hard-deleted — the ticket and its event log remain queryable for audit (Decision 4 of the recce). Calling DELETE on an already-terminal ticket is a no-op but still returns 204. -
openTicketsForExpressionExperiment
Public hook forDatasetsWebServiceso the dataset-tickets route can delegate here without duplicating the Ticket→VO mapping logic. -
openTicketsForArrayDesign
Public hook forPlatformsWebServiceso the platform-tickets route can delegate here without duplicating the Ticket→VO mapping logic. -
openTicketSummariesForExpressionExperiments
public Map<Long, List<TicketSummaryForTargetValueObject>> openTicketSummariesForExpressionExperiments(Collection<Long> eeIds) Public hook forDatasetsWebService's bulkPOST /datasets/tickets: the same questionopenTicketsForExpressionExperiment(Long)answers for one dataset, asked about a page of them in one query.Only datasets that ARE on an open ticket get a key; an absent id is on none. Callers must have filtered the ids for readability first — this reads the ticket table, which carries no ACL of its own.
-
openTicketsForExpressionExperimentByCursor
public CursorPage<TicketValueObject> openTicketsForExpressionExperimentByCursor(Long eeId, @Nullable Cursor cursor, int limit) Cursor-mode counterpart toopenTicketsForExpressionExperiment(Long)(step 1p ofCURSOR_PAGINATION_STEP1_PLAN.md). Returns aCursorPageofTicketValueObjectso theDatasetsWebServicecan forward it directly throughResponders.paginateByCursor(CursorPage, String[]). -
openTicketsForArrayDesignByCursor
public CursorPage<TicketValueObject> openTicketsForArrayDesignByCursor(Long adId, @Nullable Cursor cursor, int limit) Cursor-mode counterpart toopenTicketsForArrayDesign(Long)(step 1s ofCURSOR_PAGINATION_STEP1_PLAN.md). Returns aCursorPageofTicketValueObjectso thePlatformsWebServicecan forward it directly throughResponders.paginateByCursor(CursorPage, String[]). MirrorsopenTicketsForExpressionExperimentByCursor(Long, ubic.gemma.persistence.util.Cursor, int)but bound toTicketTargetType.ARRAY_DESIGN.
-