diff --git a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java index f258979f..5cadfa6a 100644 --- a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -37,6 +37,9 @@ public class DashboardQueryService { private static final int PRIORITY_TASK_LIMIT = 5; private static final int UPCOMING_DAYS = 7; + private static final java.util.Comparator PRIORITY_ORDER = java.util.Comparator + .comparing(Task::dueDate, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) + .thenComparing(Task::createdAt, java.util.Comparator.reverseOrder()); private final TaskRepository taskRepository; private final WorkerRepository workerRepository; @@ -82,7 +85,7 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t .toList(); List upcoming7Days = collectUpcomingExpiry(companyId, targetDate, windowEnd); - DashboardRecommendationsResponse recommendations = collectRecommendations(companyId); + DashboardRecommendationsResponse recommendations = collectRecommendations(companyId); return new DashboardTodayResponse( summaryCounts, priorityTasks, upcoming7Days, recommendations, pendingApproval, workerResponse @@ -104,6 +107,7 @@ private DashboardRecommendationsResponse collectRecommendations(UUID companyId) List reviewTasks = new ArrayList<>(); reviewTasks.addAll(needsInfoTasks); reviewTasks.addAll(readyTasks); + reviewTasks.sort(PRIORITY_ORDER); List waitingWorkerTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria( companyId, TaskStatus.WAITING_WORKER, null, null, null, null, null, null, null, null, 0, 100 @@ -114,6 +118,7 @@ private DashboardRecommendationsResponse collectRecommendations(UUID companyId) List afterApprovalTasks = new ArrayList<>(); afterApprovalTasks.addAll(waitingWorkerTasks); afterApprovalTasks.addAll(waitingExternalTasks); + afterApprovalTasks.sort(PRIORITY_ORDER); long connectedCount = taskRepository.countOpenTasksByCompanyId(companyId); diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationController.java b/src/main/java/com/fowoco/server/notification/api/NotificationController.java new file mode 100644 index 00000000..0985a9b3 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/api/NotificationController.java @@ -0,0 +1,105 @@ +package com.fowoco.server.notification.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.notification.application.NotificationPageResult; +import com.fowoco.server.notification.application.NotificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.Instant; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Notification", description = "알림 조회·읽음 처리") +@RestController +@RequestMapping("/api/v1/notifications") +@SecurityRequirement(name = "bearerAuth") +@Validated +public class NotificationController { + + private final NotificationService notificationService; + private final ActorContextProvider actorContextProvider; + + public NotificationController( + NotificationService notificationService, + ActorContextProvider actorContextProvider + ) { + this.notificationService = notificationService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "listNotifications", + summary = "알림 목록 조회", + description = "상단 알림 패널에 승인·응답·기한·서류 알림과 이동 대상을 제공합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = NotificationPageResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public NotificationPageResponse list( + @Parameter(description = "읽지 않은 알림만 조회") @RequestParam(required = false) Boolean unreadOnly, + @Parameter(description = "이전 페이지 마지막 항목의 occurred_at (다음 페이지 조회용)") + @RequestParam(required = false) Instant cursor, + @Parameter(description = "페이지당 항목 수 (1~50)") + @RequestParam(required = false) @Min(1) @Max(100) Integer size + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + NotificationPageResult result = notificationService.findPage(actor, unreadOnly, cursor, size); + return new NotificationPageResponse( + result.items().stream().map(NotificationItemResponse::from).toList(), + result.unreadCount(), + result.hasNext(), + result.nextCursor() + ); + } + + @Operation( + operationId = "readNotification", + summary = "알림 읽음 처리", + description = "사용자가 확인한 알림을 읽음으로 기록합니다. 같은 요청을 반복해도 결과는 동일합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "처리 성공"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @PostMapping(path = "/{notificationId}/read") + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public ResponseEntity read( + @Parameter(description = "알림 ID") @PathVariable UUID notificationId + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + notificationService.markAsRead(notificationId, actor); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationItemResponse.java b/src/main/java/com/fowoco/server/notification/api/NotificationItemResponse.java new file mode 100644 index 00000000..66040af8 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/api/NotificationItemResponse.java @@ -0,0 +1,91 @@ +package com.fowoco.server.notification.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.notification.domain.NotificationTargetType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; + +@Schema(name = "NotificationItemResponse", description = "알림 항목") +public final class NotificationItemResponse { + + @JsonProperty("id") + @Schema(name = "id", format = "uuid") + private final UUID id; + + @JsonProperty("target_type") + @Schema(name = "target_type", description = "알림 대상 종류") + private final NotificationTargetType targetType; + + @JsonProperty("target_id") + @Schema(name = "target_id", format = "uuid") + private final UUID targetId; + + @JsonProperty("route") + @Schema(name = "route", description = "허용된 화면으로 이동할 안전한 내부 경로") + private final String route; + + @JsonProperty("title") + private final String title; + + @JsonProperty("read") + private final boolean read; + + @JsonProperty("occurred_at") + @Schema(name = "occurred_at", format = "date-time") + private final Instant occurredAt; + + private NotificationItemResponse( + UUID id, NotificationTargetType targetType, UUID targetId, + String route, String title, boolean read, Instant occurredAt + ) { + this.id = id; + this.targetType = targetType; + this.targetId = targetId; + this.route = route; + this.title = title; + this.read = read; + this.occurredAt = occurredAt; + } + + public static NotificationItemResponse from(Notification notification) { + return new NotificationItemResponse( + notification.notificationId(), + notification.targetType(), + notification.targetId(), + notification.route(), + notification.title(), + notification.read(), + notification.occurredAt() + ); + } + + public UUID getId() { + return id; + } + + public NotificationTargetType getTargetType() { + return targetType; + } + + public UUID getTargetId() { + return targetId; + } + + public String getRoute() { + return route; + } + + public String getTitle() { + return title; + } + + public boolean isRead() { + return read; + } + + public Instant getOccurredAt() { + return occurredAt; + } +} diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java b/src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java new file mode 100644 index 00000000..df43df2e --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/api/NotificationPageResponse.java @@ -0,0 +1,49 @@ +package com.fowoco.server.notification.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(name = "NotificationPageResponse", description = "알림 목록 응답") +public final class NotificationPageResponse { + + @JsonProperty("items") + private final List items; + + @JsonProperty("unread_count") + @Schema(name = "unread_count", description = "읽지 않은 알림 개수") + private final long unreadCount; + + @JsonProperty("has_next") + @Schema(name = "has_next", description = "다음 페이지 존재 여부") + private final boolean hasNext; + + @JsonProperty("next_cursor") + @Schema(name = "next_cursor", description = "다음 페이지 조회용 커서 (없으면 마지막 페이지)") + private final String nextCursor; + + public NotificationPageResponse( + List items, long unreadCount, boolean hasNext, String nextCursor + ) { + this.items = items; + this.unreadCount = unreadCount; + this.hasNext = hasNext; + this.nextCursor = nextCursor; + } + + public List getItems() { + return items; + } + + public long getUnreadCount() { + return unreadCount; + } + + public boolean isHasNext() { + return hasNext; + } + + public String getNextCursor() { + return nextCursor; + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java b/src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java new file mode 100644 index 00000000..58477227 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/NotificationPageResult.java @@ -0,0 +1,12 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.notification.domain.Notification; +import java.util.List; + +public record NotificationPageResult( + List items, + long unreadCount, + boolean hasNext, + String nextCursor +) { +} diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationService.java b/src/main/java/com/fowoco/server/notification/application/NotificationService.java new file mode 100644 index 00000000..7f1aba17 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/NotificationService.java @@ -0,0 +1,75 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.error.NotificationErrorCode; +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class NotificationService { + + private static final int MAX_PAGE_SIZE = 35; + private static final int DEFAULT_PAGE_SIZE = 5; + + private final NotificationRepository notificationRepository; + private final TenantDatabaseContext tenantDatabaseContext; + + public NotificationService( + NotificationRepository notificationRepository, + TenantDatabaseContext tenantDatabaseContext + ) { + this.notificationRepository = notificationRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + } + + @Transactional(readOnly = true) + public NotificationPageResult findPage(ActorContext actor, Boolean unreadOnly, Instant cursor, Integer size) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + UUID companyId = actor.companyId(); + UUID userId = actor.actorId(); + int effectiveSize = normalizeSize(size); + + List fetched = notificationRepository.findPage( + companyId, userId, unreadOnly != null && unreadOnly, cursor, effectiveSize + 1 + ); + + boolean hasNext = fetched.size() > effectiveSize; + List items = hasNext + ? new ArrayList<>(fetched.subList(0, effectiveSize)) + : fetched; + + long unreadCount = notificationRepository.countUnread(companyId, userId); + String nextCursor = hasNext && !items.isEmpty() + ? items.get(items.size() - 1).occurredAt().toString() + : null; + + return new NotificationPageResult(items, unreadCount, hasNext, nextCursor); + } + + @Transactional + public void markAsRead(UUID notificationId, ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + Notification notification = notificationRepository + .findByIdAndCompanyId(notificationId, actor.companyId()) + .orElseThrow(() -> new ApiException(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); + Notification updated = notification.markAsRead(); + if (updated != notification) { + notificationRepository.update(updated); + } + } + + private int normalizeSize(Integer size) { + if (size == null) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(Math.max(size, 1), MAX_PAGE_SIZE); + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java b/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java new file mode 100644 index 00000000..23ba7b16 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java @@ -0,0 +1,34 @@ +package com.fowoco.server.notification.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum NotificationErrorCode implements ApiErrorCode { + NOTIFICATION_NOT_FOUND( + HttpStatus.NOT_FOUND, + "알림을 찾을 수 없습니다." + ); + + private final HttpStatus status; + private final String defaultMessage; + + NotificationErrorCode(HttpStatus status, String defaultMessage) { + this.status = status; + this.defaultMessage = defaultMessage; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String defaultMessage() { + return defaultMessage; + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java b/src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java new file mode 100644 index 00000000..f4fc06ef --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/port/NotificationRepository.java @@ -0,0 +1,20 @@ +package com.fowoco.server.notification.application.port; + +import com.fowoco.server.notification.domain.Notification; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface NotificationRepository { + + void insert(Notification notification); + + Notification update(Notification notification); + + Optional findByIdAndCompanyId(UUID notificationId, UUID companyId); + + List findPage(UUID companyId, UUID userId, boolean unreadOnly, Instant cursor, int size); + + long countUnread(UUID companyId, UUID userId); +} diff --git a/src/main/java/com/fowoco/server/notification/domain/Notification.java b/src/main/java/com/fowoco/server/notification/domain/Notification.java new file mode 100644 index 00000000..07b12736 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/domain/Notification.java @@ -0,0 +1,125 @@ +package com.fowoco.server.notification.domain; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public final class Notification { + + private final UUID notificationId; + private final UUID companyId; + private final UUID userId; + private final NotificationTargetType targetType; + private final UUID targetId; + private final String route; + private final String title; + private final boolean read; + private final Instant occurredAt; + private final Instant createdAt; + + public Notification( + UUID notificationId, + UUID companyId, + UUID userId, + NotificationTargetType targetType, + UUID targetId, + String route, + String title, + boolean read, + Instant occurredAt, + Instant createdAt + ) { + this.notificationId = Objects.requireNonNull(notificationId, "notificationId must not be null"); + this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.userId = Objects.requireNonNull(userId, "userId must not be null"); + this.targetType = Objects.requireNonNull(targetType, "targetType must not be null"); + this.targetId = Objects.requireNonNull(targetId, "targetId must not be null"); + this.route = requireNonBlank(route, "route"); + this.title = requireNonBlank(title, "title"); + this.read = read; + this.occurredAt = Objects.requireNonNull(occurredAt, "occurredAt must not be null"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); + } + + public static Notification create( + UUID notificationId, + UUID companyId, + UUID userId, + NotificationTargetType targetType, + UUID targetId, + String title, + Instant occurredAt, + Instant now + ) { + return new Notification( + notificationId, companyId, userId, targetType, targetId, + buildRoute(targetType, targetId), title, false, occurredAt, now + ); + } + + public Notification markAsRead() { + if (read) { + return this; + } + return new Notification( + notificationId, companyId, userId, targetType, targetId, route, title, true, occurredAt, createdAt + ); + } + + public UUID notificationId() { + return notificationId; + } + + public UUID companyId() { + return companyId; + } + + public UUID userId() { + return userId; + } + + public NotificationTargetType targetType() { + return targetType; + } + + public UUID targetId() { + return targetId; + } + + public String route() { + return route; + } + + public String title() { + return title; + } + + public boolean read() { + return read; + } + + public Instant occurredAt() { + return occurredAt; + } + + public Instant createdAt() { + return createdAt; + } + + private static String buildRoute(NotificationTargetType targetType, UUID targetId) { + Objects.requireNonNull(targetType, "targetType must not be null"); + Objects.requireNonNull(targetId, "targetId must not be null"); + return switch (targetType) { + case TASK -> "/tasks/" + targetId; + case WORKER -> "/workers/" + targetId + "/detail"; + case DOCUMENT -> "/documents/" + targetId; + }; + } + + private static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value; + } +} diff --git a/src/main/java/com/fowoco/server/notification/domain/NotificationTargetType.java b/src/main/java/com/fowoco/server/notification/domain/NotificationTargetType.java new file mode 100644 index 00000000..ec501319 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/domain/NotificationTargetType.java @@ -0,0 +1,7 @@ +package com.fowoco.server.notification.domain; + +public enum NotificationTargetType { + TASK, + WORKER, + DOCUMENT +} diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java new file mode 100644 index 00000000..077224b6 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java @@ -0,0 +1,51 @@ +package com.fowoco.server.notification.infrastructure.persistence; + +import com.fowoco.server.notification.application.port.NotificationRepository; +import com.fowoco.server.notification.domain.Notification; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaNotificationRepository implements NotificationRepository { + + private final SpringDataNotificationJpaRepository repository; + + public JpaNotificationRepository(SpringDataNotificationJpaRepository repository) { + this.repository = repository; + } + + @Override + public void insert(Notification notification) { + repository.save(NotificationJpaEntity.fromDomain(notification)); + } + + @Override + public Notification update(Notification notification) { + NotificationJpaEntity entity = repository + .findByIdAndCompanyId(notification.notificationId(), notification.companyId()) + .orElseThrow(() -> new IllegalStateException("notification not found for update")); + entity.applyState(notification); + return repository.saveAndFlush(entity).toDomain(); + } + + @Override + public Optional findByIdAndCompanyId(UUID notificationId, UUID companyId) { + return repository.findByIdAndCompanyId(notificationId, companyId).map(NotificationJpaEntity::toDomain); + } + + @Override + public List findPage(UUID companyId, UUID userId, boolean unreadOnly, Instant cursor, int size) { + return repository.findPage(companyId, userId, unreadOnly, cursor, PageRequest.of(0, size)).stream() + .map(NotificationJpaEntity::toDomain) + .toList(); + } + + @Override + public long countUnread(UUID companyId, UUID userId) { + return repository.countByCompanyIdAndUserIdAndReadFalse(companyId, userId); + } +} diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java new file mode 100644 index 00000000..99a52578 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/NotificationJpaEntity.java @@ -0,0 +1,108 @@ +package com.fowoco.server.notification.infrastructure.persistence; + +import com.fowoco.server.notification.domain.Notification; +import com.fowoco.server.notification.domain.NotificationTargetType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "notification") +public class NotificationJpaEntity { + + @Id + @Column(name = "notification_id", nullable = false, updatable = false) + private UUID notificationId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Column(name = "user_id", nullable = false, updatable = false) + private UUID userId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 30, updatable = false) + private NotificationTargetType targetType; + + @Column(name = "target_id", nullable = false, updatable = false) + private UUID targetId; + + @Column(name = "route", nullable = false, updatable = false) + private String route; + + @Column(name = "title", nullable = false, updatable = false) + private String title; + + @Column(name = "is_read", nullable = false) + private boolean read; + + @Column(name = "occurred_at", nullable = false, updatable = false) + private Instant occurredAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected NotificationJpaEntity() { + } + + private NotificationJpaEntity( + UUID notificationId, + UUID companyId, + UUID userId, + NotificationTargetType targetType, + UUID targetId, + String route, + String title, + boolean read, + Instant occurredAt, + Instant createdAt + ) { + this.notificationId = notificationId; + this.companyId = companyId; + this.userId = userId; + this.targetType = targetType; + this.targetId = targetId; + this.route = route; + this.title = title; + this.read = read; + this.occurredAt = occurredAt; + this.createdAt = createdAt; + } + + public static NotificationJpaEntity fromDomain(Notification notification) { + Objects.requireNonNull(notification, "notification must not be null"); + return new NotificationJpaEntity( + notification.notificationId(), + notification.companyId(), + notification.userId(), + notification.targetType(), + notification.targetId(), + notification.route(), + notification.title(), + notification.read(), + notification.occurredAt(), + notification.createdAt() + ); + } + + public Notification toDomain() { + return new Notification( + notificationId, companyId, userId, targetType, targetId, route, title, read, occurredAt, createdAt + ); + } + + public void applyState(Notification notification) { + Objects.requireNonNull(notification, "notification must not be null"); + if (!notificationId.equals(notification.notificationId()) + || !companyId.equals(notification.companyId())) { + throw new IllegalArgumentException("immutable notification fields must not change"); + } + this.read = notification.read(); + } +} diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java new file mode 100644 index 00000000..369e02db --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java @@ -0,0 +1,41 @@ +package com.fowoco.server.notification.infrastructure.persistence; + +import java.time.Instant; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +interface SpringDataNotificationJpaRepository extends JpaRepository { + + @Query(""" + SELECT n + FROM NotificationJpaEntity n + WHERE n.notificationId = :notificationId + AND n.companyId = :companyId + """) + java.util.Optional findByIdAndCompanyId( + @Param("notificationId") UUID notificationId, + @Param("companyId") UUID companyId + ); + + @Query(""" + SELECT n + FROM NotificationJpaEntity n + WHERE n.companyId = :companyId + AND n.userId = :userId + AND (:unreadOnly = false OR n.read = false) + AND (:cursor IS NULL OR n.occurredAt < :cursor) + ORDER BY n.occurredAt DESC + """) + java.util.List findPage( + @Param("companyId") UUID companyId, + @Param("userId") UUID userId, + @Param("unreadOnly") boolean unreadOnly, + @Param("cursor") Instant cursor, + Pageable pageable + ); + + long countByCompanyIdAndUserIdAndReadFalse(UUID companyId, UUID userId); +} diff --git a/src/main/resources/db/migration-postgresql/V38__prepare_notification_rls.sql b/src/main/resources/db/migration-postgresql/V38__prepare_notification_rls.sql new file mode 100644 index 00000000..499b7f4c --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V38__prepare_notification_rls.sql @@ -0,0 +1,12 @@ +CREATE POLICY pl_notification_tenant_isolation + ON public.notification + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); diff --git a/src/main/resources/db/migration/V37__create_notification.sql b/src/main/resources/db/migration/V37__create_notification.sql new file mode 100644 index 00000000..37d40a0a --- /dev/null +++ b/src/main/resources/db/migration/V37__create_notification.sql @@ -0,0 +1,15 @@ +CREATE TABLE notification ( + notification_id UUID PRIMARY KEY, + company_id UUID NOT NULL, + user_id UUID NOT NULL, + target_type VARCHAR(30) NOT NULL, + target_id UUID NOT NULL, + route VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + occurred_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX idx_notification_company_user_read_occurred + ON notification (company_id, user_id, is_read, occurred_at DESC); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index fb6ad45b..82adaec4 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -504,7 +504,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_worker_import_job_tenant_isolation", "pl_worker_import_row_tenant_isolation", "pl_worker_import_commit_idempotency_tenant_isolation", - "pl_document_ocr_run_tenant_isolation" + "pl_document_ocr_run_tenant_isolation", + "pl_notification_tenant_isolation" ); assertThat(rlsEnabledTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) diff --git a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java new file mode 100644 index 00000000..0e73d77e --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java @@ -0,0 +1,273 @@ +package com.fowoco.server.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class NotificationSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("52000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("62000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("53000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("63000000-0000-0000-0000-000000000002"); + private static final UUID HR_A2 = UUID.fromString("53000000-0000-0000-0000-000000000002"); + private static final String HR_A_EMAIL = "hr.notification.a@example.com"; + private static final String HR_A2_EMAIL = "hr.notification.a2@example.com"; + private static final String HR_B_EMAIL = "hr.notification.b@example.com"; + private static final String PASSWORD = "Test-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedCompaniesAndUsers() { + jdbcTemplate.update("DELETE FROM notification"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM task_evidence"); + jdbcTemplate.update("DELETE FROM external_submission"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + + insertCompany(COMPANY_A, "알림 사업장 A"); + insertCompany(COMPANY_B, "알림 사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash); + insertUser(HR_A2, COMPANY_A, HR_A2_EMAIL, passwordHash); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash); + } + + @BeforeEach + void resetNotificationState() { + jdbcTemplate.update("DELETE FROM notification"); + } + + @Test + void listReturnsItemsAndUnreadCount() throws Exception { + insertNotification(COMPANY_A, "TASK", false, Instant.now().minus(1, ChronoUnit.HOURS)); + insertNotification(COMPANY_A, "TASK", true, Instant.now().minus(2, ChronoUnit.HOURS)); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).hasSize(2); + assertThat(JsonPath.read(response.body(), "$.unread_count").longValue()).isEqualTo(1); + } + + @Test + void unreadOnlyFiltersReadNotifications() throws Exception { + insertNotification(COMPANY_A, "TASK", false, Instant.now()); + insertNotification(COMPANY_A, "TASK", true, Instant.now()); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications?unreadOnly=true", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).hasSize(1); + } + + @Test + void otherCompanyNotificationsAreNotVisible() throws Exception { + insertNotification(COMPANY_B, "TASK", false, Instant.now()); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).isEmpty(); + } + + @Test + void readMarksNotificationAsReadAndIsIdempotent() throws Exception { + UUID notificationId = insertNotification(COMPANY_A, "TASK", false, Instant.now()); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse firstRead = authorizedPost( + "/api/v1/notifications/" + notificationId + "/read", accessToken + ); + HttpResponse secondRead = authorizedPost( + "/api/v1/notifications/" + notificationId + "/read", accessToken + ); + + assertThat(firstRead.statusCode()).isEqualTo(204); + assertThat(secondRead.statusCode()).isEqualTo(204); + + HttpResponse listResponse = authorizedGet("/api/v1/notifications", accessToken); + assertThat(JsonPath.read(listResponse.body(), "$.items[0].read")).isTrue(); + } + + @Test + void readOnOtherCompanyNotificationReturnsNotFound() throws Exception { + UUID notificationId = insertNotification(COMPANY_B, "TASK", false, Instant.now()); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedPost( + "/api/v1/notifications/" + notificationId + "/read", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(404); + } + + @Test + void notificationsAreIsolatedBetweenUsersInSameCompany() throws Exception { + insertNotification(COMPANY_A, HR_A, "TASK", false, Instant.now()); + String hrA2Token = accessToken(login(HR_A2_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications", hrA2Token); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).isEmpty(); + assertThat(JsonPath.read(response.body(), "$.unread_count").longValue()).isZero(); + } + + @Test + void hasNextIsFalseWhenExactlySizeItemsRemain() throws Exception { + for (int i = 0; i < 3; i++) { + insertNotification(COMPANY_A, HR_A, "TASK", false, Instant.now().minusSeconds(i)); + } + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications?size=3", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).hasSize(3); + assertThat(JsonPath.read(response.body(), "$.has_next")).isFalse(); + } + + @Test + void hasNextIsTrueWhenMoreItemsRemain() throws Exception { + for (int i = 0; i < 4; i++) { + insertNotification(COMPANY_A, HR_A, "TASK", false, Instant.now().minusSeconds(i)); + } + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications?size=3", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).hasSize(3); + assertThat(JsonPath.read(response.body(), "$.has_next")).isTrue(); + } + + private UUID insertNotification(UUID companyId, String targetType, boolean read, Instant occurredAt) { + return insertNotification(companyId, HR_A, targetType, read, occurredAt); + } + + private UUID insertNotification( + UUID companyId, UUID userId, String targetType, boolean read, Instant occurredAt + ) { + UUID notificationId = UUID.randomUUID(); + jdbcTemplate.update( + """ + INSERT INTO notification ( + notification_id, company_id, user_id, target_type, target_id, route, + title, is_read, occurred_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + notificationId, companyId, userId, targetType, UUID.randomUUID(), "/tasks/" + UUID.randomUUID(), + "테스트 알림", read, java.sql.Timestamp.from(occurredAt), java.sql.Timestamp.from(Instant.now()) + ); + return notificationId; + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + companyId, name + ); + } + + private void insertUser(UUID userId, UUID companyId, String email, String passwordHash) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + userId, companyId, email, email, passwordHash + ); + } + + private HttpResponse login(String email) throws Exception { + String body = """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD); + return postJson("/api/v1/auth/login", body, null); + } + + private String accessToken(HttpResponse loginResponse) { + assertThat(loginResponse.statusCode()).isEqualTo(200); + return JsonPath.read(loginResponse.body(), "$.access_token"); + } + + private HttpResponse authorizedGet(String path, String accessToken) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse authorizedPost(String path, String accessToken) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse postJson(String path, String body, String accessToken) throws Exception { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (accessToken != null) { + requestBuilder.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); + } + return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +} diff --git a/src/test/java/com/fowoco/server/notification/domain/NotificationTest.java b/src/test/java/com/fowoco/server/notification/domain/NotificationTest.java new file mode 100644 index 00000000..7dcc0845 --- /dev/null +++ b/src/test/java/com/fowoco/server/notification/domain/NotificationTest.java @@ -0,0 +1,69 @@ +package com.fowoco.server.notification.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class NotificationTest { + + private static final UUID COMPANY_ID = UUID.randomUUID(); + private static final UUID USER_ID = UUID.randomUUID(); + private static final UUID TARGET_ID = UUID.randomUUID(); + private static final Instant NOW = Instant.parse("2026-08-09T00:00:00Z"); + + @Test + void buildsTaskRoute() { + Notification notification = Notification.create( + UUID.randomUUID(), COMPANY_ID, USER_ID, + NotificationTargetType.TASK, TARGET_ID, "제목", NOW, NOW + ); + + assertThat(notification.route()).isEqualTo("/tasks/" + TARGET_ID); + } + + @Test + void buildsWorkerRoute() { + Notification notification = Notification.create( + UUID.randomUUID(), COMPANY_ID, USER_ID, + NotificationTargetType.WORKER, TARGET_ID, "제목", NOW, NOW + ); + + assertThat(notification.route()).isEqualTo("/workers/" + TARGET_ID + "/detail"); + } + + @Test + void buildsDocumentRoute() { + Notification notification = Notification.create( + UUID.randomUUID(), COMPANY_ID, USER_ID, + NotificationTargetType.DOCUMENT, TARGET_ID, "제목", NOW, NOW + ); + + assertThat(notification.route()).isEqualTo("/documents/" + TARGET_ID); + } + + @Test + void routeAlwaysMatchesTargetId() { + Notification notification = Notification.create( + UUID.randomUUID(), COMPANY_ID, USER_ID, + NotificationTargetType.TASK, TARGET_ID, "제목", NOW, NOW + ); + + assertThat(notification.route()).contains(notification.targetId().toString()); + } + + @Test + void markAsReadIsIdempotent() { + Notification notification = Notification.create( + UUID.randomUUID(), COMPANY_ID, USER_ID, + NotificationTargetType.TASK, TARGET_ID, "제목", NOW, NOW + ); + + Notification firstRead = notification.markAsRead(); + Notification secondRead = firstRead.markAsRead(); + + assertThat(firstRead.read()).isTrue(); + assertThat(secondRead).isSameAs(firstRead); + } +}