Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 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
2306c11
feat(notification): NotificationEventHandler 단위/통합 테스트 추가
chaeliki Aug 9, 2026
0dc82c8
fix(notification): user_id 추가하여 사용자별 알림 격리, 페이지 수정
chaeliki Aug 9, 2026
3636c1b
feat(notification): route를 target_type/target_id로 불일치 방지
chaeliki Aug 9, 2026
0c8392a
test(notification): user_id 격리, hasNext 경계값 검증 테스트 추가
chaeliki Aug 9, 2026
0cef83e
Merge branch 'feat/15-notifications' into feat/15-notification-genera…
chaeliki Aug 9, 2026
b8e9594
fix(notification): NotificationEventHandler를 Notification.create() 새 …
chaeliki Aug 9, 2026
4fc24dd
feat(approval): ApprovalRequested 이벤트 발행 추가
chaeliki Aug 9, 2026
f334f75
feat(notification): ApprovalRequested 이벤트 구독하여 승인 요청 알림 생성
chaeliki Aug 9, 2026
0cf3279
feat(workerlink): WorkerResponseService.submit()에서 Submitted 이벤트 발행
chaeliki Aug 9, 2026
73a66b6
test(notification): WorkerResponseSubmitted 이벤트 처리 테스트 추가
chaeliki Aug 9, 2026
b7c29ad
feat(task): updateChecklistItem()에서 NEEDS_INFO 전이 시 TaskNeedsInfo 이벤트 발행
chaeliki Aug 9, 2026
167e306
test(notification): TaskNeedsInfo 이벤트 처리 테스트 추가
chaeliki Aug 9, 2026
4af2cdb
feat(notification): Scheduler
chaeliki Aug 11, 2026
b917b65
Merge remote-tracking branch 'origin/main' into feat/15-notification-…
chaeliki Aug 11, 2026
11ab4a2
fix(test): RenewalExecutionIntegrationTest 정리 목록에 event_publication, …
chaeliki Aug 11, 2026
b8d1cb3
Merge branch 'main' into feat/15-notification-generation
chaeliki Aug 11, 2026
eb2c3db
fix(notification): DueSoonCompanyNotifier로 회사별 처리를 분리, @Transactional…
chaeliki Aug 11, 2026
3b0b188
test(notification): 트랜잭션 통합 테스트 추가
chaeliki Aug 11, 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
@@ -0,0 +1,54 @@
package com.fowoco.server.approval.application;

import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.common.web.RequestMetadata;
import com.fowoco.server.reliability.domain.DomainEventEnvelope;
import com.fowoco.server.reliability.domain.EventActorType;
import com.fowoco.server.reliability.domain.SafeEventPayload;
import com.fowoco.server.task.domain.Task;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

