diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..522c44f --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Database connection +DB_URL=jdbc:postgresql://localhost:5432/ticket_tracker +DB_USER=ticket_user +DB_PASSWORD=change_me + +# Resend email API +RESEND_API_KEY=your_resend_api_key_here diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68db75a..71688c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,27 @@ jobs: build: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ticket_user + POSTGRES_PASSWORD: ticket_password + POSTGRES_DB: ticket_tracker + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U ticket_user -d ticket_tracker" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + RESEND_API_KEY: dummy-ci-key + DB_URL: jdbc:postgresql://localhost:5432/ticket_tracker + DB_USER: ticket_user + DB_PASSWORD: ticket_password + steps: - uses: actions/checkout@v4 @@ -19,4 +40,4 @@ jobs: java-version: '25' cache: maven - - run: mvn clean verify \ No newline at end of file + - run: mvn clean verify diff --git a/.gitignore b/.gitignore index 1ff575d..d3843b0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,8 @@ build/ ### System Files ### .DS_Store Thumbs.db + +### Environment ### +.env +.env.local +!.env.example diff --git a/API.md b/API.md new file mode 100644 index 0000000..29f1a4b --- /dev/null +++ b/API.md @@ -0,0 +1,592 @@ +# Ticket Tracking System — API & Project Reference + +A complete reference document covering the project summary, architecture, data model, and every REST endpoint exposed by the Ticket Tracking System. + +--- + +## Table of Contents + +1. [Project Summary](#1-project-summary) +2. [Tech Stack](#2-tech-stack) +3. [Architecture Overview](#3-architecture-overview) +4. [Data Model](#4-data-model) +5. [Configuration & Setup](#5-configuration--setup) +6. [API Conventions](#6-api-conventions) +7. [Users API](#7-users-api) +8. [Projects API](#8-projects-api) +9. [Tickets API](#9-tickets-api) +10. [Email Notifications](#10-email-notifications) +11. [Error Handling](#11-error-handling) +12. [Testing](#12-testing) + +--- + +## 1. Project Summary + +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. + +--- + +## 2. Tech Stack + +| 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) | + +--- + +## 3. Architecture Overview + +``` +HTTP request + │ + ▼ +┌──────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ +│ Controller │──▶ │ Service │──▶ │ Repository │──▶ │ PostgreSQL │ +└──────────────┘ └─────────────┘ └──────────────┘ └────────────┘ + │ + │ (@Async, fire-and-forget) + ▼ + ┌─────────────────────────┐ ┌────────────────┐ + │ ResendAutomationService │──▶ │ Resend API │ + └─────────────────────────┘ └────────────────┘ +``` + +### Layers + +- **`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 via `JdbcTemplate`. Native SQL, parameter-bound. No JPA, no entity manager. +- **`email/`** — `ResendAutomationService` sends HTML emails asynchronously, uses the JDK `HttpClient`, escapes HTML, logs failures. +- **`exception/`** — `GlobalExceptionHandler` (`@RestControllerAdvice`) maps domain and framework exceptions to a single `ErrorResponse` JSON shape. +- **`dto/`** — Java `record` types for both `request/` and `response/` payloads. +- **`model/`** — Domain records (`User`, `Ticket`) and the `TicketStatus` enum. +- **`config/`** — `EmailConfig` (HttpClient bean) and `StringToTicketStatusConverter` (query parameter binding). + +### Key Design Choices + +- **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.status` column is a `ticket_status` ENUM (`OPEN`, `IN_PROGRESS`, `CLOSED`). The API exposes them lowercased / space-separated (`open`, `in progress`, `closed`) via `@JsonValue` / `@JsonCreator` on the enum. +- **Race-free assignment** — `INSERT ... ON CONFLICT DO NOTHING` on the junction table; the affected-rows count signals whether the row was actually inserted. +- **Async email** — `@EnableAsync` on the application class; `@Async` on `sendTicketUpdatedEmail`. Email failures never affect the HTTP response. + +--- + +## 4. Data Model + +### Tables + +#### `users` +| Column | Type | Constraints | +|--------|----------------|----------------------------------------------| +| id | `bigserial` | PRIMARY KEY | +| name | `varchar(255)` | NOT NULL, `char_length(name) >= 3` | +| email | `varchar(255)` | NOT NULL, UNIQUE | + +#### `projects` +| Column | Type | Constraints | +|--------|----------------|----------------------------------------------| +| id | `bigserial` | PRIMARY KEY | +| name | `varchar(255)` | NOT NULL, `char_length(name) >= 3` | + +#### `tickets` +| 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`. + +#### `user_ticket` (junction) +| 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`. + +### Entity Relationships + +``` +users 1 ─── * user_ticket * ─── 1 tickets * ─── 1 projects +``` + +A user can be assigned to many tickets and a ticket can have many assignees. + +--- + +## 5. Configuration & Setup + +### Prerequisites + +- Java 25 (Temurin recommended) +- PostgreSQL 16+ +- A Resend account + API key + +### Database + +```bash +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.sql +``` + +### Environment Variables + +Copy `.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 ` | "From" address used in outgoing mail | + +### Running the App + +```bash +export $(grep -v '^#' .env | xargs) +./mvnw spring-boot:run +``` + +The API listens on **`http://localhost:8080`**. + +--- + +## 6. API Conventions + +- **Base path:** `/api/v1` +- **Content type:** `application/json` for 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 Request` with a `validationErrors` map. +- **Ticket status (wire format):** + - `"open"` ↔ enum `OPEN` + - `"in progress"` ↔ enum `IN_PROGRESS` + - `"closed"` ↔ enum `CLOSED` +- Query parameters for `status` accept lowercased, space-separated, or underscored forms (case-insensitive). E.g. `open`, `IN_PROGRESS`, `in-progress`. + +--- + +## 7. Users API + +### 7.1 List users + +``` +GET /api/v1/users +``` + +**Response — 200 OK** +```json +[ + { "id": 1, "name": "Alice", "email": "alice@example.com" }, + { "id": 2, "name": "Bob", "email": "bob@example.com" } +] +``` + +--- + +### 7.2 Get user by ID + +``` +GET /api/v1/users/{id} +``` + +**Response — 200 OK** +```json +{ "id": 1, "name": "Alice", "email": "alice@example.com" } +``` + +**Errors** +- `404 Not Found` — user does not exist. + +--- + +### 7.3 Create user + +``` +POST /api/v1/users +``` + +**Request body** +```json +{ "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** +```json +{ "id": 1, "name": "Alice", "email": "alice@example.com" } +``` + +**Errors** +- `400 Bad Request` — validation failure. +- `409 Conflict` — email already in use. + +--- + +### 7.4 Update user + +``` +PUT /api/v1/users/{id} +``` + +Same body and validation as Create. + +**Response — 200 OK** +```json +{ "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. + +--- + +### 7.5 Delete 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). + +--- + +## 8. Projects API + +### 8.1 List projects with ticket counts + +``` +GET /api/v1/projects +``` + +Returns every project alongside ticket counts grouped by status. Projects with no tickets show zero counts. + +**Response — 200 OK** +```json +[ + { + "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). + +--- + +## 9. Tickets API + +### 9.1 List / search tickets + +``` +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** +```json +[ + { + "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. + +--- + +### 9.2 Get ticket by ID + +``` +GET /api/v1/tickets/{id} +``` + +**Response — 200 OK** (same `TicketResponse` shape as above) + +**Errors** +- `404 Not Found` — ticket does not exist. + +--- + +### 9.3 Create ticket + +``` +POST /api/v1/tickets +``` + +**Request body** +```json +{ + "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** +```json +{ + "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` — `projectId` does not exist. + +--- + +### 9.4 Update ticket + +``` +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** +```json +{ + "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 or `projectId` does not exist. + +--- + +### 9.5 Assign user to ticket + +``` +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. + +--- + +### 9.6 Unassign user from ticket + +``` +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. + +--- + +## 10. Email Notifications + +Implemented by `ResendAutomationService`: + +- Triggered by **three** flows: + 1. `PUT /api/v1/tickets/{id}` — every assignee receives an email with the diff. + 2. `POST /api/v1/tickets/{ticketId}/assignees/{userId}` — the newly-assigned user is notified. + 3. `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/emails` with 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. + +--- + +## 11. Error Handling + +All errors share the same envelope (`ErrorResponse`): + +```json +{ + "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. + +### Status-code mapping (`GlobalExceptionHandler`) + +| 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. | + +--- + +## 12. Testing + +```bash +./mvnw verify +``` + +The 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`). + +### Test inventory + +- `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 for `UserService` against a mocked repository. +- `service/TicketServiceTest` — unit tests for `TicketService` against mocked repositories + `ResendAutomationService` (verifies emails are dispatched on update/assign/unassign). + +--- + +## Appendix A — Endpoint Cheat Sheet + +| 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. + +## Appendix B — DTO Reference + +### Requests + +```text +CreateUserRequest { name, email } +UpdateUserRequest { name, email } +CreateTicketRequest { title, description?, projectId, status } +UpdateTicketRequest { title, description?, projectId, status } +``` + +### Responses + +```text +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? } +``` diff --git a/pom.xml b/pom.xml index ff263ee..759eae7 100644 --- a/pom.xml +++ b/pom.xml @@ -57,11 +57,17 @@ lombok provided - - org.springframework.boot - spring-boot-starter-web - - + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-webmvc-test + test + + + diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/TicketTrackingSystemApplication.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/TicketTrackingSystemApplication.java index b70982c..aca0768 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/TicketTrackingSystemApplication.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/TicketTrackingSystemApplication.java @@ -2,11 +2,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableAsync public class TicketTrackingSystemApplication { - static void main(String[] args) { + public static void main(String[] args) { SpringApplication.run(TicketTrackingSystemApplication.class, args); } diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/config/EmailConfig.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/config/EmailConfig.java new file mode 100644 index 0000000..88dde97 --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/config/EmailConfig.java @@ -0,0 +1,18 @@ +package net.hackyourfuture.tickettrackingsystem.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +public class EmailConfig { + + @Bean + public HttpClient resendHttpClient() { + return HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + } +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/config/StringToTicketStatusConverter.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/config/StringToTicketStatusConverter.java new file mode 100644 index 0000000..e76f8d2 --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/config/StringToTicketStatusConverter.java @@ -0,0 +1,18 @@ +package net.hackyourfuture.tickettrackingsystem.config; + +import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class StringToTicketStatusConverter implements Converter { + + @Override + public TicketStatus convert(String source) { + if (source == null || source.isBlank()) { + return null; + } + + return TicketStatus.fromValue(source); + } +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectController.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectController.java index bf29d58..d61f2be 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectController.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectController.java @@ -11,7 +11,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/projects") +@RequestMapping("/api/v1/projects") public class ProjectController { private final ProjectService projectService; diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java index 5820f9d..52bf72d 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/TicketController.java @@ -5,6 +5,8 @@ import net.hackyourfuture.tickettrackingsystem.dto.request.CreateTicketRequest; import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateTicketRequest; import net.hackyourfuture.tickettrackingsystem.dto.response.TicketResponse; +import net.hackyourfuture.tickettrackingsystem.dto.response.TicketUpdateResponse; +import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; import net.hackyourfuture.tickettrackingsystem.service.TicketService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -14,7 +16,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/tickets") +@RequestMapping("/api/v1/tickets") public class TicketController { private final TicketService ticketService; @@ -29,8 +31,11 @@ public ResponseEntity createTicket( } @GetMapping - public List getAllTickets() { - return ticketService.getAllTickets(); + public List getAllTickets( + @RequestParam(required = false) String text, + @RequestParam(required = false) TicketStatus status + ) { + return ticketService.getAllTickets(text, status); } @GetMapping("/{id}") @@ -39,37 +44,34 @@ public TicketResponse getTicketById(@PathVariable Long id) { } @PutMapping("/{id}") - public TicketResponse updateTicket( + public TicketUpdateResponse updateTicket( @PathVariable Long id, @Valid @RequestBody UpdateTicketRequest request ) { return ticketService.updateTicket(id, request); } - @DeleteMapping("/{id}") - public ResponseEntity deleteTicket(@PathVariable Long id) { - ticketService.deleteTicket(id); - return ResponseEntity.noContent().build(); - } @PostMapping("/{ticketId}/assignees/{userId}") - public ResponseEntity assignUserToTicket( + public ResponseEntity assignUserToTicket( @PathVariable Long ticketId, @PathVariable Long userId ) { - ticketService.assignUserToTicket(ticketId, userId); + TicketResponse response = + ticketService.assignUserToTicket(ticketId, userId); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok(response); } @DeleteMapping("/{ticketId}/assignees/{userId}") - public ResponseEntity unassignUserFromTicket( + public ResponseEntity unassignUserFromTicket( @PathVariable Long ticketId, @PathVariable Long userId ) { - ticketService.unassignUserFromTicket(ticketId, userId); + TicketResponse response = + ticketService.unassignUserFromTicket(ticketId, userId); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok(response); } } \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/UserController.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/UserController.java index f414dfa..c6b8053 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/UserController.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/controller/UserController.java @@ -13,7 +13,7 @@ import java.util.List; @RestController -@RequestMapping("/users") +@RequestMapping("/api/v1/users") @RequiredArgsConstructor public class UserController { diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateTicketRequest.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateTicketRequest.java index 888db61..1f65691 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateTicketRequest.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateTicketRequest.java @@ -2,12 +2,21 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; public record CreateTicketRequest( - @NotBlank String title, + @NotBlank + @Size(max = 255) + String title, + + @Size(max = 5000) String description, - @NotNull Long projectId, - @NotNull TicketStatus status + + @NotNull + Long projectId, + + @NotNull + TicketStatus status ) { -} \ No newline at end of file +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateUserRequest.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateUserRequest.java index 50a64d0..4a7e7ef 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateUserRequest.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/CreateUserRequest.java @@ -1,13 +1,17 @@ package net.hackyourfuture.tickettrackingsystem.dto.request; + import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record CreateUserRequest( @NotBlank - @Size(min = 3) + @Size(min = 3, max = 255) String name, + @NotBlank @Email - String email) { + @Size(max = 255) + String email +) { } diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateTicketRequest.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateTicketRequest.java index 0291a7f..bd9dbfc 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateTicketRequest.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateTicketRequest.java @@ -2,12 +2,21 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; public record UpdateTicketRequest( - @NotBlank String title, + @NotBlank + @Size(max = 255) + String title, + + @Size(max = 5000) String description, - @NotNull Long projectId, - @NotNull TicketStatus status + + @NotNull + Long projectId, + + @NotNull + TicketStatus status ) { -} \ No newline at end of file +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateUserRequest.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateUserRequest.java index f681080..02eb687 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateUserRequest.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/request/UpdateUserRequest.java @@ -7,11 +7,13 @@ public record UpdateUserRequest( @NotBlank - @Size(min = 3) + @Size(min = 3, max = 255) String name, @NotBlank @Email + @Size(max = 255) String email -) {} \ No newline at end of file +) { +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java new file mode 100644 index 0000000..d2c9a5b --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/dto/response/TicketUpdateResponse.java @@ -0,0 +1,7 @@ +package net.hackyourfuture.tickettrackingsystem.dto.response; + +public record TicketUpdateResponse( + TicketResponse ticket, + boolean emailNotificationsDispatched +) { +} \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java new file mode 100644 index 0000000..b40e7ed --- /dev/null +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/email/ResendAutomationService.java @@ -0,0 +1,122 @@ +package net.hackyourfuture.tickettrackingsystem.email; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; + +@Service +public class ResendAutomationService { + + private static final Logger logger = + LoggerFactory.getLogger(ResendAutomationService.class); + + private static final String RESEND_EMAIL_URL = "https://api.resend.com/emails"; + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final String apiKey; + private final String fromEmail; + + public ResendAutomationService( + HttpClient resendHttpClient, + ObjectMapper objectMapper, + @Value("${resend.api-key}") String apiKey, + @Value("${resend.from-email}") String fromEmail + ) { + this.httpClient = resendHttpClient; + this.objectMapper = objectMapper; + this.apiKey = apiKey; + this.fromEmail = fromEmail; + } + + @Async + public void sendTicketUpdatedEmail( + String assigneeEmail, + Long ticketId, + String ticketTitle, + String ticketStatus, + String updatedBy, + String changes + ) { + try { + String subject = "Ticket #" + ticketId + " updated"; + + String html = """ +

