Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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<T>` 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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
ENTRYPOINT ["java","-jar","app.jar"]
2 changes: 1 addition & 1 deletion docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/roles-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public AttachmentController(AttachmentService attachmentService) {
}

@PostMapping
@PreAuthorize("hasRole('STUDENT')")
@PreAuthorize("hasAnyRole('STUDENT', 'ADMIN')")
public ResponseEntity<ApiResponse<AttachmentResponse>> uploadAttachment(
@PathVariable Long id,
@RequestParam("file") MultipartFile file
Expand Down
14 changes: 13 additions & 1 deletion src/main/java/com/ucms_backend/security/SecurityUtils.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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"));
}
}
39 changes: 25 additions & 14 deletions src/main/java/com/ucms_backend/service/AttachmentService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -86,15 +95,17 @@ public AttachmentResponse uploadAttachment(Long ticketId, MultipartFile file) {
TicketAttachment saved = ticketAttachmentRepository.save(attachment);
String signedUrl = supabaseStorageService.generateSignedUrl(storagePath);

List<Profile> 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<Profile> 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
);
}
}
}

Expand Down
55 changes: 55 additions & 0 deletions src/test/java/com/ucms_backend/service/AttachmentServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading