Interface TicketService
- All Superinterfaces:
BaseImmutableService<Ticket>, BaseReadOnlyService<Ticket>, BaseService<Ticket>
- All Known Implementing Classes:
TicketServiceImpl
TicketEvent to
the ticket's event log; persistence is via JPA cascade.- Author:
- paul
-
Nested Class Summary
Nested ClassesModifier and TypeInterfaceDescriptionstatic classWhataddTarget(Ticket, TicketTargetType, Long, Contact)did: the saved ticket, and whether the target was new to it. -
Method Summary
Modifier and TypeMethodDescriptionaddComment(Ticket ticket, Contact actor, String payload) Append a comment event (free-form payload).addTarget(Ticket ticket, TicketTargetType targetType, Long targetId, Contact actor) Add a target to a ticket that was already opened.Assign (or re-assign) the ticket.longlongcountTickets(boolean openOnly, Long assigneeId, TicketPriority priority) longcountTickets(boolean openOnly, Long assigneeId, TicketPriority priority, TicketType type, TicketState state, TicketTargetType targetType, Date updatedSince) findAssignedTo(Contact assignee) findEventsByCursor(Ticket ticket, Cursor cursor, int limit) findOpenForTarget(TicketTargetType targetType, Long targetId) findOpenForTargetByCursor(TicketTargetType targetType, Long targetId, Cursor cursor, int limit) findOpenSummariesForTargets(TicketTargetType targetType, Collection<Long> targetIds) findTickets(boolean openOnly, Long assigneeId, TicketPriority priority, int offset, int limit) findTickets(boolean openOnly, Long assigneeId, TicketPriority priority, TicketType type, TicketState state, TicketTargetType targetType, Date updatedSince, int offset, int limit) findTicketsByCursor(boolean openOnly, Long assigneeId, TicketPriority priority, TicketType type, TicketState state, TicketTargetType targetType, Date updatedSince, Cursor cursor, int limit) findTicketsByCursor(boolean openOnly, Long assigneeId, TicketPriority priority, Cursor cursor, int limit) getOrCreateScratchpad(Contact curator) Return the curator's scratchpad, provisioning it on first call.loadValueObject(Long id, boolean includeEvents) Load a ticket and project it to aTicketValueObjectinside the same transaction, force-initializing thereporter+assignee+targets(andevents+ each event'sactorwhenincludeEvents=true) so the projection doesn't raiseLazyInitializationExceptiononce the transaction ends and the JAX-RS handler reads the returned VO.openTicket(Contact reporter, TicketType type, String title, Collection<TicketTarget> targets) Create a new ticket and seed its event log with a singleTicketEventType.OPENEDevent.removeTarget(Ticket ticket, TicketTargetType targetType, Long targetId, Contact actor) Remove a target from a ticket.searchTickets(String query, boolean openOnly, Long callerContactId, int limit) Find tickets a curator could be meaning when they type into a ticket picker — backsGET /tickets/search.transition(Ticket ticket, TicketState newState, Contact actor, String reason) Transition the ticket to a new state.updateMetadata(Ticket ticket, String changedFields) Persist metadata-only edits (priority, dueDate, title, body, mode, etc.) to a ticket.updateTargetScreeningResult(Ticket ticket, Long targetId, ScreeningResult screeningResult, String reason, boolean reasonProvided, Contact actor) Record aScreeningResulton one target (by its row id).updateTargetStatus(Ticket ticket, Long targetId, TicketTargetStatus newStatus, Contact actor) Methods inherited from interface BaseImmutableService
create, create, findOrCreate, remove, removeMethods inherited from interface BaseReadOnlyService
countAll, find, findOrFail, getElementClass, load, load, loadAll, loadOrFail, loadOrFail, loadOrFail, loadOrFail, loadOrFail, loadOrFail, streamAll, streamAllMethods inherited from interface BaseService
save, save, update, update
-
Method Details
-
openTicket
Ticket openTicket(Contact reporter, TicketType type, String title, Collection<TicketTarget> targets) Create a new ticket and seed its event log with a singleTicketEventType.OPENEDevent.- Parameters:
reporter- who is creating the ticket (must be non-null)type- domain categorytitle- short human title; mandatorytargets- one or more targets; must contain at least one entry
-
getOrCreateScratchpad
Return the curator's scratchpad, provisioning it on first call.A scratchpad is a
TicketType.SCRATCHPADticket kept open indefinitely, holding whatever the curator is currently looking at; finishing with a dataset means REMOVING it from the scratchpad, not resolving the ticket (Paul, 2026-08-31). It is created withacceptsTargets = true, because a scratchpad nothing can be added to is inert, and with no targets, which is why this does not delegate toopenTicket(Contact, TicketType, String, Collection)(that method requires at least one).Identified by
type == SCRATCHPADandreporter == curator, with no state clause: a cancelled scratchpad is still the curator's, and comes back as-is for them to reopen through the normal state transition rather than being superseded by a fresh one.🛑 Duplicate prevention is query-then-create inside one transaction, which is not a guarantee. Nothing in the schema forbids a second row, so two first-calls that both run the SELECT before either commits will both insert. What IS guaranteed is that the identity never splits afterwards:
TicketDao.findScratchpad(Contact)orders byidascending and takes one row, so every later call — this one included — returns the same ticket forever. A stray duplicate is an orphan row visible inGET /tickets?type=SCRATCHPADand reachable by id, not a scratchpad that flips between two identities. Closing the window properly needs a unique index, which needs a migration.The returned ticket has its lazy fields initialized for
TicketValueObject.from(Ticket, boolean), events included, so the REST layer can project it after the transaction ends.- Parameters:
curator- the scratchpad's owner; recorded as the ticket's reporter
-
assign
Assign (or re-assign) the ticket. Appends anTicketEventType.ASSIGNEDevent. Passassignee == nullto clear an assignment (still appends an ASSIGNED event with a null target). -
addComment
-
transition
Transition the ticket to a new state. Appends aTicketEventType.STATE_CHANGEDevent (or one of the terminal-state aliases — RESOLVED / CANCELLED / REOPENED — when the new state matches the corresponding terminal). BumpsupdatedAt. -
updateMetadata
Persist metadata-only edits (priority, dueDate, title, body, mode, etc.) to a ticket. The caller mutates theTicketarg in place, then passes a comma-separated list of changed field names so the audit trail row's NOTE column documents what changed.Unlike
transition(Ticket, TicketState, Contact, String),assign(Ticket, Contact, Contact),addComment(Ticket, Contact, String)— which append rows to BOTH log streams — metadata edits write to the governanceAuditTrailstream ONLY (Decision 4 ofAUDIT_AS_WORKFLOW_RECCE.md: "no TicketEvent log spam for fact-of-update edits"). BumpsupdatedAt. -
addTarget
TicketService.TargetAddition addTarget(Ticket ticket, TicketTargetType targetType, Long targetId, Contact actor) Add a target to a ticket that was already opened.Until this existed a ticket's targets were fixed at
openTicket(Contact, TicketType, String, Collection): the other target methods only modify rows that are already there. The motivating case is a curator scratchpad — a ticket someone keeps adding experiments to as they meet them.Two conditions, both refused with
IllegalStateException:- the ticket's
acceptsTargetsflag must be set. It is false by default and false on every ticket predating the flag, so an agent-created ticket keeps the fixed batch it was opened for unless someone deliberately opens it up. - the ticket must not be
TicketState.RESOLVED, whatever the flag says, so a finished ticket cannot quietly grow new work. The flag is not rewritten — reopening the ticket makes it effective again.
TicketService.TargetAddition.isAdded()is false.- Returns:
- the saved ticket, and whether this call is what put the target on it
- the ticket's
-
removeTarget
@Nullable TicketTargetStatus removeTarget(Ticket ticket, TicketTargetType targetType, Long targetId, Contact actor) Remove a target from a ticket.On a curator scratchpad this is what finishing with a dataset looks like — the ticket stays open and the dataset leaves it — so this is the counterpart of
addTarget(Ticket, TicketTargetType, Long, Contact)rather than an afterthought.Idempotent: removing a target the ticket does not have returns null rather than throwing, since the caller has already reached the state it asked for. A terminal ticket (
RESOLVED/CANCELLED) refuses the change.Removing a target whose status is past
NOT_DONEis permitted — a scratchpad's rows are all NOT_DONE and refusing would make the common case pay for the rare one — so the removed status is returned and the caller decides what to say about it.- Returns:
- the status the removed target had, or
nullif it was not on the ticket
-
updateTargetStatus
Ticket updateTargetStatus(Ticket ticket, Long targetId, TicketTargetStatus newStatus, Contact actor) -
updateTargetScreeningResult
Ticket updateTargetScreeningResult(Ticket ticket, Long targetId, @Nullable ScreeningResult screeningResult, @Nullable String reason, boolean reasonProvided, Contact actor) Record aScreeningResulton one target (by its row id). Uncoupled fromupdateTargetStatus(Ticket, Long, TicketTargetStatus, Contact): the two are set independently. No-op when unchanged; writes a SCREENING_RESULT_CHANGED ticket event when it changes. -
loadValueObject
Load a ticket and project it to aTicketValueObjectinside the same transaction, force-initializing thereporter+assignee+targets(andevents+ each event'sactorwhenincludeEvents=true) so the projection doesn't raiseLazyInitializationExceptiononce the transaction ends and the JAX-RS handler reads the returned VO.Returns
nullwhen no ticket with that id exists; the REST surface turns that into a 404. -
findOpenForTarget
- See Also:
-
findOpenSummariesForTargets
Map<Long, List<TicketSummaryForTargetValueObject>> findOpenSummariesForTargets(TicketTargetType targetType, Collection<Long> targetIds) - See Also:
-
findAssignedTo
-
findTickets
List<Ticket> findTickets(boolean openOnly, @Nullable Long assigneeId, @Nullable TicketPriority priority, int offset, int limit) - See Also:
-
findTickets
List<Ticket> findTickets(boolean openOnly, @Nullable Long assigneeId, @Nullable TicketPriority priority, @Nullable TicketType type, @Nullable TicketState state, @Nullable TicketTargetType targetType, @Nullable Date updatedSince, int offset, int limit) - See Also:
-
countTickets
- See Also:
-
countTickets
long countTickets(boolean openOnly, @Nullable Long assigneeId, @Nullable TicketPriority priority, @Nullable TicketType type, @Nullable TicketState state, @Nullable TicketTargetType targetType, @Nullable Date updatedSince) - See Also:
-
findTicketsByCursor
CursorPage<Ticket> findTicketsByCursor(boolean openOnly, @Nullable Long assigneeId, @Nullable TicketPriority priority, @Nullable Cursor cursor, int limit) - See Also:
-
findTicketsByCursor
CursorPage<Ticket> findTicketsByCursor(boolean openOnly, @Nullable Long assigneeId, @Nullable TicketPriority priority, @Nullable TicketType type, @Nullable TicketState state, @Nullable TicketTargetType targetType, @Nullable Date updatedSince, @Nullable Cursor cursor, int limit) - See Also:
-
findOpenForTargetByCursor
CursorPage<Ticket> findOpenForTargetByCursor(TicketTargetType targetType, Long targetId, @Nullable Cursor cursor, int limit) - See Also:
-
findEventsByCursor
- See Also:
-
countOpenByType
Map<TicketType, Long> countOpenByType()- See Also:
-
countOpen
long countOpen()- See Also:
-
findOldestOpenCreatedAt
- See Also:
-
searchTickets
List<TicketSearchHitValueObject> searchTickets(String query, boolean openOnly, @Nullable Long callerContactId, int limit) Find tickets a curator could be meaning when they type into a ticket picker — backsGET /tickets/search. Exists because a flat dropdown of every open ticket stops being usable well before the corpus does.querymatches EITHER a ticket id typed verbatim (digits and nothing else:"6"is ticket 6,"6 samples"is title text) OR a case-insensitive substring of the title — a curator has whichever of the two is to hand. An id that parses but names no ticket simply contributes no hit; it is not an error, and the caller should not turn it into a 404.Hits come back exact-id-first, then by
updatedAtdescending, truncated tolimit. Each is aTicketSearchHitValueObject, whosetargetCountis counted in SQL — noTicketTargetrow is loaded.- Parameters:
query- id or title fragment; blank yields no hitsopenOnly- restrict to OPEN/IN_PROGRESS. The REST default is true: work is rarely added to a closed ticketcallerContactId- the calling curator's contact id, or null when anonymous. Their ownTicketType.SCRATCHPADtickets are offered; nobody else's arelimit- maximum hits; must be greater than zero
-