Class TicketServiceImpl
- All Implemented Interfaces:
BaseImmutableService<Ticket>, BaseReadOnlyService<Ticket>, BaseService<Ticket>, TicketService
TicketService. Each mutating method writes to
BOTH log streams (Decision 6 of AUDIT_AS_WORKFLOW_RECCE.md):
- The domain-workflow
TicketEventstream — append-only, with the ticket-shapedTicketEventTypeenum carrying the action. - The governance
AuditTrailstream inherited fromAbstractAuditable, populated via@Audited-annotated event types (TicketOpenedEvent,TicketAssignedEvent,CommentedEvent,TicketStateChangedEvent).
openTicket the @Audited aspect can't target the result
(it inspects method args), so the TicketOpenedEvent row is written
inline via AuditTrailService after the ticket is created.- Author:
- paul
-
Nested Class Summary
Nested classes/interfaces inherited from interface TicketService
TicketService.TargetAddition -
Field Summary
Fields inherited from class AbstractService
log -
Constructor Summary
Constructors -
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 newResult, String newReason, boolean reasonProvided, Contact actor) Record aScreeningResulton one target (by its row id).updateTargetStatus(Ticket ticket, Long targetId, TicketTargetStatus newStatus, Contact actor) Methods inherited from class AbstractService
countAll, create, create, ensureInSession, ensureInSession, find, findOrCreate, findOrFail, getElementClass, load, load, loadAll, loadOrFail, loadOrFail, loadOrFail, loadOrFail, loadOrFail, loadOrFail, remove, remove, save, save, streamAll, streamAll, update, updateMethods inherited from class Object
clone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, waitMethods 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
-
Constructor Details
-
TicketServiceImpl
-
-
Method Details
-
openTicket
@Transactional public Ticket openTicket(Contact reporter, TicketType type, String title, Collection<TicketTarget> targets) Description copied from interface:TicketServiceCreate a new ticket and seed its event log with a singleTicketEventType.OPENEDevent.- Specified by:
openTicketin interfaceTicketService- 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
Description copied from interface:TicketServiceReturn 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 toTicketService.openTicket(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.- Specified by:
getOrCreateScratchpadin interfaceTicketService- Parameters:
curator- the scratchpad's owner; recorded as the ticket's reporter
-
assign
@Transactional @Audited(value=TicketAssignedEvent.class, messageSpel="'Assignee ' + (#assignee != null ? '-> ' + #assignee.getId() : 'cleared')") public Ticket assign(Ticket ticket, Contact actor, @Nullable Contact assignee) Description copied from interface:TicketServiceAssign (or re-assign) the ticket. Appends anTicketEventType.ASSIGNEDevent. Passassignee == nullto clear an assignment (still appends an ASSIGNED event with a null target).- Specified by:
assignin interfaceTicketService
-
addComment
@Transactional @Audited(value=CommentedEvent.class, message="Ticket comment added") public Ticket addComment(Ticket ticket, Contact actor, @Nullable String payload) Description copied from interface:TicketServiceAppend a comment event (free-form payload). Doesn't change ticket state; doesn't change theupdatedAtfield beyond bumping the audit timestamp.- Specified by:
addCommentin interfaceTicketService
-
transition
@Transactional public Ticket transition(Ticket ticket, TicketState newState, Contact actor, @Nullable String reason) Description copied from interface:TicketServiceTransition 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.- Specified by:
transitionin interfaceTicketService
-
updateMetadata
@Transactional @Audited(value=TicketMetadataChangedEvent.class, messageSpel="'changed: ' + #changedFields") public Ticket updateMetadata(Ticket ticket, String changedFields) Description copied from interface:TicketServicePersist 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
TicketService.transition(Ticket, TicketState, Contact, String),TicketService.assign(Ticket, Contact, Contact),TicketService.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.- Specified by:
updateMetadatain interfaceTicketService
-
addTarget
@Transactional public TicketService.TargetAddition addTarget(Ticket ticket, TicketTargetType targetType, Long targetId, Contact actor) Description copied from interface:TicketServiceAdd a target to a ticket that was already opened.Until this existed a ticket's targets were fixed at
TicketService.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.- Specified by:
addTargetin interfaceTicketService- Returns:
- the saved ticket, and whether this call is what put the target on it
- the ticket's
-
removeTarget
@Transactional public TicketTargetStatus removeTarget(Ticket ticket, TicketTargetType targetType, Long targetId, Contact actor) Description copied from interface:TicketServiceRemove 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
TicketService.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.- Specified by:
removeTargetin interfaceTicketService- Returns:
- the status the removed target had, or
nullif it was not on the ticket
-
updateTargetStatus
@Transactional public Ticket updateTargetStatus(Ticket ticket, Long targetId, TicketTargetStatus newStatus, Contact actor) - Specified by:
updateTargetStatusin interfaceTicketService
-
updateTargetScreeningResult
@Transactional public Ticket updateTargetScreeningResult(Ticket ticket, Long targetId, @Nullable ScreeningResult newResult, @Nullable String newReason, boolean reasonProvided, Contact actor) Description copied from interface:TicketServiceRecord aScreeningResulton one target (by its row id). Uncoupled fromTicketService.updateTargetStatus(Ticket, Long, TicketTargetStatus, Contact): the two are set independently. No-op when unchanged; writes a SCREENING_RESULT_CHANGED ticket event when it changes.- Specified by:
updateTargetScreeningResultin interfaceTicketService
-
loadValueObject
@Transactional(readOnly=true) public TicketValueObject loadValueObject(Long id, boolean includeEvents) Description copied from interface:TicketServiceLoad 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.- Specified by:
loadValueObjectin interfaceTicketService
-
findOpenForTarget
@Transactional(readOnly=true) public List<Ticket> findOpenForTarget(TicketTargetType targetType, Long targetId) - Specified by:
findOpenForTargetin interfaceTicketService- See Also:
-
findOpenSummariesForTargets
@Transactional(readOnly=true) public Map<Long, List<TicketSummaryForTargetValueObject>> findOpenSummariesForTargets(TicketTargetType targetType, Collection<Long> targetIds) - Specified by:
findOpenSummariesForTargetsin interfaceTicketService- See Also:
-
findAssignedTo
- Specified by:
findAssignedToin interfaceTicketService- See Also:
-
findTickets
@Transactional(readOnly=true) public List<Ticket> findTickets(boolean openOnly, @Nullable Long assigneeId, @Nullable TicketPriority priority, int offset, int limit) - Specified by:
findTicketsin interfaceTicketService- See Also:
-
findTickets
@Transactional(readOnly=true) public 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) - Specified by:
findTicketsin interfaceTicketService- See Also:
-
countTickets
@Transactional(readOnly=true) public long countTickets(boolean openOnly, @Nullable Long assigneeId, @Nullable TicketPriority priority) - Specified by:
countTicketsin interfaceTicketService- See Also:
-
countTickets
@Transactional(readOnly=true) public long countTickets(boolean openOnly, @Nullable Long assigneeId, @Nullable TicketPriority priority, @Nullable TicketType type, @Nullable TicketState state, @Nullable TicketTargetType targetType, @Nullable Date updatedSince) - Specified by:
countTicketsin interfaceTicketService- See Also:
-
findTicketsByCursor
@Transactional(readOnly=true) public CursorPage<Ticket> findTicketsByCursor(boolean openOnly, @Nullable Long assigneeId, @Nullable TicketPriority priority, @Nullable Cursor cursor, int limit) - Specified by:
findTicketsByCursorin interfaceTicketService- See Also:
-
findTicketsByCursor
@Transactional(readOnly=true) public 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) - Specified by:
findTicketsByCursorin interfaceTicketService- See Also:
-
findOpenForTargetByCursor
@Transactional(readOnly=true) public CursorPage<Ticket> findOpenForTargetByCursor(TicketTargetType targetType, Long targetId, @Nullable Cursor cursor, int limit) - Specified by:
findOpenForTargetByCursorin interfaceTicketService- See Also:
-
findEventsByCursor
@Transactional(readOnly=true) public CursorPage<TicketEvent> findEventsByCursor(Ticket ticket, @Nullable Cursor cursor, int limit) - Specified by:
findEventsByCursorin interfaceTicketService- See Also:
-
countOpenByType
- Specified by:
countOpenByTypein interfaceTicketService- See Also:
-
countOpen
@Transactional(readOnly=true) public long countOpen()- Specified by:
countOpenin interfaceTicketService- See Also:
-
findOldestOpenCreatedAt
- Specified by:
findOldestOpenCreatedAtin interfaceTicketService- See Also:
-
searchTickets
@Transactional(readOnly=true) public List<TicketSearchHitValueObject> searchTickets(String query, boolean openOnly, @Nullable Long callerContactId, int limit) Description copied from interface:TicketServiceFind 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.- Specified by:
searchTicketsin interfaceTicketService- 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
-