Let agents move support tickets through their statuses
Java · Spring Boot · intermediate · modification
Agents can now move a ticket through its statuses from the inbox instead of the legacy admin console. `TicketStatus.canMoveTo` holds the allowed moves (with a test over all 16 pairs) and `TicketStatus.fromJson` binds only the exact status names, `Ticket.moveTo` applies one and keeps `resolvedAt` in step, and `TicketService.changeStatus` loads the ticket with a row lock, applies the move and writes the history row in one transaction. A move that is not allowed throws `InvalidTransitionException`, which answers 409 and rolls the transaction back. The response gains `resolvedAt`.
Helpdesk app on Spring Boot 3.3.5 (Java 21), Spring Data JPA with Hibernate 6.5 on PostgreSQL 16 at its default isolation (read committed); Hibernate maps `Instant` to `timestamptz` and `@Lock(PESSIMISTIC_WRITE)` to `SELECT ... FOR UPDATE`. Existing and unchanged: `CurrentAgent` (the signed-in agent of the current request, `agentId()` and `orgId()`; the security config requires authentication on every `/api/**` route), the `Clock` bean (`Clock.systemUTC()`), `TicketNotFoundException` (annotated `@ResponseStatus(NOT_FOUND)`) and `CreateTicketRequest`. Errors use Spring Boot's default error body with `server.error.include-message` at its default (`never`). `spring.jpa.open-in-view` is at its default; nothing in the request loads the ticket before the service call. Statuses used to be changed in the legacy admin console, which is switched off when this endpoint ships, so after it nothing else writes `tickets.status`. A migration that ships separately and is already applied added the nullable `resolved_at` column (backfilled for tickets already resolved or closed) and the `ticket_status_changes` table, with a foreign key to `tickets`. A status change touches one ticket row and inserts one history row, with no outside calls in the transaction. Ticket search lives in `TicketSearchController` (unchanged, out of scope).
Requirements
- New `POST /api/tickets/{id}/status` with the body `{"status": "<STATUS>"}` moves a ticket of the signed-in agent's organisation to another status and answers 200 with the ticket, in the same shape as `GET /api/tickets/{id}`. Any agent of the organisation may change any of its tickets.
- Allowed moves: `OPEN` to `PENDING` or `RESOLVED`; `PENDING` to `OPEN` or `RESOLVED`; `RESOLVED` to `OPEN` (a reopen) or `CLOSED`; nothing out of `CLOSED`. Any other move, including a move to the status the ticket already has, answers 409 and changes nothing.
- A ticket that does not exist or belongs to another organisation answers 404. A body without a `status`, a `status` that is not exactly one of the four upper-case names (a number, another case or surrounding spaces included), or a path id that is not a number answers 400.
- New field `resolvedAt`, returned by both endpoints: set to the current time when the ticket moves to `RESOLVED`, cleared when it moves back to `OPEN`, and left as it is on every other move (so a `CLOSED` ticket keeps the time it was resolved).
- Every applied move appends one row to `ticket_status_changes` (ticket, previous status, new status, the agent, the time) in the same transaction as the status change. A request answered 400, 404 or 409 writes no row.
- Requests for the same ticket that arrive at the same time behave as if they ran one after another: each is checked against the status the previous one left. When two agents resolve the same open ticket at once, one gets 200 and the other 409, and the history gets one row.
- The current time comes from the application's existing `Clock` bean, never from `Instant.now()`.
Files touched
- src/main/java/com/acme/helpdesk/ticket/TicketStatus.java
- src/main/java/com/acme/helpdesk/ticket/Ticket.java
- src/main/java/com/acme/helpdesk/ticket/InvalidTransitionException.java
- src/main/java/com/acme/helpdesk/ticket/TicketStatusChange.java
- src/main/java/com/acme/helpdesk/ticket/TicketStatusChangeRepository.java
- src/main/java/com/acme/helpdesk/ticket/ChangeStatusRequest.java
- src/main/java/com/acme/helpdesk/ticket/TicketRepository.java
- src/main/java/com/acme/helpdesk/ticket/TicketResponse.java
- src/main/java/com/acme/helpdesk/ticket/TicketService.java
- src/main/java/com/acme/helpdesk/ticket/TicketController.java
- src/test/java/com/acme/helpdesk/ticket/TicketStatusTest.java
--- src/main/java/com/acme/helpdesk/ticket/TicketStatus.java
package com.acme.helpdesk.ticket;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
public enum TicketStatus {
PENDING,
RESOLVED,
- CLOSED
+ CLOSED;
+
+ /** Request bodies must name a status exactly; Jackson's default enum binding also accepts ordinals. */
+ @JsonCreator
+ public static TicketStatus fromJson(String value) {
+ for (TicketStatus status : values()) {
+ if (status.name().equals(value)) {
+ return status;
+ }
+ }
+ throw new IllegalArgumentException("unknown ticket status");
+ }
+
+ public boolean canMoveTo(TicketStatus next) {
+ return switch (this) {
+ case OPEN -> next == PENDING || next == RESOLVED;
+ case PENDING -> next == OPEN || next == RESOLVED;
+ case RESOLVED -> next == OPEN || next == CLOSED;
+ case CLOSED -> false;
+ };
+ }