Ticket updated

+ +

Ticket ID: %s

+

Title: %s

+

Status: %s

+

Updated by: %s

+

Changes:

+
%s
+ """.formatted( + ticketId, + escapeHtml(ticketTitle), + escapeHtml(ticketStatus), + escapeHtml(updatedBy), + escapeHtml(changes) + ); + + Map requestBody = Map.of( + "from", fromEmail, + "to", assigneeEmail, + "subject", subject, + "html", html + ); + + String jsonBody = objectMapper.writeValueAsString(requestBody); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(RESEND_EMAIL_URL)) + .timeout(Duration.ofSeconds(10)) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + logger.warn( + "Email sending failed for {}. Resend returned status {}. Body: {}", + assigneeEmail, + response.statusCode(), + response.body() + ); + return; + } + + logger.info("Email sent successfully to {}", assigneeEmail); + + } catch (Exception exception) { + logger.error("Email sending crashed for {}", assigneeEmail, exception); + } + } + + private String escapeHtml(String value) { + if (value == null) { + return ""; + } + + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/exception/GlobalExceptionHandler.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/exception/GlobalExceptionHandler.java index a74602d..c9e99bc 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/exception/GlobalExceptionHandler.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/exception/GlobalExceptionHandler.java @@ -2,6 +2,8 @@ import jakarta.servlet.http.HttpServletRequest; import net.hackyourfuture.tickettrackingsystem.dto.response.ErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -10,6 +12,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import java.time.LocalDateTime; import java.util.LinkedHashMap; @@ -19,6 +22,9 @@ @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger logger = + LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity handleResourceNotFoundException( ResourceNotFoundException exception, @@ -83,6 +89,23 @@ public ResponseEntity handleValidationException( ); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException exception, + HttpServletRequest request + ) { + String message = exception.getCause() instanceof IllegalArgumentException cause + ? cause.getMessage() + : "Invalid value for parameter '" + exception.getName() + "'"; + + return buildErrorResponse( + HttpStatus.BAD_REQUEST, + message, + request.getRequestURI(), + null + ); + } + @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadableException( HttpServletRequest request @@ -97,8 +120,15 @@ public ResponseEntity handleHttpMessageNotReadableException( @ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity handleDataIntegrityViolationException( + DataIntegrityViolationException exception, HttpServletRequest request ) { + logger.warn( + "Database constraint violation at {}: {}", + request.getRequestURI(), + exception.getMostSpecificCause().getMessage() + ); + return buildErrorResponse( HttpStatus.CONFLICT, "Database constraint violation", @@ -109,8 +139,15 @@ public ResponseEntity handleDataIntegrityViolationException( @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException( + Exception exception, HttpServletRequest request ) { + logger.error( + "Unhandled exception at {}", + request.getRequestURI(), + exception + ); + return buildErrorResponse( HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong", diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java index 93f63a3..a179134 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/model/TicketStatus.java @@ -1,21 +1,43 @@ package net.hackyourfuture.tickettrackingsystem.model; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; +import lombok.RequiredArgsConstructor; -public enum TicketStatus { +import java.util.Arrays; +@RequiredArgsConstructor +public enum TicketStatus { OPEN("open"), IN_PROGRESS("in progress"), CLOSED("closed"); private final String value; - TicketStatus(String value) { - this.value = value; - } - @JsonValue public String getValue() { return value; } + + @JsonCreator + public static TicketStatus fromValue(String input) { + if (input == null) { + return null; + } + + String normalized = input.trim().toLowerCase(); + String enumStyle = normalized + .replace("-", "_") + .replace(" ", "_"); + + return Arrays.stream(values()) + .filter(status -> + status.value.equals(normalized) + || status.name().equalsIgnoreCase(enumStyle) + ) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Unknown status: \"" + input + "\". Allowed: open, in progress, closed" + )); + } } \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java index 3ef93fa..19b33bc 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/repository/TicketRepository.java @@ -8,8 +8,13 @@ import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; @Repository @RequiredArgsConstructor @@ -47,16 +52,47 @@ INSERT INTO tickets (title, description, project_id, status) ); } - public List findAll() { - String sql = """ - SELECT id, title, description, project_id, status, creation_date, update_date - FROM tickets - ORDER BY creation_date DESC - """; + public List findAll(String text, TicketStatus status) { + StringBuilder sql = new StringBuilder(""" + SELECT id, title, description, project_id, status, creation_date, update_date + FROM tickets + WHERE 1 = 1 + """); + + List params = new ArrayList<>(); + + String cleanedText = text == null ? null : text.trim(); + + if (cleanedText != null && !cleanedText.isBlank()) { + sql.append(""" + AND ( + strpos(LOWER(title), LOWER(?)) > 0 + OR strpos(LOWER(COALESCE(description, '')), LOWER(?)) > 0 + ) + """); + + params.add(cleanedText); + params.add(cleanedText); + } + + if (status != null) { + sql.append(""" + AND status = ?::ticket_status + """); - return jdbcTemplate.query(sql, ticketRowMapper); + params.add(status.name()); + } + + sql.append(" ORDER BY id"); + + return jdbcTemplate.query( + sql.toString(), + ticketRowMapper, + params.toArray() + ); } + public Optional findById(Long id) { String sql = """ SELECT id, title, description, project_id, status, creation_date, update_date @@ -95,21 +131,18 @@ public Ticket update(Ticket ticket) { ); } - public boolean deleteById(Long id) { - String sql = "DELETE FROM tickets WHERE id = ?"; - - int rowsAffected = jdbcTemplate.update(sql, id); - return rowsAffected > 0; - } - public void assignUser(Long ticketId, Long userId) { + public boolean assignUser(Long ticketId, Long userId) { String sql = """ INSERT INTO user_ticket (ticket_id, user_id) VALUES (?, ?) + ON CONFLICT DO NOTHING """; - jdbcTemplate.update(sql, ticketId, userId); + int rowsAffected = jdbcTemplate.update(sql, ticketId, userId); + + return rowsAffected > 0; } public boolean unassignUser(Long ticketId, Long userId) { @@ -124,22 +157,31 @@ public boolean unassignUser(Long ticketId, Long userId) { return rowsAffected > 0; } - public boolean assignmentExists(Long ticketId, Long userId) { - String sql = """ - SELECT COUNT(*) - FROM user_ticket - WHERE ticket_id = ? - AND user_id = ? - """; + public Map> findAssignedUserIdsByTicketIds(Collection ticketIds) { + if (ticketIds.isEmpty()) { + return Map.of(); + } - Integer count = jdbcTemplate.queryForObject( - sql, - Integer.class, - ticketId, - userId - ); + String placeholders = ticketIds.stream() + .map(id -> "?") + .collect(Collectors.joining(",")); - return count != null && count > 0; + String sql = """ + SELECT ticket_id, user_id + FROM user_ticket + WHERE ticket_id IN (%s) + ORDER BY ticket_id, user_id + """.formatted(placeholders); + + return jdbcTemplate.query(sql, rs -> { + Map> result = new HashMap<>(); + while (rs.next()) { + long ticketId = rs.getLong("ticket_id"); + long userId = rs.getLong("user_id"); + result.computeIfAbsent(ticketId, k -> new ArrayList<>()).add(userId); + } + return result; + }, ticketIds.toArray()); } public List findAssignedUserIds(Long ticketId) { @@ -152,4 +194,19 @@ public List findAssignedUserIds(Long ticketId) { return jdbcTemplate.queryForList(sql, Long.class, ticketId); } + + public List findAssigneeEmailsByTicketId(Long ticketId) { + String sql = """ + SELECT u.email + FROM users u + JOIN user_ticket ut ON ut.user_id = u.id + WHERE ut.ticket_id = ? + """; + + return jdbcTemplate.query( + sql, + (rs, rowNum) -> rs.getString("email"), + ticketId + ); + } } \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/ProjectService.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/ProjectService.java index 40dab49..f0a9f8e 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/ProjectService.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/ProjectService.java @@ -4,6 +4,7 @@ import net.hackyourfuture.tickettrackingsystem.dto.response.ProjectSummaryResponse; import net.hackyourfuture.tickettrackingsystem.repository.ProjectRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -13,7 +14,8 @@ public class ProjectService { private final ProjectRepository projectRepository; + @Transactional(readOnly = true) public List getAllProjects() { return projectRepository.findAllProjectSummaries(); } -} \ No newline at end of file +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java index ce8ba8c..d751fed 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/TicketService.java @@ -4,27 +4,39 @@ import net.hackyourfuture.tickettrackingsystem.dto.request.CreateTicketRequest; import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateTicketRequest; import net.hackyourfuture.tickettrackingsystem.dto.response.TicketResponse; +import net.hackyourfuture.tickettrackingsystem.dto.response.TicketUpdateResponse; +import net.hackyourfuture.tickettrackingsystem.email.ResendAutomationService; import net.hackyourfuture.tickettrackingsystem.exception.ResourceNotFoundException; import net.hackyourfuture.tickettrackingsystem.model.Ticket; +import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; +import net.hackyourfuture.tickettrackingsystem.model.User; import net.hackyourfuture.tickettrackingsystem.repository.ProjectRepository; import net.hackyourfuture.tickettrackingsystem.repository.TicketRepository; import net.hackyourfuture.tickettrackingsystem.repository.UserRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.Objects; @Service @RequiredArgsConstructor public class TicketService { + private static final String SYSTEM_ACTOR = "System"; + private final TicketRepository ticketRepository; private final ProjectRepository projectRepository; private final UserRepository userRepository; + private final ResendAutomationService resendAutomationService; + @Transactional public TicketResponse createTicket(CreateTicketRequest request) { if (!projectRepository.existsById(request.projectId())) { throw new ResourceNotFoundException( - "Project with id " + request.projectId() + " not found"); + "Project with id " + request.projectId() + " not found" + ); } Ticket ticket = new Ticket( @@ -42,31 +54,51 @@ public TicketResponse createTicket(CreateTicketRequest request) { return toTicketResponse(createdTicket); } - public List getAllTickets() { - return ticketRepository.findAll() - .stream() - .map(this::toTicketResponse) + @Transactional(readOnly = true) + public List getAllTickets(String text, TicketStatus status) { + List tickets = ticketRepository.findAll(text, status); + + if (tickets.isEmpty()) { + return List.of(); + } + + List ticketIds = tickets.stream().map(Ticket::id).toList(); + Map> assigneesByTicket = + ticketRepository.findAssignedUserIdsByTicketIds(ticketIds); + + return tickets.stream() + .map(ticket -> toTicketResponse( + ticket, + assigneesByTicket.getOrDefault(ticket.id(), List.of()) + )) .toList(); } + @Transactional(readOnly = true) public TicketResponse getTicketById(Long id) { Ticket ticket = ticketRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException( - "Ticket with id " + id + " not found")); + "Ticket with id " + id + " not found" + ) + ); return toTicketResponse(ticket); } - public TicketResponse updateTicket(Long id, UpdateTicketRequest request) { + @Transactional + public TicketUpdateResponse updateTicket(Long id, UpdateTicketRequest request) { Ticket existingTicket = ticketRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException( - "Ticket with id " + id + " not found")); + "Ticket with id " + id + " not found" + ) + ); if (!projectRepository.existsById(request.projectId())) { throw new ResourceNotFoundException( - "Project with id " + request.projectId() + " not found"); + "Project with id " + request.projectId() + " not found" + ); } Ticket updatedTicket = new Ticket( @@ -81,66 +113,167 @@ public TicketResponse updateTicket(Long id, UpdateTicketRequest request) { Ticket savedTicket = ticketRepository.update(updatedTicket); - return toTicketResponse(savedTicket); + boolean emailNotificationsDispatched = + dispatchUpdateEmails(existingTicket, savedTicket); + + return new TicketUpdateResponse( + toTicketResponse(savedTicket), + emailNotificationsDispatched + + ); } - public void deleteTicket(Long id) { - boolean deleted = ticketRepository.deleteById(id); + @Transactional + public TicketResponse assignUserToTicket(Long ticketId, Long userId) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> + new ResourceNotFoundException( + "Ticket with id " + ticketId + " not found" + ) + ); - if (!deleted) { - throw new ResourceNotFoundException( - "Ticket with id " + id + " not found"); + User user = userRepository.findById(userId) + .orElseThrow(() -> + new ResourceNotFoundException( + "User with id " + userId + " not found" + ) + ); + + boolean inserted = ticketRepository.assignUser(ticketId, userId); + + if (!inserted) { + throw new IllegalArgumentException( + "User " + userId + " is already assigned to ticket " + ticketId + ); } + + resendAutomationService.sendTicketUpdatedEmail( + user.email(), + ticket.id(), + ticket.title(), + String.valueOf(ticket.status()), + SYSTEM_ACTOR, + "You have been assigned to this ticket." + ); + + return toTicketResponse(ticket); } - public void assignUserToTicket(Long ticketId, Long userId) { - if (ticketRepository.findById(ticketId).isEmpty()) { + @Transactional + public TicketResponse unassignUserFromTicket(Long ticketId, Long userId) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> + new ResourceNotFoundException( + "Ticket with id " + ticketId + " not found" + ) + ); + + User user = userRepository.findById(userId) + .orElseThrow(() -> + new ResourceNotFoundException( + "User with id " + userId + " not found" + ) + ); + + boolean removed = ticketRepository.unassignUser(ticketId, userId); + + if (!removed) { throw new ResourceNotFoundException( - "Ticket with id " + ticketId + " not found"); + "User " + userId + " is not assigned to ticket " + ticketId + ); } - if (userRepository.findById(userId).isEmpty()) { - throw new ResourceNotFoundException( - "User with id " + userId + " not found"); + resendAutomationService.sendTicketUpdatedEmail( + user.email(), + ticket.id(), + ticket.title(), + String.valueOf(ticket.status()), + SYSTEM_ACTOR, + "You have been removed from this ticket." + ); + + return toTicketResponse(ticket); + } + + private boolean dispatchUpdateEmails(Ticket existingTicket, Ticket savedTicket) { + List assigneeEmails = + ticketRepository.findAssigneeEmailsByTicketId(savedTicket.id()); + + if (assigneeEmails.isEmpty()) { + return false; } - if (ticketRepository.assignmentExists(ticketId, userId)) { - throw new IllegalArgumentException( - "User " + userId + " is already assigned to ticket " + ticketId); + String changes = buildChanges(existingTicket, savedTicket); + + for (String email : assigneeEmails) { + resendAutomationService.sendTicketUpdatedEmail( + email, + savedTicket.id(), + savedTicket.title(), + String.valueOf(savedTicket.status()), + SYSTEM_ACTOR, + changes + ); } - ticketRepository.assignUser(ticketId, userId); + return true; } - public void unassignUserFromTicket(Long ticketId, Long userId) { - if (ticketRepository.findById(ticketId).isEmpty()) { - throw new ResourceNotFoundException( - "Ticket with id " + ticketId + " not found"); + private String buildChanges(Ticket oldTicket, Ticket newTicket) { + StringBuilder changes = new StringBuilder(); + + if (!Objects.equals(oldTicket.title(), newTicket.title())) { + changes.append("Title changed from \"") + .append(oldTicket.title()) + .append("\" to \"") + .append(newTicket.title()) + .append("\"\n"); } - if (userRepository.findById(userId).isEmpty()) { - throw new ResourceNotFoundException( - "User with id " + userId + " not found"); + if (!Objects.equals(oldTicket.description(), newTicket.description())) { + changes.append("Description was updated\n"); } - boolean removed = ticketRepository.unassignUser(ticketId, userId); + if (!Objects.equals(oldTicket.projectId(), newTicket.projectId())) { + changes.append("Project changed from ") + .append(oldTicket.projectId()) + .append(" to ") + .append(newTicket.projectId()) + .append("\n"); + } - if (!removed) { - throw new ResourceNotFoundException( - "User " + userId + " is not assigned to ticket " + ticketId); + if (!Objects.equals(oldTicket.status(), newTicket.status())) { + changes.append("Status changed from ") + .append(oldTicket.status()) + .append(" to ") + .append(newTicket.status()) + .append("\n"); } + + if (changes.isEmpty()) { + return "Ticket was updated, but no tracked fields changed."; + } + + return changes.toString(); } private TicketResponse toTicketResponse(Ticket ticket) { + return toTicketResponse( + ticket, + ticketRepository.findAssignedUserIds(ticket.id()) + ); + } + + private TicketResponse toTicketResponse(Ticket ticket, List assignedUserIds) { return new TicketResponse( ticket.id(), ticket.title(), ticket.description(), ticket.projectId(), ticket.status(), - ticketRepository.findAssignedUserIds(ticket.id()), + assignedUserIds, ticket.creationDate(), ticket.updateDate() ); } -} \ No newline at end of file +} diff --git a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/UserService.java b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/UserService.java index f8a79b7..825895b 100644 --- a/src/main/java/net/hackyourfuture/tickettrackingsystem/service/UserService.java +++ b/src/main/java/net/hackyourfuture/tickettrackingsystem/service/UserService.java @@ -9,6 +9,7 @@ import net.hackyourfuture.tickettrackingsystem.model.User; import net.hackyourfuture.tickettrackingsystem.repository.UserRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @@ -19,6 +20,7 @@ public class UserService { private final UserRepository userRepository; + @Transactional public UserResponse createUser(CreateUserRequest request) { if (userRepository.findByEmail(request.email()).isPresent()) { throw new DuplicateEmailException( @@ -34,6 +36,7 @@ public UserResponse createUser(CreateUserRequest request) { return toUserResponse(createdUser); } + @Transactional(readOnly = true) public List getAllUsers() { return userRepository.findAll() .stream() @@ -41,6 +44,7 @@ public List getAllUsers() { .toList(); } + @Transactional(readOnly = true) public UserResponse getUserById(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> @@ -50,6 +54,7 @@ public UserResponse getUserById(Long id) { return toUserResponse(user); } + @Transactional public UserResponse updateUser(Long id, UpdateUserRequest request) { User existingUser = userRepository.findById(id) .orElseThrow(() -> @@ -76,6 +81,7 @@ public UserResponse updateUser(Long id, UpdateUserRequest request) { return toUserResponse(savedUser); } + @Transactional public void deleteUser(Long id) { boolean deleted = userRepository.deleteById(id); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3145487..9828684 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,3 +5,5 @@ spring.datasource.username=ticket_user spring.datasource.password=ticket_password spring.datasource.driver-class-name=org.postgresql.Driver +resend.api-key=${RESEND_API_KEY:} +resend.from-email=${RESEND_FROM_EMAIL:Ticket Tracking } \ No newline at end of file diff --git a/src/main/resources/db/schema.sql b/src/main/resources/schema.sql similarity index 56% rename from src/main/resources/db/schema.sql rename to src/main/resources/schema.sql index a0c4a2f..3d65aa0 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/schema.sql @@ -1,20 +1,31 @@ +DROP TABLE IF EXISTS user_ticket; +DROP TABLE IF EXISTS tickets; +DROP TABLE IF EXISTS projects; +DROP TABLE IF EXISTS users; + +DROP TYPE IF EXISTS ticket_status; + CREATE TYPE ticket_status AS ENUM ( - 'OPEN', 'IN_PROGRESS', 'CLOSED'); + 'OPEN', + 'IN_PROGRESS', + 'CLOSED' + ); -CREATE TABLE users ( - id bigserial primary key , +CREATE TABLE IF NOT EXISTS users ( + id bigserial primary key, name varchar(255) not null CHECK (char_length(name) >= 3), email varchar(255) not null unique - ); -CREATE TABLE projects ( - id bigserial primary key , + +CREATE TABLE IF NOT EXISTS projects ( + id bigserial primary key, name varchar(255) not null CHECK (char_length(name) >= 3) ); -CREATE TABLE tickets ( - id bigserial primary key , + +CREATE TABLE IF NOT EXISTS tickets ( + id bigserial primary key, title varchar(255) not null, - description varchar(255) , + description TEXT, project_id BIGINT NOT NULL, status ticket_status not null, creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -22,14 +33,13 @@ CREATE TABLE tickets ( CONSTRAINT fk_ticket_project FOREIGN KEY (project_id) REFERENCES projects(id) - ); -CREATE TABLE user_ticket ( +CREATE TABLE IF NOT EXISTS user_ticket ( user_id BIGINT NOT NULL, ticket_id BIGINT NOT NULL, - primary key (user_id, ticket_id), + primary key (user_id, ticket_id), CONSTRAINT fk_user_ticket_user FOREIGN KEY (user_id) @@ -39,13 +49,13 @@ CREATE TABLE user_ticket ( FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE - ); +); -CREATE INDEX idx_tickets_status +CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); -CREATE INDEX idx_tickets_project_id +CREATE INDEX IF NOT EXISTS idx_tickets_project_id ON tickets(project_id); -CREATE INDEX idx_user_ticket_user_id - ON user_ticket(user_id); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_user_ticket_ticket_id + ON user_ticket(ticket_id); diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectControllerIntegrationTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectControllerIntegrationTest.java new file mode 100644 index 0000000..628c026 --- /dev/null +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/ProjectControllerIntegrationTest.java @@ -0,0 +1,48 @@ +package net.hackyourfuture.tickettrackingsystem.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Sql( + statements = { + "TRUNCATE TABLE user_ticket, tickets, users, projects RESTART IDENTITY CASCADE", + "INSERT INTO projects (name) VALUES ('Project Alpha')", + "INSERT INTO projects (name) VALUES ('Project Beta')", + "INSERT INTO tickets (title, project_id, status) VALUES ('A1', 1, 'OPEN')", + "INSERT INTO tickets (title, project_id, status) VALUES ('A2', 1, 'OPEN')", + "INSERT INTO tickets (title, project_id, status) VALUES ('A3', 1, 'IN_PROGRESS')", + "INSERT INTO tickets (title, project_id, status) VALUES ('A4', 1, 'CLOSED')", + "INSERT INTO tickets (title, project_id, status) VALUES ('B1', 2, 'CLOSED')" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD +) +class ProjectControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void getAllProjects_returnsProjectsWithStatusCounts() throws Exception { + mockMvc.perform(get("/api/v1/projects")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].name").value("Project Alpha")) + .andExpect(jsonPath("$[0].openTickets").value(2)) + .andExpect(jsonPath("$[0].inProgressTickets").value(1)) + .andExpect(jsonPath("$[0].closedTickets").value(1)) + .andExpect(jsonPath("$[1].name").value("Project Beta")) + .andExpect(jsonPath("$[1].openTickets").value(0)) + .andExpect(jsonPath("$[1].inProgressTickets").value(0)) + .andExpect(jsonPath("$[1].closedTickets").value(1)); + } +} diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java new file mode 100644 index 0000000..cf0ee4b --- /dev/null +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/TicketControllerIntegrationTest.java @@ -0,0 +1,96 @@ +package net.hackyourfuture.tickettrackingsystem.controller; + +import net.hackyourfuture.tickettrackingsystem.email.ResendAutomationService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Sql( + statements = { + "TRUNCATE TABLE user_ticket, tickets, users, projects RESTART IDENTITY CASCADE", + "INSERT INTO projects (name) VALUES ('Project Alpha')", + "INSERT INTO users (name, email) VALUES ('Alice Doe', 'alice@example.com')", + "INSERT INTO tickets (title, description, project_id, status) VALUES ('Bug login', 'Login button is broken', 1, 'OPEN')", + "INSERT INTO tickets (title, description, project_id, status) VALUES ('Feature export', 'Export tickets to CSV', 1, 'CLOSED')", + "INSERT INTO user_ticket (user_id, ticket_id) VALUES (1, 1)" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD +) +class TicketControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ResendAutomationService resendAutomationService; + + @Test + void getAllTickets_withoutFilters_returnsAllTickets() throws Exception { + mockMvc.perform(get("/api/v1/tickets")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].title").value("Bug login")) + .andExpect(jsonPath("$[1].title").value("Feature export")); + } + + @Test + void getAllTickets_withTextAndStatus_usesAndLogic() throws Exception { + mockMvc.perform(get("/api/v1/tickets") + .param("text", "bug") + .param("status", "open")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].title").value("Bug login")) + .andExpect(jsonPath("$[0].status").value("open")); + } + + @Test + void getAllTickets_withInvalidStatus_returnsBadRequest() throws Exception { + mockMvc.perform(get("/api/v1/tickets") + .param("status", "not-a-real-status")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + void getAllTickets_withWildcardInText_treatsItAsLiteral() throws Exception { + mockMvc.perform(get("/api/v1/tickets") + .param("text", "%")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + void updateTicket_returnsTicketAndEmailDispatchedFlag() throws Exception { + String requestBody = """ + { + "title": "Bug login updated", + "description": "Login button is still broken", + "projectId": 1, + "status": "in progress" + } + """; + + mockMvc.perform(put("/api/v1/tickets/1") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.ticket.id").value(1)) + .andExpect(jsonPath("$.ticket.title").value("Bug login updated")) + .andExpect(jsonPath("$.ticket.status").value("in progress")) + .andExpect(jsonPath("$.emailNotificationsDispatched").value(true)); + } +} diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/UserControllerIntegrationTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/UserControllerIntegrationTest.java new file mode 100644 index 0000000..956750f --- /dev/null +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/controller/UserControllerIntegrationTest.java @@ -0,0 +1,93 @@ +package net.hackyourfuture.tickettrackingsystem.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Sql( + statements = { + "TRUNCATE TABLE user_ticket, tickets, users, projects RESTART IDENTITY CASCADE", + "INSERT INTO users (name, email) VALUES ('Alice Doe', 'alice@example.com')", + "INSERT INTO users (name, email) VALUES ('Bob Smith', 'bob@example.com')" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD +) +class UserControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void getAllUsers_returnsBothSeededUsers() throws Exception { + mockMvc.perform(get("/api/v1/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].email").value("alice@example.com")) + .andExpect(jsonPath("$[1].email").value("bob@example.com")); + } + + @Test + void getUserById_whenUserMissing_returnsNotFound() throws Exception { + mockMvc.perform(get("/api/v1/users/999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + } + + @Test + void createUser_withDuplicateEmail_returnsConflict() throws Exception { + String body = """ + { "name": "Another Alice", "email": "alice@example.com" } + """; + + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value(409)); + } + + @Test + void createUser_withInvalidEmail_returnsBadRequestWithFieldError() throws Exception { + String body = """ + { "name": "Eve", "email": "not-an-email" } + """; + + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.validationErrors.email").exists()); + } + + @Test + void createUser_withValidPayload_returnsCreatedUser() throws Exception { + String body = """ + { "name": "Carol", "email": "carol@example.com" } + """; + + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.name").value("Carol")) + .andExpect(jsonPath("$.email").value("carol@example.com")); + } + + @Test + void deleteUser_whenUserMissing_returnsNotFound() throws Exception { + mockMvc.perform(delete("/api/v1/users/999")) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java new file mode 100644 index 0000000..12d5175 --- /dev/null +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/service/TicketServiceTest.java @@ -0,0 +1,223 @@ +package net.hackyourfuture.tickettrackingsystem.service; + +import net.hackyourfuture.tickettrackingsystem.dto.request.CreateTicketRequest; +import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateTicketRequest; +import net.hackyourfuture.tickettrackingsystem.dto.response.TicketResponse; +import net.hackyourfuture.tickettrackingsystem.dto.response.TicketUpdateResponse; +import net.hackyourfuture.tickettrackingsystem.email.ResendAutomationService; +import net.hackyourfuture.tickettrackingsystem.exception.ResourceNotFoundException; +import net.hackyourfuture.tickettrackingsystem.model.Ticket; +import net.hackyourfuture.tickettrackingsystem.model.TicketStatus; +import net.hackyourfuture.tickettrackingsystem.model.User; +import net.hackyourfuture.tickettrackingsystem.repository.ProjectRepository; +import net.hackyourfuture.tickettrackingsystem.repository.TicketRepository; +import net.hackyourfuture.tickettrackingsystem.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TicketServiceTest { + + @Mock + private TicketRepository ticketRepository; + + @Mock + private ProjectRepository projectRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ResendAutomationService resendAutomationService; + + @InjectMocks + private TicketService ticketService; + + @Test + void createTicket_whenProjectDoesNotExist_throwsResourceNotFoundException() { + CreateTicketRequest request = new CreateTicketRequest( + "Bug login", + "Login button is broken", + 99L, + TicketStatus.OPEN + ); + + when(projectRepository.existsById(99L)).thenReturn(false); + + assertThrows( + ResourceNotFoundException.class, + () -> ticketService.createTicket(request) + ); + + verify(ticketRepository, never()).create(any()); + } + + @Test + void updateTicket_dispatchesEmailToEveryAssigneeAndFlagsResponse() { + Ticket existingTicket = new Ticket( + 1L, + "Bug login", + "Login button is broken", + 1L, + TicketStatus.OPEN, + LocalDateTime.now().minusDays(1), + null + ); + + Ticket savedTicket = new Ticket( + 1L, + "Bug login fixed", + "Login button is fixed", + 1L, + TicketStatus.IN_PROGRESS, + existingTicket.creationDate(), + LocalDateTime.now() + ); + + UpdateTicketRequest request = new UpdateTicketRequest( + "Bug login fixed", + "Login button is fixed", + 1L, + TicketStatus.IN_PROGRESS + ); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(existingTicket)); + when(projectRepository.existsById(1L)).thenReturn(true); + when(ticketRepository.update(any(Ticket.class))).thenReturn(savedTicket); + when(ticketRepository.findAssigneeEmailsByTicketId(1L)) + .thenReturn(List.of("alice@example.com", "bob@example.com")); + when(ticketRepository.findAssignedUserIds(1L)) + .thenReturn(List.of(2L, 3L)); + + TicketUpdateResponse response = ticketService.updateTicket(1L, request); + + assertEquals("Bug login fixed", response.ticket().title()); + assertEquals(TicketStatus.IN_PROGRESS, response.ticket().status()); + assertTrue(response.emailNotificationsDispatched()); + + verify(resendAutomationService, times(2)).sendTicketUpdatedEmail( + anyString(), + eq(1L), + anyString(), + anyString(), + eq("System"), + anyString() + ); + } + + @Test + void updateTicket_withNoAssignees_doesNotDispatchAnyEmail() { + Ticket existingTicket = new Ticket( + 1L, + "Bug login", + "Login button is broken", + 1L, + TicketStatus.OPEN, + LocalDateTime.now().minusDays(1), + null + ); + + Ticket savedTicket = new Ticket( + 1L, + "Bug login fixed", + "Login button is broken", + 1L, + TicketStatus.IN_PROGRESS, + existingTicket.creationDate(), + LocalDateTime.now() + ); + + UpdateTicketRequest request = new UpdateTicketRequest( + "Bug login fixed", + "Login button is broken", + 1L, + TicketStatus.IN_PROGRESS + ); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(existingTicket)); + when(projectRepository.existsById(1L)).thenReturn(true); + when(ticketRepository.update(any(Ticket.class))).thenReturn(savedTicket); + when(ticketRepository.findAssigneeEmailsByTicketId(1L)).thenReturn(List.of()); + when(ticketRepository.findAssignedUserIds(1L)).thenReturn(List.of()); + + TicketUpdateResponse response = ticketService.updateTicket(1L, request); + + assertFalse(response.emailNotificationsDispatched()); + verify(resendAutomationService, never()).sendTicketUpdatedEmail( + anyString(), anyLong(), anyString(), anyString(), anyString(), anyString() + ); + } + + @Test + void assignUserToTicket_whenAssignmentAlreadyExists_throwsAndDoesNotEmail() { + Ticket ticket = new Ticket( + 1L, + "Bug login", + "Login button is broken", + 1L, + TicketStatus.OPEN, + LocalDateTime.now().minusDays(1), + null + ); + + User user = new User(2L, "Alice Doe", "alice@example.com"); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(ticketRepository.assignUser(1L, 2L)).thenReturn(false); + + assertThrows( + IllegalArgumentException.class, + () -> ticketService.assignUserToTicket(1L, 2L) + ); + + verify(resendAutomationService, never()).sendTicketUpdatedEmail( + anyString(), anyLong(), anyString(), anyString(), anyString(), anyString() + ); + } + + @Test + void assignUserToTicket_whenInsertSucceeds_dispatchesEmailAndReturnsTicket() { + Ticket ticket = new Ticket( + 1L, + "Bug login", + "Login button is broken", + 1L, + TicketStatus.OPEN, + LocalDateTime.now().minusDays(1), + null + ); + + User user = new User(2L, "Alice Doe", "alice@example.com"); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(ticketRepository.assignUser(1L, 2L)).thenReturn(true); + when(ticketRepository.findAssignedUserIds(1L)).thenReturn(List.of(2L)); + + TicketResponse response = ticketService.assignUserToTicket(1L, 2L); + + assertEquals(1L, response.id()); + assertTrue(response.assignedUserIds().contains(2L)); + + verify(resendAutomationService).sendTicketUpdatedEmail( + eq("alice@example.com"), + eq(1L), + anyString(), + anyString(), + eq("System"), + anyString() + ); + } +} diff --git a/src/test/java/net/hackyourfuture/tickettrackingsystem/service/UserServiceTest.java b/src/test/java/net/hackyourfuture/tickettrackingsystem/service/UserServiceTest.java new file mode 100644 index 0000000..ae41013 --- /dev/null +++ b/src/test/java/net/hackyourfuture/tickettrackingsystem/service/UserServiceTest.java @@ -0,0 +1,116 @@ +package net.hackyourfuture.tickettrackingsystem.service; + +import net.hackyourfuture.tickettrackingsystem.dto.request.CreateUserRequest; +import net.hackyourfuture.tickettrackingsystem.dto.request.UpdateUserRequest; +import net.hackyourfuture.tickettrackingsystem.dto.response.UserResponse; +import net.hackyourfuture.tickettrackingsystem.exception.DuplicateEmailException; +import net.hackyourfuture.tickettrackingsystem.exception.ResourceNotFoundException; +import net.hackyourfuture.tickettrackingsystem.model.User; +import net.hackyourfuture.tickettrackingsystem.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserService userService; + + @Test + void createUser_whenEmailAlreadyExists_throwsDuplicateEmailException() { + CreateUserRequest request = new CreateUserRequest( + "Alice Doe", + "alice@example.com" + ); + + when(userRepository.findByEmail("alice@example.com")) + .thenReturn(Optional.of(new User(1L, "Alice Doe", "alice@example.com"))); + + assertThrows( + DuplicateEmailException.class, + () -> userService.createUser(request) + ); + + verify(userRepository, never()).create(any()); + } + + @Test + void createUser_whenEmailIsFree_persistsAndReturnsResponse() { + CreateUserRequest request = new CreateUserRequest( + "Carol", + "carol@example.com" + ); + + when(userRepository.findByEmail("carol@example.com")) + .thenReturn(Optional.empty()); + when(userRepository.create(any(User.class))) + .thenReturn(new User(7L, "Carol", "carol@example.com")); + + UserResponse response = userService.createUser(request); + + assertEquals(7L, response.id()); + assertEquals("Carol", response.name()); + assertEquals("carol@example.com", response.email()); + } + + @Test + void updateUser_whenEmailIsTakenByAnotherUser_throwsDuplicateEmailException() { + UpdateUserRequest request = new UpdateUserRequest( + "Alice Renamed", + "bob@example.com" + ); + + when(userRepository.findById(1L)) + .thenReturn(Optional.of(new User(1L, "Alice Doe", "alice@example.com"))); + when(userRepository.findByEmail("bob@example.com")) + .thenReturn(Optional.of(new User(2L, "Bob Smith", "bob@example.com"))); + + assertThrows( + DuplicateEmailException.class, + () -> userService.updateUser(1L, request) + ); + + verify(userRepository, never()).update(any()); + } + + @Test + void updateUser_whenEmailUnchanged_keepsGoingAndReturnsUpdatedUser() { + UpdateUserRequest request = new UpdateUserRequest( + "Alice Renamed", + "alice@example.com" + ); + + when(userRepository.findById(1L)) + .thenReturn(Optional.of(new User(1L, "Alice Doe", "alice@example.com"))); + when(userRepository.findByEmail("alice@example.com")) + .thenReturn(Optional.of(new User(1L, "Alice Doe", "alice@example.com"))); + when(userRepository.update(any(User.class))) + .thenReturn(new User(1L, "Alice Renamed", "alice@example.com")); + + UserResponse response = userService.updateUser(1L, request); + + assertEquals("Alice Renamed", response.name()); + } + + @Test + void deleteUser_whenUserMissing_throwsResourceNotFoundException() { + when(userRepository.deleteById(99L)).thenReturn(false); + + assertThrows( + ResourceNotFoundException.class, + () -> userService.deleteUser(99L) + ); + } +}