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

Files touched

--- 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;
+        };
+    }

Review this PR

Java practice