final class ApprovalDomainEvents {

private static final String PAYLOAD_VERSION = "1";
private static final String AGGREGATE_TYPE = "Task";
private static final Set<String> APPROVAL_REQUESTED_FIELDS = Set.of(
"task_title",
"task_type"
);

private ApprovalDomainEvents() {
}

static DomainEventEnvelope approvalRequested(
UUID eventId,
Task task,
ActorContext actor,
RequestMetadata metadata,
Instant occurredAt
) {
return new DomainEventEnvelope(
eventId,
"ApprovalRequested",
PAYLOAD_VERSION,
AGGREGATE_TYPE,
task.taskId(),
task.companyId(),
EventActorType.HR_USER,
actor.actorId(),
metadata.requestId(),
metadata.traceId(),
occurredAt,
SafeEventPayload.of(
APPROVAL_REQUESTED_FIELDS,
Map.of(
"task_title", task.title(),
"task_type", task.taskType()
)
)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import com.fowoco.server.common.error.ApiException;
import com.fowoco.server.common.error.ErrorCode;
import com.fowoco.server.common.id.UuidGenerator;
import com.fowoco.server.reliability.application.port.DomainEventPublisher;
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.common.web.RequestMetadata;
import com.fowoco.server.settings.application.port.CompanySettingsRepository;
Expand Down Expand Up @@ -58,6 +59,7 @@ public class ApprovalService implements ApprovalControlPort {
private final SafeJsonService safeJsonService;
private final UuidGenerator uuidGenerator;
private final Clock clock;
private final DomainEventPublisher eventPublisher;

public ApprovalService(
ActorAuthorizer actorAuthorizer,
Expand All @@ -72,7 +74,8 @@ public ApprovalService(
AuditEventRepository auditRepository,
SafeJsonService safeJsonService,
UuidGenerator uuidGenerator,
Clock clock
Clock clock,
DomainEventPublisher eventPublisher
) {
this.actorAuthorizer = actorAuthorizer;
this.tenantDatabaseContext = tenantDatabaseContext;
Expand All @@ -87,6 +90,7 @@ public ApprovalService(
this.safeJsonService = safeJsonService;
this.uuidGenerator = uuidGenerator;
this.clock = clock;
this.eventPublisher = eventPublisher;
}

@Transactional
Expand Down Expand Up @@ -144,6 +148,13 @@ public ApprovalResult requestApproval(
metadata,
now
);
eventPublisher.publish(ApprovalDomainEvents.approvalRequested(
uuidGenerator.generate(),
savedTask,
actor,
metadata,
now
));
return result(savedApproval, savedTask);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package com.fowoco.server.company.application.port;

import com.fowoco.server.company.domain.Company;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface CompanyRepository {

Optional<Company> findById(UUID companyId);

void insert(Company company);
List<UUID> findAllIds();
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.fowoco.server.company.application.port.CompanyRepository;
import com.fowoco.server.company.domain.Company;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
Expand Down Expand Up @@ -30,4 +31,11 @@ public void insert(Company company) {
entityManager.persist(CompanyJpaEntity.fromDomain(company));
entityManager.flush();
}

@Override
public List<UUID> findAllIds() {
return entityManager
.createQuery("SELECT company.companyId FROM CompanyJpaEntity company", UUID.class)
.getResultList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.fowoco.server.notification.application;

import com.fowoco.server.common.id.UuidGenerator;
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.notification.application.port.NotificationRepository;
import com.fowoco.server.notification.domain.Notification;
import com.fowoco.server.notification.domain.NotificationTargetType;
import com.fowoco.server.task.application.port.TaskRepository;
import com.fowoco.server.task.domain.Task;
import java.time.Clock;
import java.time.LocalDate;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class DueSoonCompanyNotifier {

private final TaskRepository taskRepository;
private final NotificationRepository notificationRepository;
private final TenantDatabaseContext tenantDatabaseContext;
private final UuidGenerator uuidGenerator;
private final Clock clock;

public DueSoonCompanyNotifier(
TaskRepository taskRepository,
NotificationRepository notificationRepository,
TenantDatabaseContext tenantDatabaseContext,
UuidGenerator uuidGenerator,
Clock clock
) {
this.taskRepository = taskRepository;
this.notificationRepository = notificationRepository;
this.tenantDatabaseContext = tenantDatabaseContext;
this.uuidGenerator = uuidGenerator;
this.clock = clock;
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processCompany(UUID companyId, LocalDate today, LocalDate windowEnd) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId);
taskRepository.findOpenTasksDueBetween(companyId, today, windowEnd)
.forEach(this::createDueSoonNotification);
}

private void createDueSoonNotification(Task task) {
Notification notification = Notification.create(
uuidGenerator.generate(),
task.companyId(),
task.createdBy(),
NotificationTargetType.TASK,
task.taskId(),
"마감이 임박했습니다: " + task.title(),
clock.instant(),
clock.instant()
);
notificationRepository.insert(notification);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.fowoco.server.notification.application;

import com.fowoco.server.company.application.port.CompanyRepository;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class DueSoonNotificationScheduler {

private static final int UPCOMING_DAYS = 7;

private final CompanyRepository companyRepository;
private final DueSoonCompanyNotifier companyNotifier;
private final Clock clock;

public DueSoonNotificationScheduler(
CompanyRepository companyRepository,
DueSoonCompanyNotifier companyNotifier,
Clock clock
) {
this.companyRepository = companyRepository;
this.companyNotifier = companyNotifier;
this.clock = clock;
}

@Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul")
public void notifyDueSoonTasks() {
LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul")));
LocalDate windowEnd = today.plusDays(UPCOMING_DAYS);

companyRepository.findAllIds().forEach(companyId ->
companyNotifier.processCompany(companyId, today, windowEnd)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.fowoco.server.notification.application;

import com.fowoco.server.common.id.UuidGenerator;
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.notification.application.port.NotificationRepository;
import com.fowoco.server.notification.domain.Notification;
import com.fowoco.server.notification.domain.NotificationTargetType;
import com.fowoco.server.reliability.application.port.DomainEventHandler;
import com.fowoco.server.reliability.domain.DomainEventEnvelope;
import com.fowoco.server.task.application.port.TaskRepository;
import com.fowoco.server.task.domain.Task;
import com.fowoco.server.task.domain.TaskSource;
import java.time.Clock;
import java.util.Set;
import org.springframework.stereotype.Component;

@Component
public final class NotificationEventHandler implements DomainEventHandler {

private static final String HANDLER_NAME = "notificationFromTaskEvents";
private static final String TASK_CREATED = "TaskCreated";
private static final String APPROVAL_REQUESTED = "ApprovalRequested";
private static final String WORKER_RESPONSE_SUBMITTED = "WorkerResponseSubmitted";
private static final String TASK_NEEDS_INFO = "TaskNeedsInfo";
private static final Set<String> SUPPORTED_EVENTS =
Set.of(TASK_CREATED, APPROVAL_REQUESTED, WORKER_RESPONSE_SUBMITTED, TASK_NEEDS_INFO);

private final TaskRepository taskRepository;
private final NotificationRepository notificationRepository;
private final TenantDatabaseContext tenantDatabaseContext;
private final UuidGenerator uuidGenerator;
private final Clock clock;

public NotificationEventHandler(
TaskRepository taskRepository,
NotificationRepository notificationRepository,
TenantDatabaseContext tenantDatabaseContext,
UuidGenerator uuidGenerator,
Clock clock
) {
this.taskRepository = taskRepository;
this.notificationRepository = notificationRepository;
this.tenantDatabaseContext = tenantDatabaseContext;
this.uuidGenerator = uuidGenerator;
this.clock = clock;
}

@Override
public String handlerName() {
return HANDLER_NAME;
}

@Override
public boolean supports(String eventType) {
return SUPPORTED_EVENTS.contains(eventType);
}

@Override
public void handle(DomainEventEnvelope event) {
if (TASK_CREATED.equals(event.eventType())) {
handleTaskCreated(event);
} else if (APPROVAL_REQUESTED.equals(event.eventType())) {
handleApprovalRequested(event);
} else if (WORKER_RESPONSE_SUBMITTED.equals(event.eventType())) {
handleWorkerResponseSubmitted(event);
} else if (TASK_NEEDS_INFO.equals(event.eventType())) {
handleTaskNeedsInfo(event);
}
}

private void handleTaskCreated(DomainEventEnvelope event) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId());
Task task = taskRepository.findByIdAndCompanyId(event.aggregateId(), event.companyId())
.orElseThrow(() -> new IllegalStateException("task not found for TaskCreated event"));

if (task.source() != TaskSource.AI_CANDIDATE) {
return;
}

Notification notification = Notification.create(
uuidGenerator.generate(),
task.companyId(),
event.actorId(),
NotificationTargetType.TASK,
task.taskId(),
"Agent 분석이 완료됐습니다: " + task.title(),
event.occurredAt(),
clock.instant()
);
notificationRepository.insert(notification);
}

private void handleApprovalRequested(DomainEventEnvelope event) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId());
Object taskTitle = event.payload().values().get("task_title");

Notification notification = Notification.create(
uuidGenerator.generate(),
event.companyId(),
event.actorId(),
NotificationTargetType.TASK,
event.aggregateId(),
"승인 요청이 도착했습니다: " + taskTitle,
event.occurredAt(),
clock.instant()
);
notificationRepository.insert(notification);
}

private void handleWorkerResponseSubmitted(DomainEventEnvelope event) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId());
Object taskTitle = event.payload().values().get("task_title");

Notification notification = Notification.create(
uuidGenerator.generate(),
event.companyId(),
event.actorId(),
NotificationTargetType.TASK,
event.aggregateId(),
"문서 제출이 완료됐습니다: " + taskTitle,
event.occurredAt(),
clock.instant()
);
notificationRepository.insert(notification);
}

private void handleTaskNeedsInfo(DomainEventEnvelope event) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(event.companyId());
Object taskTitle = event.payload().values().get("task_title");

Notification notification = Notification.create(
uuidGenerator.generate(),
event.companyId(),
event.actorId(),
NotificationTargetType.TASK,
event.aggregateId(),
"문서 보완이 필요합니다: " + taskTitle,
event.occurredAt(),
clock.instant()
);
notificationRepository.insert(notification);
}
}
Loading
Loading