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
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ int markAsReadByIdAndUserId(
@Param("readAt") LocalDateTime readAt
);

@Modifying(flushAutomatically = true)
@Query("""
update Notification n
set n.isRead = true,
n.readAt = :readAt
where n.user.id = :userId
and n.type = :type
and n.targetType = :targetType
and n.targetId = :videoId
and n.isRead = false
and n.deletedAt is null
""")
int markVideoFeedbackNotificationsAsRead(
@Param("userId") Long userId,
@Param("type") NotificationType type,
@Param("targetType") String targetType,
@Param("videoId") Long videoId,
@Param("readAt") LocalDateTime readAt
);

// 동일 대상의 미읽음 그룹 알림을 잠금 조회해 누적 개수와 문구를 같은 엔티티 상태 기준으로 갱신한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ public void markAllNotificationsAsRead(Long currentUserId) {
notificationRepository.markAllAsReadByUserId(currentUserId, LocalDateTime.now());
}

@Transactional
public void markVideoFeedbackNotificationsAsRead(Long userId, Long videoId) {
validateActiveUser(userId);
validateRequiredId(videoId);

notificationRepository.markVideoFeedbackNotificationsAsRead(
userId,
NotificationType.VIDEO_FEEDBACK_COMMENTED,
NotificationTargetType.VIDEO.name(),
videoId,
LocalDateTime.now()
);
}

/**
* 영상 목록의 미읽은 피드백 누적 개수를 한 번에 조회한다.
*/
Expand Down Expand Up @@ -461,13 +475,15 @@ public void createNoticeCreatedNotifications(
@Transactional
public void createFileUploadedNotifications(
Long projectId,
Long fileId,
String projectTitle,
String fileName,
String uploaderName,
List<Long> recipientIds,
Long actorUserId
) {
validateRequiredId(projectId);
validateRequiredId(fileId);
validateRequiredId(actorUserId);
validateRequiredText(projectTitle);
validateRequiredText(fileName);
Expand All @@ -480,7 +496,7 @@ public void createFileUploadedNotifications(
.title(createFileUploadedTitle(projectTitle))
.content(createFileUploadedContent(fileName, uploaderName))
.targetType(NotificationTargetType.PROJECT_FILE)
.targetId(projectId)
.targetId(fileId)
.excludeUserId(actorUserId)
.build());
}
Expand Down Expand Up @@ -669,7 +685,7 @@ private NotificationListResponse.NotificationSummary toSummary(Notification noti
}

private String createScheduleAssignedContent(String scheduleTitle, String assigneeName) {
return assigneeName + "님이 [" + scheduleTitle + "] 담당자로 지정되었어요";
return assigneeName + "님이 [" + scheduleTitle + "] 담당자로 지명되었어요.";
}

private String createScheduleAssignedTitle(String projectTitle) {
Expand All @@ -681,15 +697,15 @@ private String createProjectJoinedTitle(String projectTitle) {
}

private String createProjectJoinedContent(String joinerName) {
return joinerName + "님이 프로젝트에 합류했어요";
return joinerName + "님이 프로젝트에 합류했어요.";
}

private String createScheduleCreatedTitle(String projectTitle) {
return joinTitle(projectTitle, "새 일정");
}

private String createScheduleCreatedContent(String scheduleTitle, String creatorName) {
return creatorName + "님이 [" + scheduleTitle + "] 일정을 등록했어요";
return creatorName + "님이 [" + scheduleTitle + "] 일정을 등록했어요.";
}

private String createVideoFeedbackCommentedTitle(String projectTitle) {
Expand All @@ -702,10 +718,10 @@ private String createVideoFeedbackCommentedContent(
int groupCount
) {
if (groupCount <= 1) {
return commenterName + "님이 [" + videoTitle + "]에 새로운 피드백을 남겼어요";
return commenterName + "님이 [" + videoTitle + "]에 새로운 피드백을 남겼어요.";
}

return "[" + videoTitle + "]에 새로운 피드백 " + groupCount + "건이 등록되었어요";
return "[" + videoTitle + "]에 새로운 피드백 " + groupCount + "건이 등록되었어요.";
}

private String createRecruitmentAppliedTitle(String projectTitle) {
Expand All @@ -718,26 +734,26 @@ private String createRecruitmentAppliedContent(
int groupCount
) {
if (groupCount <= 1) {
return applicantName + "님이 [" + recruitmentTitle + "]에 지원했어요";
return applicantName + "님이 [" + recruitmentTitle + "]에 지원했어요.";
}

return "[" + recruitmentTitle + "]에 새로운 지원자 " + groupCount + "명이 지원했어요";
return "[" + recruitmentTitle + "]에 새로운 지원자 " + groupCount + "명이 지원했어요.";
}

private String createNoticeCreatedTitle(String projectTitle) {
return joinTitle(projectTitle, "새 공지");
}

private String createNoticeCreatedContent(String noticeTitle, String creatorName) {
return creatorName + "님이 새 공지를 등록했어요: " + noticeTitle;
return creatorName + "님이 새 공지를 등록했어요: " + noticeTitle + ".";
}

private String createFileUploadedTitle(String projectTitle) {
return joinTitle(projectTitle, "새 파일");
}

private String createFileUploadedContent(String fileName, String uploaderName) {
return uploaderName + "님이 [" + fileName + "] 파일을 등록했어요";
return uploaderName + "님이 [" + fileName + "] 파일을 등록했어요.";
}

private String createFallbackTitle(NotificationType type) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.slatto.domain.project.service;

import com.slatto.domain.notification.service.ActivityLogService;
import com.slatto.domain.notification.service.NotificationService;
import com.slatto.domain.project.dto.ProjectFileDownloadResponse;
import com.slatto.domain.project.dto.ProjectFileResponse;
import com.slatto.domain.project.dto.ProjectFileListResponse;
Expand All @@ -12,6 +13,7 @@
import com.slatto.domain.project.entity.ProjectMember;
import com.slatto.domain.project.exception.ProjectErrorCode;
import com.slatto.domain.project.repository.ProjectFileRepository;
import com.slatto.domain.project.repository.ProjectMemberRepository;
import com.slatto.domain.user.entity.Users;
import com.slatto.global.exception.BaseException;
import com.slatto.global.response.code.CommonErrorCode;
Expand Down Expand Up @@ -53,8 +55,10 @@ public class ProjectFileService {

private final ProjectFileRepository projectFileRepository;
private final ProjectAccessValidator projectAccessValidator;
private final ProjectMemberRepository projectMemberRepository;
private final StorageService storageService;
private final ActivityLogService activityLogService;
private final NotificationService notificationService;

public ProjectFileListResponse getProjectFiles(
Long projectId,
Expand Down Expand Up @@ -123,6 +127,15 @@ public ProjectFileResponse uploadProjectFile(

ProjectFile savedFile = projectFileRepository.save(projectFile);
activityLogService.createFileUploadedLog(projectId, currentUserId, savedFile.getId(), savedFile.getFileName());
notificationService.createFileUploadedNotifications(
projectId,
savedFile.getId(),
project.getTitle(),
savedFile.getFileName(),
currentMember.getUser().getNickname(),
getActiveProjectMemberUserIds(projectId),
currentUserId
);

return toResponse(savedFile);
}
Expand Down Expand Up @@ -321,6 +334,14 @@ private int normalizePageSize(int size) {
return Math.min(size, MAX_PAGE_SIZE);
}

private List<Long> getActiveProjectMemberUserIds(Long projectId) {
return projectMemberRepository.findAllActiveMembersByProjectId(projectId)
.stream()
.map(ProjectMember::getUser)
.map(Users::getId)
.toList();
}

private ProjectFileResponse toResponse(ProjectFile projectFile) {
Users uploader = projectFile.getUploader();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import com.slatto.domain.project.repository.ProjectMemberRepository;
import com.slatto.domain.project.repository.ProjectUserRoleRepository;
import com.slatto.domain.notification.service.ActivityLogService;
import com.slatto.domain.notification.service.NotificationService;
import com.slatto.domain.user.entity.Users;
import com.slatto.domain.user.enums.RoleName;
import com.slatto.domain.user.repository.UserRepository;
Expand Down Expand Up @@ -50,6 +51,7 @@ public class ProjectInvitationService {
private final ProjectAccessValidator projectAccessValidator;
private final ProjectInvitationProperties projectInvitationProperties;
private final ActivityLogService activityLogService;
private final NotificationService notificationService;
private final SecureRandom secureRandom = new SecureRandom();

@Transactional
Expand Down Expand Up @@ -117,6 +119,12 @@ public ProjectInvitationAcceptResponse acceptInvitation(
saveProjectRoles(projectMember, roleNames);
projectInvitation.accept(accepter);
activityLogService.createProjectMemberJoinedLog(project.getId(), accepter.getId());
notificationService.createProjectJoinedNotifications(
project.getId(),
project.getTitle(),
accepter.getNickname(),
getActiveProjectMemberUserIds(project.getId())
);

return ProjectInvitationAcceptResponse.builder()
.projectId(project.getId())
Expand All @@ -134,6 +142,14 @@ private void saveProjectRoles(ProjectMember projectMember, List<RoleName> roleNa
projectUserRoleRepository.saveAll(projectUserRoles);
}

private List<Long> getActiveProjectMemberUserIds(Long projectId) {
return projectMemberRepository.findAllActiveMembersByProjectId(projectId)
.stream()
.map(ProjectMember::getUser)
.map(Users::getId)
.toList();
}

private void validateAcceptableInvitation(ProjectInvitation projectInvitation) {
if (projectInvitation.isAccepted()) {
throw new BaseException(ProjectErrorCode.PROJECT_INVITATION_ALREADY_ACCEPTED);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.slatto.domain.project.service;

import com.slatto.domain.notification.service.ActivityLogService;
import com.slatto.domain.notification.service.NotificationService;
import com.slatto.domain.project.dto.ProjectNoticeCreateRequest;
import com.slatto.domain.project.dto.ProjectNoticeListResponse;
import com.slatto.domain.project.dto.ProjectNoticeReadResponse;
Expand All @@ -11,6 +12,7 @@
import com.slatto.domain.project.entity.ProjectNotice;
import com.slatto.domain.project.entity.ProjectNoticeRead;
import com.slatto.domain.project.exception.ProjectErrorCode;
import com.slatto.domain.project.repository.ProjectMemberRepository;
import com.slatto.domain.project.repository.ProjectNoticeReadRepository;
import com.slatto.domain.project.repository.ProjectNoticeRepository;
import com.slatto.domain.user.entity.Users;
Expand All @@ -35,8 +37,10 @@ public class ProjectNoticeService {

private final ProjectNoticeRepository projectNoticeRepository;
private final ProjectNoticeReadRepository projectNoticeReadRepository;
private final ProjectMemberRepository projectMemberRepository;
private final ProjectAccessValidator projectAccessValidator;
private final ActivityLogService activityLogService;
private final NotificationService notificationService;

public ProjectNoticeListResponse getProjectNotices(
Long projectId,
Expand Down Expand Up @@ -95,6 +99,15 @@ public ProjectNoticeResponse createProjectNotice(
);
ProjectNotice savedNotice = projectNoticeRepository.save(projectNotice);
activityLogService.createNoticeCreatedLog(projectId, currentUserId, savedNotice.getId());
notificationService.createNoticeCreatedNotifications(
projectId,
savedNotice.getId(),
project.getTitle(),
savedNotice.getTitle(),
currentMember.getUser().getNickname(),
getActiveProjectMemberUserIds(projectId),
currentUserId
);

return toResponse(savedNotice, false);
}
Expand Down Expand Up @@ -202,6 +215,14 @@ private Map<Long, Boolean> getReadByNoticeId(Long userId, List<ProjectNotice> pr
));
}

private List<Long> getActiveProjectMemberUserIds(Long projectId) {
return projectMemberRepository.findAllActiveMembersByProjectId(projectId)
.stream()
.map(ProjectMember::getUser)
.map(Users::getId)
.toList();
}

private int normalizePageSize(int size) {
if (size <= 0) {
return DEFAULT_PAGE_SIZE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,15 @@ public ScheduleResponse createSchedule(Long currentUserId, ScheduleCreateRequest
writer.getId()
);
if (savedSchedule.isProjectSchedule()) {
notificationService.createScheduleCreatedNotifications(
project.getId(),
savedSchedule.getId(),
project.getTitle(),
savedSchedule.getTitle(),
writer.getNickname(),
getActiveProjectMemberUserIds(project.getId()),
writer.getId()
);
activityLogService.createScheduleCreatedLog(
project.getId(),
currentUserId,
Expand Down Expand Up @@ -377,6 +386,14 @@ private List<Users> getProjectParticipantUsers(Long projectId, List<Long> partic
.toList();
}

private List<Long> getActiveProjectMemberUserIds(Long projectId) {
return projectMemberRepository.findAllActiveMembersByProjectId(projectId)
.stream()
.map(ProjectMember::getUser)
.map(Users::getId)
.toList();
}

private Users getActiveUser(Long userId) {
return userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public class VideoService {
private final YoutubeUrlParser youtubeUrlParser;
private final YoutubeApiClient youtubeApiClient;

@Transactional
public VideoDetailResDTO getVideo(Long memberId, Long projectId, Long videoId) {
if (!projectAccessRepository.projectExistsById(projectId)) {
throw new BaseException(CommonErrorCode.NOT_FOUND);
Expand All @@ -75,6 +76,8 @@ public VideoDetailResDTO getVideo(Long memberId, Long projectId, Long videoId) {
projectAccessRepository.findProjectRoleNames(projectId)
);

notificationService.markVideoFeedbackNotificationsAsRead(memberId, videoId);

return VideoDetailResDTO.from(video, bookmarked, projectTags);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- project_file 레거시 컬럼 제거 (file_url, is_pinned)
--
-- 배경:
-- a833792(2026-07-23) "프로젝트 파일 도메인 모델 정리"에서 ProjectFile 엔티티의
-- file_url -> storage_key, isPinned(boolean) -> pinnedAt(nullable) 으로 모델을 바꿨으나
-- 대응 마이그레이션이 없었다. 운영 DB는 ddl-auto=update로 생성돼 신규 컬럼만 추가되고
-- 구 컬럼 두 개가 NOT NULL / DEFAULT 없음 상태로 남았다.
-- 현재 INSERT문은 두 컬럼을 포함하지 않으므로 파일 업로드가 전건 실패한다.
-- Field 'file_url' doesn't have a default value
-- ddl-auto=validate는 "엔티티에 있는데 DB에 없는 컬럼"만 검사하고 그 반대는 보지 않아
-- 이 상태를 잡아내지 못했다.
--
-- 안전성:
-- 삭제 시점 기준 project_file 행 수 0건이므로 데이터 손실 없음.
--
-- 주의:
-- MySQL 8.4에는 DROP COLUMN IF EXISTS가 없다(MariaDB 전용 문법). 조건 없이 삭제한다.
-- 따라서 두 컬럼이 존재하는 DB에서만 성공한다. 운영 DB에서 직접 DROP을 먼저 실행하면
-- 이 마이그레이션이 ERROR 1091로 실패해 이후 배포가 모두 막히므로 절대 병행하지 말 것.
--
-- 버전 번호:
-- V011은 d5191f6에서 activity_log 인덱스용으로 쓰였다가 f1e99f0에서 삭제된 이력이 있어
-- 재사용하지 않는다. 번호를 비워도 Flyway 동작에는 영향이 없다.
ALTER TABLE project_file
DROP COLUMN file_url,
DROP COLUMN is_pinned;
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;

import java.time.LocalDate;
Expand Down Expand Up @@ -56,6 +57,9 @@ class ProjectNoticeActivityFlowIntegrationTest {
@Autowired
private ProjectActivityReadRepository projectActivityReadRepository;

@MockBean
private NotificationService notificationService;

@Autowired
private UserRepository userRepository;

Expand Down
Loading