From 019330ae5046037bc466c5e8b6b5cb9c89183e58 Mon Sep 17 00:00:00 2001 From: kennethdomdom Date: Sun, 12 Apr 2026 12:01:10 +0800 Subject: [PATCH] feature/ admin-send attachment --- CLAUDE.md | 90 +++++++++++++++++++ Dockerfile | 2 +- docs/api-contract.md | 2 +- docs/roles-permissions.md | 2 +- .../controller/AttachmentController.java | 2 +- .../ucms_backend/security/SecurityUtils.java | 14 ++- .../service/AttachmentService.java | 39 +++++--- .../service/AttachmentServiceTest.java | 55 ++++++++++++ 8 files changed, 187 insertions(+), 19 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..053dbcf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +UCMS Backend is a REST API for a University Concern Management System — a ticket submission, routing, and resolution platform for students, staff, and admins. Built with Spring Boot 4.0.3 / Java 21, backed by Supabase (PostgreSQL + Auth + Storage). + +## Commands + +```bash +# Run locally (port 8080) +./mvnw spring-boot:run + +# Build JAR +./mvnw clean package + +# Run all tests +./mvnw test + +# Run a specific test class +./mvnw test -Dtest=TicketServiceTest + +# Run a specific test method +./mvnw test -Dtest=TicketServiceTest#testCreateTicket +``` + +No dedicated linting tool. Code style follows the Google Java Style Guide (no wildcard imports). + +## Architecture + +Strict 3-tier layered architecture: **Controller → Service → Repository** + +- **No business logic in controllers** — controllers only parse requests and delegate to services. +- **Ownership and role checks belong exclusively in the service layer.** +- **All responses use `ApiResponse` wrapper.** + +### Key Service Responsibilities + +| Service | Responsibility | +|---|---| +| `TicketService` | Ticket CRUD, status transitions, ownership enforcement | +| `SupabaseAuthService` | JWT validation and token refresh via Supabase | +| `SupabaseStorageService` | File upload/download, signed URL generation | +| `NotificationService` | Email notifications triggered by ticket events | +| `TicketUrgencyScoringService` | AI urgency scoring via Gemini API | +| `RealtimeSseService` | Server-sent events for live ticket updates | +| `AnalyticsService` | Reports and statistics | + +### Security + +Authentication uses Supabase JWTs validated as OAuth2 resource server tokens. The `SupabaseAuthFilter` extracts roles and sets the Spring Security context. RBAC is enforced in the service layer — see `docs/roles-permissions.md` for the full matrix. + +### Database + +PostgreSQL (Supabase-managed). Schema is managed entirely by **Flyway** — `spring.jpa.hibernate.ddl-auto` is set to `none`. Migration files live in `src/main/resources/db/migration/` using the `V{n}__{description}.sql` naming convention. Never use Hibernate to auto-generate or modify schema. + +### Rate Limiting + +Bucket4j is used for API rate limiting. Configuration lives in `SecurityConfig`. + +## Configuration + +Copy `src/main/resources/application.yaml.example` to `application.yaml` for local dev. Required keys: + +- `SPRING_DATASOURCE_URL` / `USERNAME` / `PASSWORD` — PostgreSQL connection +- `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY` — Supabase project +- `SUPABASE_JWKS_URI`, `SUPABASE_JWT_ISSUER` — JWT validation +- `GEMINI_API_KEY`, `GEMINI_MODEL` — AI urgency scoring (optional) +- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD` — SMTP + +Spring profiles: `dev` (default, verbose SQL logging) and `prod` (env vars, connection pooling). + +## Testing + +Tests live in `src/test/java/com/ucms_backend/`. Service tests use `@ExtendWith(MockitoExtension.class)` with `@Mock` / `@InjectMocks`. Tests that involve authorization manually set up `SecurityContextHolder`. + +CI runs `./mvnw test` on every PR to `development` via GitHub Actions. + +## Branching + +Always branch off `development`. Never work directly on `main`. + +## Key Reference Docs + +- `docs/ticket-status-flow.md` — Valid ticket status transitions (state machine) +- `docs/roles-permissions.md` — RBAC matrix +- `docs/api-contract.md` — Full endpoint contracts +- `docs/data-models.md` — ERD and schema decisions +- `docs/error-codes.md` — Error codes and messages diff --git a/Dockerfile b/Dockerfile index 9b502b9..6f2fed6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["sh","-c","java -Dspring.profiles.active=prod -Dserver.port=$PORT -jar app.jar"] \ No newline at end of file +ENTRYPOINT ["java","-jar","app.jar"] \ No newline at end of file diff --git a/docs/api-contract.md b/docs/api-contract.md index 62efa51..e903b82 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -53,7 +53,7 @@ Notes: | Method | Endpoint | Role | Description | |--------|----------|------|-------------| -| POST | `/api/tickets/{id}/attachments` | Student | Upload photo/file → Supabase Storage; saves `storage_path` in DB | +| POST | `/api/tickets/{id}/attachments` | Student / Admin | Upload photo/file → Supabase Storage; saves `storage_path` in DB | | GET | `/api/tickets/{id}/attachments` | Student / Admin | List attachments for a ticket (includes signed URLs) | Notes: diff --git a/docs/roles-permissions.md b/docs/roles-permissions.md index 29a68c2..a55812a 100644 --- a/docs/roles-permissions.md +++ b/docs/roles-permissions.md @@ -40,7 +40,7 @@ ### Attachments | Action | Student | Admin | |--------|:-------:|:-----:| -| Upload attachment | ✅ own tickets (verified email) | ❌ | +| Upload attachment | ✅ own tickets (verified email) | ✅ any ticket | | View attachments | ✅ own tickets | ✅ all | ### Responses diff --git a/src/main/java/com/ucms_backend/controller/AttachmentController.java b/src/main/java/com/ucms_backend/controller/AttachmentController.java index fee2f24..fd14582 100644 --- a/src/main/java/com/ucms_backend/controller/AttachmentController.java +++ b/src/main/java/com/ucms_backend/controller/AttachmentController.java @@ -26,7 +26,7 @@ public AttachmentController(AttachmentService attachmentService) { } @PostMapping - @PreAuthorize("hasRole('STUDENT')") + @PreAuthorize("hasAnyRole('STUDENT', 'ADMIN')") public ResponseEntity> uploadAttachment( @PathVariable Long id, @RequestParam("file") MultipartFile file diff --git a/src/main/java/com/ucms_backend/security/SecurityUtils.java b/src/main/java/com/ucms_backend/security/SecurityUtils.java index b8f3962..b1f3f3b 100644 --- a/src/main/java/com/ucms_backend/security/SecurityUtils.java +++ b/src/main/java/com/ucms_backend/security/SecurityUtils.java @@ -1,5 +1,6 @@ package com.ucms_backend.security; +import com.ucms_backend.exception.AppException; import java.util.UUID; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -11,11 +12,22 @@ private SecurityUtils() { public static UUID getCurrentUserId() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new AppException(401, "UNAUTHORIZED", "No authenticated user"); + } return (UUID) authentication.getPrincipal(); } public static String getCurrentRole() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - return authentication.getAuthorities().iterator().next().getAuthority().replace("ROLE_", ""); + if (authentication == null) { + throw new AppException(401, "UNAUTHORIZED", "No authenticated user"); + } + return authentication.getAuthorities().stream() + .map(a -> a.getAuthority()) + .filter(a -> a.startsWith("ROLE_")) + .findFirst() + .map(a -> a.replace("ROLE_", "")) + .orElseThrow(() -> new AppException(401, "UNAUTHORIZED", "No role assigned to authenticated user")); } } diff --git a/src/main/java/com/ucms_backend/service/AttachmentService.java b/src/main/java/com/ucms_backend/service/AttachmentService.java index 67a701f..0391b55 100644 --- a/src/main/java/com/ucms_backend/service/AttachmentService.java +++ b/src/main/java/com/ucms_backend/service/AttachmentService.java @@ -51,19 +51,28 @@ public AttachmentResponse uploadAttachment(Long ticketId, MultipartFile file) { byte[] content = validateAndReadFile(file); String detectedMimeType = new Tika().detect(content); UUID userId = SecurityUtils.getCurrentUserId(); + String role = SecurityUtils.getCurrentRole(); Ticket ticket = ticketRepository.findById(ticketId) .orElseThrow(() -> new AppException(404, "TICKET_NOT_FOUND", "Ticket not found")); - if (!ticket.getUserId().equals(userId)) { + boolean isAdmin = "ADMIN".equals(role); + boolean isStudent = "STUDENT".equals(role); + if (!isAdmin && !isStudent) { throw new AppException(403, "FORBIDDEN", "Access denied"); } - Profile profile = profileRepository.findById(userId) - .orElseThrow(() -> new AppException(404, "PROFILE_NOT_FOUND", "Profile not found")); + if (isStudent) { + if (ticket.getUserId() == null || !ticket.getUserId().equals(userId)) { + throw new AppException(403, "FORBIDDEN", "Access denied"); + } + + Profile profile = profileRepository.findById(userId) + .orElseThrow(() -> new AppException(404, "PROFILE_NOT_FOUND", "Profile not found")); - if (!profile.isEmailVerified()) { - throw new AppException(403, "ACCOUNT_LIMITED", "Verified email required to upload attachments"); + if (!profile.isEmailVerified()) { + throw new AppException(403, "ACCOUNT_LIMITED", "Verified email required to upload attachments"); + } } if (ticket.getStatus() == TicketStatus.CLOSED || ticket.getStatus() == TicketStatus.RESOLVED) { @@ -86,15 +95,17 @@ public AttachmentResponse uploadAttachment(Long ticketId, MultipartFile file) { TicketAttachment saved = ticketAttachmentRepository.save(attachment); String signedUrl = supabaseStorageService.generateSignedUrl(storagePath); - List admins = profileRepository.findByRole("ADMIN"); - String filename = saved.getOriginalFilename() != null ? saved.getOriginalFilename() : "file"; - for (Profile admin : admins) { - if (admin != null && admin.getAuthUserId() != null) { - notificationService.createNotification( - admin.getAuthUserId(), - ticket.getId(), - "Student uploaded attachment on ticket #" + ticket.getTicketNumber() + ": " + filename - ); + if (isStudent) { + List admins = profileRepository.findByRole("ADMIN"); + String filename = saved.getOriginalFilename() != null ? saved.getOriginalFilename() : "file"; + for (Profile admin : admins) { + if (admin != null && admin.getAuthUserId() != null) { + notificationService.createNotification( + admin.getAuthUserId(), + ticket.getId(), + "Student uploaded attachment on ticket #" + ticket.getTicketNumber() + ": " + filename + ); + } } } diff --git a/src/test/java/com/ucms_backend/service/AttachmentServiceTest.java b/src/test/java/com/ucms_backend/service/AttachmentServiceTest.java index ea859a2..2a42bdc 100644 --- a/src/test/java/com/ucms_backend/service/AttachmentServiceTest.java +++ b/src/test/java/com/ucms_backend/service/AttachmentServiceTest.java @@ -236,6 +236,61 @@ void upload_success_returnsResponse() { verify(supabaseStorageService).uploadFile(anyString(), any(), anyString()); } + @Test + void upload_admin_anyTicket_success() { + Ticket ticket = Ticket.builder() + .id(11L) + .userId(UUID.randomUUID()) // owned by a student, not the admin + .status(TicketStatus.PENDING) + .build(); + + setAuthenticatedUser(UUID.randomUUID(), "ADMIN"); + + when(ticketRepository.findById(11L)).thenReturn(Optional.of(ticket)); + when(supabaseStorageService.generateSignedUrl(anyString())).thenReturn("admin-signed-url"); + when(ticketAttachmentRepository.save(any(TicketAttachment.class))).thenAnswer(invocation -> { + TicketAttachment input = invocation.getArgument(0); + return TicketAttachment.builder() + .id(20L) + .ticketId(input.getTicketId()) + .storagePath(input.getStoragePath()) + .originalFilename(input.getOriginalFilename()) + .mimeType(input.getMimeType()) + .sizeBytes(input.getSizeBytes()) + .uploadedAt(LocalDateTime.now()) + .build(); + }); + + AttachmentResponse response = attachmentService.uploadAttachment(11L, mockJpegFile()); + + assertEquals(20L, response.getId()); + assertEquals("admin-signed-url", response.getSignedUrl()); + verify(supabaseStorageService).uploadFile(anyString(), any(), anyString()); + // Email-verified gate must be skipped for admins + verify(profileRepository, never()).findById(any()); + } + + @Test + void upload_admin_closedTicket_throws403() { + Ticket ticket = Ticket.builder() + .id(12L) + .userId(UUID.randomUUID()) + .status(TicketStatus.CLOSED) + .build(); + + setAuthenticatedUser(UUID.randomUUID(), "ADMIN"); + + when(ticketRepository.findById(12L)).thenReturn(Optional.of(ticket)); + + AppException exception = assertThrows(AppException.class, () -> + attachmentService.uploadAttachment(12L, mockJpegFile()) + ); + + assertEquals(403, exception.getStatus()); + assertEquals("TICKET_CLOSED", exception.getErrorCode()); + verify(supabaseStorageService, never()).uploadFile(anyString(), any(), anyString()); + } + @Test void upload_fileTooLarge_throws413() { byte[] bigContent = new byte[11 * 1024 * 1024]; // 11MB — exceeds 10MB limit