Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f22a50f
feat(task): 대시보드용 열린 Task 조회, 상태별 개수 집계 쿼리 추가
chaeliki Aug 7, 2026
eb1f3a3
feat(task): TaskRepository에 findOpenTasks, countByCompanyIdAndStatus 노출
chaeliki Aug 7, 2026
01fd01e
feat(dashboard): DashboardTaskSummaryResponse DTO 추가
chaeliki Aug 7, 2026
b71ed07
feat(dashboard): DashboardQueryService 추가, due-today를 열린 Task 기준으로 정확…
chaeliki Aug 7, 2026
b59614b
feat(dashboard): GET /dashboard/today API 추가 (date, timezone 파라미터 지원)
chaeliki Aug 7, 2026
c2fbc64
test(dashboard): DashboardSecurityIntegrationTest 5개 (빈사업장, 개수일치, 타사업…
chaeliki Aug 7, 2026
66dee42
fix(dashboard): timezone 검증 실패 시 500 대신 400 반환, summary_counts 실제 값 검…
chaeliki Aug 7, 2026
a3cce84
feat(dashboard): UpcomingExpiryCategory, UpcomingExpiryItemResponse D…
chaeliki Aug 7, 2026
3bf037f
feat(dashboard): upcoming_7_days 계산 로직 추가 (Worker 4개 날짜 + WorkerDocum…
chaeliki Aug 7, 2026
6eeb6aa
test(dashboard): upcoming_7_days 통합 테스트 2개
chaeliki Aug 7, 2026
dec7a42
fix(dashboard): upcoming_7_days에서 이미 지난 만료일 제외, 카테고리별/과거제외 테스트 추가
chaeliki Aug 8, 2026
fbf6d6f
feat(dashboard): recommendations 계산 로직 및 카운트 전용 쿼리 추가
chaeliki Aug 8, 2026
6f705b7
Merge branch 'main' into feat/15-dashboard-upcoming-expiry
chaeliki Aug 8, 2026
b0366f9
Merge remote-tracking branch 'origin/feat/15-dashboard-upcoming-expir…
chaeliki Aug 8, 2026
2bbe5cd
test(dashboard): recommendations 통합 테스트 3개
chaeliki Aug 8, 2026
5d3750b
fix(dashboard): timezone 생략 시 UTC 대신 Asia/Seoul 기본값 사용 (단위 테스트를 통해 검증)
chaeliki Aug 8, 2026
eeebe7a
feat(notification): Notification 도메인, V37, NotificationRepository 인터페…
chaeliki Aug 8, 2026
3343722
feat(notification): NotificationJpaEntity 추가
chaeliki Aug 8, 2026
d5608e7
feat(notification): JpaRepository 추가, JpaNotificationRepository 구현
chaeliki Aug 8, 2026
b236b1b
feat(notification): error, service, Controller (GET /notifications, P…
chaeliki Aug 8, 2026
b8cd511
fix(dashboard): recommendations의 review/after_approval을 dueDate 우선 정렬
chaeliki Aug 8, 2026
2d44ac1
Merge remote-tracking branch 'origin/main' into feat/15-notifications
chaeliki Aug 8, 2026
9c80f25
feat(notification): V38 notification 테이블 RLS 정책
chaeliki Aug 8, 2026
594aa39
Merge remote-tracking branch 'origin/main' into feat/15-notifications
chaeliki Aug 8, 2026
f81371b
test: PostgreSqlMigrationTests 예상 RLS 정책 목록에 추가
chaeliki Aug 8, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task> 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;
Expand Down Expand Up @@ -82,7 +85,7 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t
.toList();

List<UpcomingExpiryItemResponse> upcoming7Days = collectUpcomingExpiry(companyId, targetDate, windowEnd);
DashboardRecommendationsResponse recommendations = collectRecommendations(companyId);
DashboardRecommendationsResponse recommendations = collectRecommendations(companyId);

return new DashboardTodayResponse(
summaryCounts, priorityTasks, upcoming7Days, recommendations, pendingApproval, workerResponse
Expand All @@ -104,6 +107,7 @@ private DashboardRecommendationsResponse collectRecommendations(UUID companyId)
List<Task> reviewTasks = new ArrayList<>();
reviewTasks.addAll(needsInfoTasks);
reviewTasks.addAll(readyTasks);
reviewTasks.sort(PRIORITY_ORDER);

List<Task> waitingWorkerTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria(
companyId, TaskStatus.WAITING_WORKER, null, null, null, null, null, null, null, null, 0, 100
Expand All @@ -114,6 +118,7 @@ private DashboardRecommendationsResponse collectRecommendations(UUID companyId)
List<Task> afterApprovalTasks = new ArrayList<>();
afterApprovalTasks.addAll(waitingWorkerTasks);
afterApprovalTasks.addAll(waitingExternalTasks);
afterApprovalTasks.sort(PRIORITY_ORDER);

long connectedCount = taskRepository.countOpenTasksByCompanyId(companyId);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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~100)")
@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.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<Void> read(
@Parameter(description = "알림 ID") @PathVariable UUID notificationId
) {
ActorContext actor = actorContextProvider.requireCurrentActor();
notificationService.markAsRead(notificationId, actor);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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<NotificationItemResponse> items;

@JsonProperty("unread_count")
@Schema(name = "unread_count", description = "읽지 않은 알림 개수")
private final long unreadCount;

@JsonProperty("next_cursor")
@Schema(name = "next_cursor", description = "다음 페이지 조회용 커서 (없으면 마지막 페이지)")
private final String nextCursor;

public NotificationPageResponse(List<NotificationItemResponse> items, long unreadCount, String nextCursor) {
this.items = items;
this.unreadCount = unreadCount;
this.nextCursor = nextCursor;
}

public List<NotificationItemResponse> getItems() {
return items;
}

public long getUnreadCount() {
return unreadCount;
}

public String getNextCursor() {
return nextCursor;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.fowoco.server.notification.application;

import com.fowoco.server.notification.domain.Notification;
import java.util.List;

public record NotificationPageResult(
List<Notification> items,
long unreadCount,
String nextCursor
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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.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 = 100;
private static final int DEFAULT_PAGE_SIZE = 20;

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();
int effectiveSize = normalizeSize(size);

List<Notification> items = notificationRepository.findPage(
companyId, unreadOnly != null && unreadOnly, cursor, effectiveSize
);
long unreadCount = notificationRepository.countUnread(companyId);
String nextCursor = items.size() == effectiveSize && !items.isEmpty()
? items.get(items.size() - 1).occurredAt().toString()
: null;

return new NotificationPageResult(items, unreadCount, 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading