A complete reference document covering the project summary, architecture, data model, and every REST endpoint exposed by the Ticket Tracking System.
- Project Summary
- Tech Stack
- Architecture Overview
- Data Model
- Configuration & Setup
- API Conventions
- Users API
- Projects API
- Tickets API
- Email Notifications
- Error Handling
- Testing
The Ticket Tracking System is a REST API for managing software-development style work items. It supports:
- Users — people who can be assigned to tickets (CRUD).
- Projects — containers for tickets, exposed read-only with per-status ticket counts.
- Tickets — work items with a title, description, status, project, and many-to-many assignees. Supports text + status filtering.
- Email notifications — whenever a ticket is updated or a user is assigned/unassigned, the assignee receives an HTML email via the Resend API. Dispatch is asynchronous and fire-and-forget; failures are logged, never surfaced to callers.
The codebase is intentionally small, layered (controller → service → repository), and uses JdbcTemplate directly instead of JPA — every SQL statement is visible in the repository classes.
| Layer | Choice |
|---|---|
| Language | Java 25 |
| Framework | Spring Boot 4.1.0 |
| Web | spring-boot-starter-web (Spring MVC) |
| Persistence | spring-boot-starter-jdbc (JdbcTemplate) |
| Validation | spring-boot-starter-validation (Jakarta Bean Validation) |
| Async | @EnableAsync for email dispatch |
| Database | PostgreSQL 16+ (with a custom ticket_status ENUM type) |
| Email Provider | Resend HTTP API |
| HTTP Client | java.net.http.HttpClient (JDK built-in) |
| Boilerplate | Lombok (@RequiredArgsConstructor) |
| Tests | JUnit 5 + Spring Boot Test (integration + service) |
| Build | Maven (./mvnw) |
| CI | GitHub Actions (runs ./mvnw verify against Postgres) |
HTTP request
│
▼
┌──────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────────┐
│ Controller │──▶ │ Service │──▶ │ Repository │──▶ │ PostgreSQL │
└──────────────┘ └─────────────┘ └──────────────┘ └────────────┘
│
│ (@Async, fire-and-forget)
▼
┌─────────────────────────┐ ┌────────────────┐
│ ResendAutomationService │──▶ │ Resend API │
└─────────────────────────┘ └────────────────┘
controller/— Pure HTTP shape: route mapping, request validation (@Valid), status codes. No business logic.service/— Business logic. All mutating methods are@Transactional; reads are@Transactional(readOnly = true).repository/— Plain JDBC viaJdbcTemplate. Native SQL, parameter-bound. No JPA, no entity manager.email/—ResendAutomationServicesends HTML emails asynchronously, uses the JDKHttpClient, escapes HTML, logs failures.exception/—GlobalExceptionHandler(@RestControllerAdvice) maps domain and framework exceptions to a singleErrorResponseJSON shape.dto/— Javarecordtypes for bothrequest/andresponse/payloads.model/— Domain records (User,Ticket) and theTicketStatusenum.config/—EmailConfig(HttpClient bean) andStringToTicketStatusConverter(query parameter binding).
- Records everywhere — DTOs and models are immutable records.
- No JPA — explicit SQL keeps the layer thin and easy to reason about.
- Postgres ENUM for status — the
tickets.statuscolumn is aticket_statusENUM (OPEN,IN_PROGRESS,CLOSED). The API exposes them lowercased / space-separated (open,in progress,closed) via@JsonValue/@JsonCreatoron the enum. - Race-free assignment —
INSERT ... ON CONFLICT DO NOTHINGon the junction table; the affected-rows count signals whether the row was actually inserted. - Async email —
@EnableAsyncon the application class;@AsynconsendTicketUpdatedEmail. Email failures never affect the HTTP response.
| Column | Type | Constraints |
|---|---|---|
| id | bigserial |
PRIMARY KEY |
| name | varchar(255) |
NOT NULL, char_length(name) >= 3 |
varchar(255) |
NOT NULL, UNIQUE |
| Column | Type | Constraints |
|---|---|---|
| id | bigserial |
PRIMARY KEY |
| name | varchar(255) |
NOT NULL, char_length(name) >= 3 |
| Column | Type | Constraints |
|---|---|---|
| id | bigserial |
PRIMARY KEY |
| title | varchar(255) |
NOT NULL |
| description | text |
nullable |
| project_id | bigint |
NOT NULL, FK → projects(id) |
| status | ticket_status |
NOT NULL — Postgres ENUM (OPEN, IN_PROGRESS, CLOSED) |
| creation_date | timestamp |
NOT NULL, DEFAULT CURRENT_TIMESTAMP |
| update_date | timestamp |
nullable, set on every UPDATE |
Indexes: idx_tickets_status, idx_tickets_project_id.
| Column | Type | Constraints |
|---|---|---|
| user_id | bigint |
NOT NULL, FK → users(id) ON DELETE CASCADE |
| ticket_id | bigint |
NOT NULL, FK → tickets(id) ON DELETE CASCADE |
Composite primary key: (user_id, ticket_id). Index: idx_user_ticket_ticket_id.
users 1 ─── * user_ticket * ─── 1 tickets * ─── 1 projects
A user can be assigned to many tickets and a ticket can have many assignees.
- Java 25 (Temurin recommended)
- PostgreSQL 16+
- A Resend account + API key
psql -U postgres -c "CREATE USER ticket_user WITH PASSWORD 'ticket_password';"
psql -U postgres -c "CREATE DATABASE ticket_tracker OWNER ticket_user;"
psql -U ticket_user -d ticket_tracker -f src/main/resources/schema.sqlCopy .env.example to .env and fill in:
| Variable | Default | Purpose |
|---|---|---|
DB_USER |
(none) | Postgres username |
DB_PASSWORD |
(none) | Postgres password |
DB_URL |
jdbc:postgresql://localhost:5432/ticket_tracker |
JDBC URL |
RESEND_API_KEY |
(none) | Resend API key |
RESEND_FROM_EMAIL |
Ticket Tracking <info.ticket@paiksa.com> |
"From" address used in outgoing mail |
export $(grep -v '^#' .env | xargs)
./mvnw spring-boot:runThe API listens on http://localhost:8080.
- Base path:
/api/v1 - Content type:
application/jsonfor both requests and responses. - IDs: all resource identifiers are
Long(64-bit integer). - Dates: ISO-8601
LocalDateTime, e.g.2026-06-17T12:00:00. - Validation: invalid request bodies return
400 Bad Requestwith avalidationErrorsmap. - Ticket status (wire format):
"open"↔ enumOPEN"in progress"↔ enumIN_PROGRESS"closed"↔ enumCLOSED
- Query parameters for
statusaccept lowercased, space-separated, or underscored forms (case-insensitive). E.g.open,IN_PROGRESS,in-progress.
GET /api/v1/users
Response — 200 OK
[
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
]GET /api/v1/users/{id}
Response — 200 OK
{ "id": 1, "name": "Alice", "email": "alice@example.com" }Errors
404 Not Found— user does not exist.
POST /api/v1/users
Request body
{ "name": "Alice", "email": "alice@example.com" }Validation
name: not blank, 3–255 chars.email: not blank, valid email format, max 255 chars.
Response — 201 Created
{ "id": 1, "name": "Alice", "email": "alice@example.com" }Errors
400 Bad Request— validation failure.409 Conflict— email already in use.
PUT /api/v1/users/{id}
Same body and validation as Create.
Response — 200 OK
{ "id": 1, "name": "Alice Doe", "email": "alice.doe@example.com" }Errors
400 Bad Request— validation failure.404 Not Found— user does not exist.409 Conflict— email belongs to another user.
DELETE /api/v1/users/{id}
Response — 204 No Content
Errors
404 Not Found— user does not exist.
Deleting a user automatically removes their rows in
user_ticket(cascade).
GET /api/v1/projects
Returns every project alongside ticket counts grouped by status. Projects with no tickets show zero counts.
Response — 200 OK
[
{
"id": 1,
"name": "Project Alpha",
"openTickets": 10,
"inProgressTickets": 5,
"closedTickets": 30
},
{
"id": 2,
"name": "Project Beta",
"openTickets": 0,
"inProgressTickets": 0,
"closedTickets": 0
}
]Projects are read-only via the API. They are inserted directly into the database (e.g. via seed scripts).
GET /api/v1/tickets?text={text}&status={status}
| Query | Type | Required | Description |
|---|---|---|---|
text |
string | no | Case-insensitive substring search over title and description. |
status |
enum | no | One of open, in progress, closed (also accepts in-progress, IN_PROGRESS, etc.). |
Both filters combine with AND.
Response — 200 OK
[
{
"id": 1,
"title": "Login button broken",
"description": "Clicking does nothing on Firefox",
"projectId": 1,
"status": "open",
"assignedUserIds": [1, 2],
"creationDate": "2026-06-15T09:00:00",
"updateDate": null
}
]Errors
400 Bad Request— unknown status value.
GET /api/v1/tickets/{id}
Response — 200 OK (same TicketResponse shape as above)
Errors
404 Not Found— ticket does not exist.
POST /api/v1/tickets
Request body
{
"title": "Login button is broken",
"description": "Clicking does nothing on Firefox",
"projectId": 1,
"status": "open"
}Validation
title: not blank, max 255 chars.description: nullable, max 5000 chars.projectId: required.status: required, one of the allowed values.
Response — 201 Created
{
"id": 42,
"title": "Login button is broken",
"description": "Clicking does nothing on Firefox",
"projectId": 1,
"status": "open",
"assignedUserIds": [],
"creationDate": "2026-06-17T12:00:00",
"updateDate": null
}Errors
400 Bad Request— validation failure.404 Not Found—projectIddoes not exist.
PUT /api/v1/tickets/{id}
Updates title, description, projectId, and status. Sends an email to every assignee describing the diff between the previous and the new ticket.
Request body — same shape and validation as Create.
Response — 200 OK
{
"ticket": {
"id": 42,
"title": "Login button is broken on Firefox 130",
"description": "Clicking does nothing on Firefox",
"projectId": 1,
"status": "in progress",
"assignedUserIds": [1, 2],
"creationDate": "2026-06-17T12:00:00",
"updateDate": "2026-06-17T13:30:00"
},
"emailNotificationsDispatched": true
}emailNotificationsDispatched is true when at least one assignee email was queued for delivery. The actual HTTP call to Resend happens asynchronously — any failure is logged and does not affect the response.
Errors
400 Bad Request— validation failure.404 Not Found— ticket orprojectIddoes not exist.
POST /api/v1/tickets/{ticketId}/assignees/{userId}
Adds a row to user_ticket. Sends the user an email saying they have been assigned.
Response — 200 OK — the updated TicketResponse.
Errors
400 Bad Request— user is already assigned to this ticket.404 Not Found— ticket or user does not exist.
DELETE /api/v1/tickets/{ticketId}/assignees/{userId}
Removes the assignment. Sends the user an email saying they have been removed.
Response — 200 OK — the updated TicketResponse.
Errors
404 Not Found— ticket or user does not exist, or the user was not assigned to this ticket.
Implemented by ResendAutomationService:
-
Triggered by three flows:
PUT /api/v1/tickets/{id}— every assignee receives an email with the diff.POST /api/v1/tickets/{ticketId}/assignees/{userId}— the newly-assigned user is notified.DELETE /api/v1/tickets/{ticketId}/assignees/{userId}— the unassigned user is notified.
-
Email body is an HTML template (escaped for safety) containing the ticket ID, title, status, who triggered the update (currently always
"System"), and a human-readable change summary. -
Transport: HTTP POST to
https://api.resend.com/emailswith a 10-second timeout and a 5-second connect timeout. -
Failure handling: non-2xx responses or exceptions are logged at WARN/ERROR. They never propagate to the controller.
All errors share the same envelope (ErrorResponse):
{
"timestamp": "2026-06-17T12:00:00",
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"path": "/api/v1/users",
"validationErrors": {
"email": "must be a well-formed email address"
}
}validationErrors is only populated for MethodArgumentNotValidException (Bean Validation failures); it is null otherwise.
| Exception | HTTP Status | Notes |
|---|---|---|
ResourceNotFoundException |
404 Not Found |
Unknown user/ticket/project ID. |
DuplicateEmailException |
409 Conflict |
Email already exists. |
IllegalArgumentException |
400 Bad Request |
E.g. assigning an already-assigned user, bad status. |
MethodArgumentNotValidException |
400 Bad Request |
Bean Validation errors → validationErrors map. |
MethodArgumentTypeMismatchException |
400 Bad Request |
Query/path parameter cannot be converted. |
HttpMessageNotReadableException |
400 Bad Request |
Malformed JSON in the request body. |
DataIntegrityViolationException |
409 Conflict |
DB constraint violation; cause is logged at WARN. |
Exception (fallback) |
500 Internal Server Error |
Unhandled exceptions; full stack is logged. |
./mvnw verifyThe integration tests require a running Postgres with the schema applied. CI runs the same command against a containerised Postgres on every push to main and every PR (see .github/workflows/ci.yml).
TicketTrackingSystemApplicationTests— context-loads sanity check.controller/UserControllerIntegrationTest— full HTTP slice for users.controller/ProjectControllerIntegrationTest— full HTTP slice for projects.controller/TicketControllerIntegrationTest— full HTTP slice for tickets, including assignment flows.service/UserServiceTest— unit tests forUserServiceagainst a mocked repository.service/TicketServiceTest— unit tests forTicketServiceagainst mocked repositories +ResendAutomationService(verifies emails are dispatched on update/assign/unassign).
| Method | Path | Status (success) | Auth | Body | Returns |
|---|---|---|---|---|---|
| GET | /api/v1/users |
200 | — | — | UserResponse[] |
| GET | /api/v1/users/{id} |
200 | — | — | UserResponse |
| POST | /api/v1/users |
201 | — | yes | UserResponse |
| PUT | /api/v1/users/{id} |
200 | — | yes | UserResponse |
| DELETE | /api/v1/users/{id} |
204 | — | — | — |
| GET | /api/v1/projects |
200 | — | — | ProjectSummaryResponse[] |
| GET | /api/v1/tickets?text=&status= |
200 | — | — | TicketResponse[] |
| GET | /api/v1/tickets/{id} |
200 | — | — | TicketResponse |
| POST | /api/v1/tickets |
201 | — | yes | TicketResponse |
| PUT | /api/v1/tickets/{id} |
200 | — | yes | TicketUpdateResponse |
| POST | /api/v1/tickets/{ticketId}/assignees/{userId} |
200 | — | — | TicketResponse |
| DELETE | /api/v1/tickets/{ticketId}/assignees/{userId} |
200 | — | — | TicketResponse |
The API is currently unauthenticated — there is no auth layer in the codebase. Add one before exposing this service publicly.
CreateUserRequest { name, email }
UpdateUserRequest { name, email }
CreateTicketRequest { title, description?, projectId, status }
UpdateTicketRequest { title, description?, projectId, status }
UserResponse { id, name, email }
ProjectSummaryResponse { id, name, openTickets, inProgressTickets, closedTickets }
TicketResponse { id, title, description, projectId, status,
assignedUserIds[], creationDate, updateDate }
TicketUpdateResponse { ticket: TicketResponse, emailNotificationsDispatched }
ErrorResponse { timestamp, status, error, message, path, validationErrors? }