Class TicketsWebService

java.lang.Object
ubic.gemma.rest.TicketsWebService

@Service @Path("/tickets") public class TicketsWebService extends Object
RESTful interface for curation tickets (Phase B-2 of 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
  • Constructor Details

  • 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.md adds opt-in keyset (cursor) pagination alongside the legacy offset path. Legacy mode keeps the t.updatedAt desc ordering used for human-readable dashboards; cursor mode forces a single-component ascending id sort 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. Sums TicketService.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 /scratchpad segment beats /{id}: JAX-RS sorts candidate methods by literal character count before template count (JSR-370 §3.7.2), so this never reaches getTicket(Long) to be parsed as a Long. The sibling /mine, /summary and /summary/me routes 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 from GET /tickets?query= in one respect that matters — a hit carries targetCount, not the targets array, 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.md adds opt-in keyset (cursor) pagination alongside the legacy unpaginated path. Legacy mode (no cursor) returns the full event list as a ResponseDataObject (occurredAt-asc ordering); cursor mode pages by ascending id (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 of AUDIT_AS_WORKFLOW_RECCE.md, any authenticated principal may create a ticket; anonymous callers are denied at the PreAuthorize layer.
      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 a null JSON 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 a TicketMetadataChangedEvent is 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 for updateTicket(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 to TicketState.CANCELLED and 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 List<TicketValueObject> openTicketsForExpressionExperiment(Long eeId)
      Public hook for DatasetsWebService so the dataset-tickets route can delegate here without duplicating the Ticket→VO mapping logic.
    • openTicketsForArrayDesign

      public List<TicketValueObject> openTicketsForArrayDesign(Long adId)
      Public hook for PlatformsWebService so 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 for DatasetsWebService's bulk POST /datasets/tickets: the same question openTicketsForExpressionExperiment(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 to openTicketsForExpressionExperiment(Long) (step 1p of CURSOR_PAGINATION_STEP1_PLAN.md). Returns a CursorPage of TicketValueObject so the DatasetsWebService can forward it directly through Responders.paginateByCursor(CursorPage, String[]).
    • openTicketsForArrayDesignByCursor

      public CursorPage<TicketValueObject> openTicketsForArrayDesignByCursor(Long adId, @Nullable Cursor cursor, int limit)