diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java index 670cf2b0..81958234 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleQueryService.java @@ -20,7 +20,7 @@ public class UserTeamRoleQueryService { private final UserTeamRoleRepository userTeamRoleRepository; private final ProjectUserRepository projectUserRepository; - // 마이페이지 팀 파트 조회(UserTeamRole 사용) + // 마이페이지 팀 파트 조회(UserTeamRole 사용), 프로젝트 멤버면 조회 가능 @Transactional(readOnly = true) public UserTeamRolesResDto readMyPageParts(Long projectId, Long requesterUserId) { @@ -37,7 +37,7 @@ public UserTeamRolesResDto readMyPageParts(Long projectId, Long requesterUserId) } List roles = - userTeamRoleRepository.findAllByProject_IdAndUser_UserIdAndDeletedAtIsNullOrderByIdAsc(projectId, requesterUserId); + userTeamRoleRepository.findAllByProject_IdAndDeletedAtIsNullOrderByIdAsc(projectId); List parts = roles.stream() .map(r -> new UserTeamRolesResDto.PartDto( diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java index 586504da..8e1ca5e4 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/UserTeamRoleService.java @@ -24,7 +24,6 @@ public class UserTeamRoleService { private static final int DEFAULT_REQUIRED_COUNT = 1; private final UserTeamRoleRepository userTeamRoleRepository; - private final UserRepository userRepository; private final ProjectRepository projectRepository; private final ProjectUserRepository projectUserRepository; @@ -76,8 +75,7 @@ public UserTeamRoleCreateResDto create(Long projectId, Long userId, UserTeamRole } boolean duplicate = userTeamRoleRepository - .existsByProject_IdAndUser_UserIdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( - projectId, userId, roleField, customName); + .existsByProject_IdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull(projectId, roleField, customName); if(duplicate) { throw new UserTeamRoleException( @@ -87,7 +85,7 @@ public UserTeamRoleCreateResDto create(Long projectId, Long userId, UserTeamRole } }else { boolean duplicate = userTeamRoleRepository - .existsByProject_IdAndUser_UserIdAndRoleFieldAndDeletedAtIsNull(projectId, userId, roleField); + .existsByProject_IdAndRoleFieldAndDeletedAtIsNull(projectId, roleField); if (duplicate) { throw new UserTeamRoleException( @@ -99,11 +97,6 @@ public UserTeamRoleCreateResDto create(Long projectId, Long userId, UserTeamRole customName = null; } - // 유저 조회 - User user = userRepository.findById(userId) - .orElseThrow(() -> new UserTeamRoleException(UserTeamRoleErrorCode.USER_NOT_FOUND, - "userId=" + userId - )); Project project = projectRepository.findById(projectId) .orElseThrow(() -> new UserTeamRoleException(UserTeamRoleErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId)); @@ -111,7 +104,6 @@ public UserTeamRoleCreateResDto create(Long projectId, Long userId, UserTeamRole UserTeamRole saved = userTeamRoleRepository.save( UserTeamRole.builder() .project(project) - .user(user) .roleField(roleField) .customRoleFieldName(customName) .requiredCount(requiredCount) @@ -188,8 +180,8 @@ public UserTeamRoleUpdateResDto update(Long projectId, Long userId, Long userTea String oldName = role.getCustomRoleFieldName(); if (oldName == null || !oldName.equalsIgnoreCase(newName)) { boolean duplicate = userTeamRoleRepository - .existsByProject_IdAndUser_UserIdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( - projectId, role.getUser().getUserId(), RoleField.CUSTOM, newName + .existsByProject_IdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( + projectId, RoleField.CUSTOM, newName ); if (duplicate) { diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentMetaDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentMetaDto.java new file mode 100644 index 00000000..433eb737 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentMetaDto.java @@ -0,0 +1,21 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.api.domain.team.process.enums.AttachmentType; +import com.nect.core.entity.team.enums.FileExt; + +import java.time.LocalDateTime; + +public record AttachmentMetaDto( + @JsonProperty("type") + AttachmentType type, + + @JsonProperty("document_id") + Long documentId, + + @JsonProperty("attached_at") + LocalDateTime attachedAt, + + @JsonProperty("file_ext") + FileExt fileExt +) {} \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentSummaryDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentSummaryDto.java new file mode 100644 index 00000000..27259f73 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/AttachmentSummaryDto.java @@ -0,0 +1,24 @@ +package com.nect.api.domain.team.process.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import com.nect.core.entity.team.enums.FileExt; + +public record AttachmentSummaryDto( + @JsonProperty("total_count") + long totalCount, + + @JsonProperty("file_count") + long fileCount, + + @JsonProperty("link_count") + long linkCount, + + @JsonProperty("file_extensions") + List fileExtensions +) { + public static AttachmentSummaryDto empty() { + return new AttachmentSummaryDto(0, 0, 0, List.of()); + } +} + diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java index b3471504..89f40eb7 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/dto/res/ProcessCardResDto.java @@ -44,5 +44,11 @@ public record ProcessCardResDto( boolean hasOpenFeedback, @JsonProperty("assignee") - List assignee + List assignee, + + @JsonProperty("attachment_summary") + AttachmentSummaryDto attachmentSummary, + + @JsonProperty("attachments_meta") + List attachmentsMeta ) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java index cbb179c9..212a525d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessService.java @@ -15,6 +15,7 @@ import com.nect.core.entity.notifications.enums.NotificationType; import com.nect.core.entity.team.ProjectUser; import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; import com.nect.core.entity.team.history.enums.HistoryAction; import com.nect.core.entity.team.history.enums.HistoryTargetType; import com.nect.core.entity.team.Project; @@ -31,10 +32,7 @@ import com.nect.core.repository.team.ProjectTeamRoleRepository; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.team.SharedDocumentRepository; -import com.nect.core.repository.team.process.ProcessFeedbackRepository; -import com.nect.core.repository.team.process.ProcessLaneOrderRepository; -import com.nect.core.repository.team.process.ProcessMentionRepository; -import com.nect.core.repository.team.process.ProcessRepository; +import com.nect.core.repository.team.process.*; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -60,6 +58,7 @@ public class ProcessService { private final ProcessLaneOrderRepository processLaneOrderRepository; private final ProjectTeamRoleRepository projectTeamRoleRepository; private final ProcessFeedbackRepository processFeedbackRepository; + private final ProcessSharedDocumentRepository processSharedDocumentRepository; private final S3Service s3Service; private final ProcessLaneOrderService processLaneOrderService; @@ -1413,6 +1412,43 @@ public void deleteProcess(Long projectId, Long userId, Long processId) { ); } + private Map buildAttachmentSummaryMap(List processIds) { + if (processIds == null || processIds.isEmpty()) return Map.of(); + + var rows = processSharedDocumentRepository.aggregateAttachmentsByProcessIds(processIds); + + Map fileCount = new HashMap<>(); + Map linkCount = new HashMap<>(); + Map> extSet = new HashMap<>(); + + for (var r : rows) { + Long pid = r.getProcessId(); + DocumentType type = r.getDocumentType(); + FileExt ext = r.getFileExt(); + long cnt = (r.getCnt() == null) ? 0L : r.getCnt(); + + if (type == DocumentType.LINK) { + linkCount.put(pid, linkCount.getOrDefault(pid, 0L) + cnt); + } else { // FILE + fileCount.put(pid, fileCount.getOrDefault(pid, 0L) + cnt); + if (ext != null) { + extSet.computeIfAbsent(pid, k -> new LinkedHashSet<>()).add(ext); + } + } + } + + Map out = new HashMap<>(); + for (Long pid : processIds) { + long f = fileCount.getOrDefault(pid, 0L); + long l = linkCount.getOrDefault(pid, 0L); + + List exts = extSet.getOrDefault(pid, new LinkedHashSet<>()).stream().toList(); + + out.put(pid, new AttachmentSummaryDto(f + l, f, l, exts)); + } + return out; + } + private LocalDate normalizeWeekStart(LocalDate date) { if (date == null) { throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "startDate must not be null"); @@ -1420,7 +1456,12 @@ private LocalDate normalizeWeekStart(LocalDate date) { return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } - private ProcessCardResDto toProcessCardResDTO(Process p, boolean hasOpenFeedback) { + private ProcessCardResDto toProcessCardResDTO( + Process p, + boolean hasOpenFeedback, + AttachmentSummaryDto attachmentSummary, + List attachmentsMeta + ) { int whole = (p.getTaskItems() == null) ? 0 : p.getTaskItems().size(); int done = (p.getTaskItems() == null) ? 0 : (int) p.getTaskItems().stream() .filter(ProcessTaskItem::isDone) @@ -1432,7 +1473,7 @@ private ProcessCardResDto toProcessCardResDTO(Process p, boolean hasOpenFeedbac .filter(pf -> pf.getDeletedAt() == null) .map(ProcessField::getRoleField) .filter(Objects::nonNull) - .filter(rf -> rf != RoleField.CUSTOM) // 커스텀은 별도 리스트로 + .filter(rf -> rf != RoleField.CUSTOM) .distinct() .toList(); @@ -1452,8 +1493,7 @@ private ProcessCardResDto toProcessCardResDTO(Process p, boolean hasOpenFeedbac .map(pu -> { User u = pu.getUser(); String profileUrl = (u == null) ? null : toPresignedUserImage(u.getProfileImageName()); - String nickname = u.getNickname(); - return new AssigneeResDto(u.getUserId(), u.getName(), nickname, profileUrl); + return new AssigneeResDto(u.getUserId(), u.getName(), u.getNickname(), profileUrl); }) .toList(); @@ -1472,10 +1512,53 @@ private ProcessCardResDto toProcessCardResDTO(Process p, boolean hasOpenFeedbac customFields, missionNumber, hasOpenFeedback, - assignees + assignees, + (attachmentSummary == null ? AttachmentSummaryDto.empty() : attachmentSummary), + (attachmentsMeta == null ? List.of() : attachmentsMeta) ); } + private Map> buildAttachmentMetaMap(List processIds) { + if (processIds == null || processIds.isEmpty()) return Map.of(); + + var rows = processSharedDocumentRepository.findAttachmentMetasByProcessIds(processIds); + + Map> out = new HashMap<>(); + + for (var r : rows) { + Long pid = r.getProcessId(); + if (pid == null) continue; + + AttachmentType type = (r.getDocumentType() == DocumentType.LINK) + ? AttachmentType.LINK + : AttachmentType.FILE; + + LocalDateTime attachedAt = (r.getAttachedAt() != null) + ? r.getAttachedAt() + : r.getCreatedAt(); + + AttachmentMetaDto dto = new AttachmentMetaDto( + type, + r.getDocumentId(), + attachedAt, + r.getFileExt() // LINK면 null 가능 + ); + + out.computeIfAbsent(pid, k -> new ArrayList<>()).add(dto); + } + + // attachedAt desc 정렬 (원하는 정책) + out.replaceAll((pid, list) -> + list.stream() + .sorted(Comparator.comparing( + (AttachmentMetaDto m) -> m.attachedAt() == null ? LocalDateTime.MIN : m.attachedAt() + ).reversed()) + .toList() + ); + + return out; + } + private Integer calcLeftDay(LocalDate deadLine) { if (deadLine == null) return null; long diff = ChronoUnit.DAYS.between(LocalDate.now(), deadLine); @@ -1603,20 +1686,34 @@ public ProcessWeeksResDto getWeekProcesses(Long projectId, Long userId, LocalDat List processes = processRepository.findAllInRangeOrdered(projectId, rangeStart, rangeEnd); if (processes == null) processes = List.of(); - Set openFeedbackProcessIds = new HashSet<>(); - if (!processes.isEmpty()) { + final Set openFeedbackProcessIds; + final Map attachmentSummaryMap; + final Map> attachmentMetaMap; + + if (processes.isEmpty()) { + openFeedbackProcessIds = Set.of(); + attachmentSummaryMap = Map.of(); + attachmentMetaMap = Map.of(); + } else { List processIds = processes.stream() .map(Process::getId) .filter(Objects::nonNull) .toList(); - if (!processIds.isEmpty()) { - openFeedbackProcessIds.addAll( - processFeedbackRepository.findOpenFeedbackProcessIds(processIds) - ); - } + openFeedbackProcessIds = processIds.isEmpty() + ? Set.of() + : new HashSet<>(processFeedbackRepository.findOpenFeedbackProcessIds(processIds)); + + attachmentSummaryMap = processIds.isEmpty() + ? Map.of() + : buildAttachmentSummaryMap(processIds); + + attachmentMetaMap = processIds.isEmpty() + ? Map.of() + : buildAttachmentMetaMap(processIds); } + // 프로세스를 주차별로 묶기 // startAt이 null이면 rangeStart 주로 보내거나, common 처리 가능 Map> byWeek = new LinkedHashMap<>(); @@ -1644,7 +1741,9 @@ public ProcessWeeksResDto getWeekProcesses(Long projectId, Long userId, LocalDat List cards = entry.getValue().stream() .map(p -> { boolean hasOpenFeedback = openFeedbackProcessIds.contains(p.getId()); - return toProcessCardResDTO(p, hasOpenFeedback); + AttachmentSummaryDto summary = attachmentSummaryMap.getOrDefault(p.getId(), AttachmentSummaryDto.empty()); + List metas = attachmentMetaMap.getOrDefault(p.getId(), List.of()); + return toProcessCardResDTO(p, hasOpenFeedback, summary, metas); }) .toList(); @@ -1727,7 +1826,7 @@ public ProcessPartResDto getPartProcesses(Long projectId, Long userId, String la */ // lane 대상 프로세스 목록 - List laneProcesses = List.of(); + List laneProcesses; if (TEAM_LANE_KEY.equals(dbLaneKey)) { // 팀(전체) laneProcesses = processRepository.findAllForTeamBoard(projectId); @@ -1746,25 +1845,40 @@ public ProcessPartResDto getPartProcesses(Long projectId, Long userId, String la throw new ProcessException(ProcessErrorCode.INVALID_REQUEST, "invalid lane_key prefix. laneKey=" + laneKey); } - Set openFeedbackProcessIds = Collections.emptySet(); - if (laneProcesses != null && !laneProcesses.isEmpty()) { + if (laneProcesses == null) laneProcesses = List.of(); + + final Set openFeedbackProcessIds; + final Map attachmentSummaryMap; + final Map> attachmentMetaMap; + + if (laneProcesses.isEmpty()) { + openFeedbackProcessIds = Set.of(); + attachmentSummaryMap = Map.of(); + attachmentMetaMap = Map.of(); + } else { List processIds = laneProcesses.stream() .map(Process::getId) .filter(Objects::nonNull) .toList(); - if (!processIds.isEmpty()) { - openFeedbackProcessIds = new HashSet<>( - processFeedbackRepository.findOpenFeedbackProcessIds(processIds) - ); - } + openFeedbackProcessIds = processIds.isEmpty() + ? Set.of() + : new HashSet<>(processFeedbackRepository.findOpenFeedbackProcessIds(processIds)); + + attachmentSummaryMap = processIds.isEmpty() + ? Map.of() + : buildAttachmentSummaryMap(processIds); + + attachmentMetaMap = processIds.isEmpty() + ? Map.of() + : buildAttachmentMetaMap(processIds); } List groups = List.of( - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.PLANNING, laneProcesses, openFeedbackProcessIds), - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.IN_PROGRESS, laneProcesses, openFeedbackProcessIds), - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.DONE, laneProcesses, openFeedbackProcessIds), - buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.BACKLOG, laneProcesses, openFeedbackProcessIds) + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.PLANNING, laneProcesses, openFeedbackProcessIds, attachmentSummaryMap, attachmentMetaMap), + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.IN_PROGRESS, laneProcesses, openFeedbackProcessIds, attachmentSummaryMap, attachmentMetaMap), + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.DONE, laneProcesses, openFeedbackProcessIds, attachmentSummaryMap, attachmentMetaMap), + buildStatusGroupOrdered(projectId, dbLaneKey, ProcessStatus.BACKLOG, laneProcesses, openFeedbackProcessIds, attachmentSummaryMap, attachmentMetaMap) ); return new ProcessPartResDto(toApiLaneKey(dbLaneKey), groups); @@ -1775,32 +1889,30 @@ private ProcessStatusGroupResDto buildStatusGroupOrdered( String laneKey, ProcessStatus status, List laneProcessesAll, - Set openFeedbackProcessIds + Set openFeedbackProcessIds, + Map attachmentSummaryMap, + Map> attachmentMetaMap ) { - // 해당 status인 프로세스만 List laneProcesses = laneProcessesAll.stream() .filter(p -> p.getStatus() == status) .toList(); - // order row 없으면 생성 (tail 부여) - processLaneOrderService.ensureLaneOrderRowsExistWriteTx( - projectId, status, laneKey, laneProcesses - ); + processLaneOrderService.ensureLaneOrderRowsExistWriteTx(projectId, status, laneKey, laneProcesses); - // order row 기준 processId 순서 확보 List orders = processLaneOrderRepository.findLaneOrders(projectId, laneKey, status); List orderedIds = orders.stream().map(o -> o.getProcess().getId()).toList(); - // 혹시라도 (order에는 있는데 laneProcesses에는 없는) 케이스 방어 - // laneProcesses 기준으로 map - Map map = laneProcesses.stream().collect(Collectors.toMap(Process::getId, p -> p)); + Map map = laneProcesses.stream() + .collect(Collectors.toMap(Process::getId, p -> p)); List cards = new ArrayList<>(); for (Long id : orderedIds) { Process p = map.get(id); if (p != null) { boolean hasOpenFeedback = openFeedbackProcessIds.contains(p.getId()); - cards.add(toProcessCardResDTO(p, hasOpenFeedback)); + AttachmentSummaryDto summary = attachmentSummaryMap.getOrDefault(p.getId(), AttachmentSummaryDto.empty()); + List metas = attachmentMetaMap.getOrDefault(p.getId(), List.of()); + cards.add(toProcessCardResDTO(p, hasOpenFeedback, summary, metas)); } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java index 2308d08d..70e400f0 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/ProcessTaskItemService.java @@ -193,7 +193,15 @@ public ProcessTaskItemResDto create(Long projectId, Long userId, Long processId, public ProcessTaskItemResDto update(Long projectId, Long userId, Long processId, Long taskItemId, ProcessTaskItemUpsertReqDto req) { assertWritableMember(projectId, userId); - getActiveProcess(projectId, processId); + Process process = getActiveProcess(projectId, processId); + + if (process.getProcessType() == ProcessType.WEEK_MISSION) { + throw new ProcessException( + ProcessErrorCode.WEEK_MISSION_FORBIDDEN, + "위크 미션 TASK는 processes 경로에서 수정할 수 없습니다. projectId=" + projectId + ", processId=" + processId + ); + } + ProcessTaskItem item = getTaskItem(processId, taskItemId); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java index a4efdb06..012be924 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/process/service/WeekMissionService.java @@ -508,20 +508,22 @@ public ProcessTaskItemResDto updateWeekMissionTaskItem( meta.put("title", process.getTitle()); meta.put("taskItemId", item.getId()); - meta.put("before", Map.of( - "content", beforeContent, - "isDone", beforeDone, - "sortOrder", beforeSortOrder, - "roleField", beforeRole == null ? null : beforeRole.name(), - "customRoleFieldName", beforeCustom - )); - meta.put("after", Map.of( - "content", item.getContent(), - "isDone", item.isDone(), - "sortOrder", item.getSortOrder(), - "roleField", item.getRoleField() == null ? null : item.getRoleField().name(), - "customRoleFieldName", item.getCustomRoleFieldName() - )); + Map beforeMap = new LinkedHashMap<>(); + beforeMap.put("content", beforeContent); + beforeMap.put("isDone", beforeDone); + beforeMap.put("sortOrder", beforeSortOrder); + beforeMap.put("roleField", beforeRole == null ? null : beforeRole.name()); + beforeMap.put("customRoleFieldName", beforeCustom); + + Map afterMap = new LinkedHashMap<>(); + afterMap.put("content", item.getContent()); + afterMap.put("isDone", item.isDone()); + afterMap.put("sortOrder", item.getSortOrder()); + afterMap.put("roleField", item.getRoleField() == null ? null : item.getRoleField().name()); + afterMap.put("customRoleFieldName", item.getCustomRoleFieldName()); + + meta.put("before", beforeMap); + meta.put("after", afterMap); publishWeekMissionHistory( projectId, userId, processId, diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java index 20416441..1d44d8e9 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentController.java @@ -1,6 +1,8 @@ package com.nect.api.domain.team.workspace.controller; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentLinkCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentCreatedResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; @@ -10,8 +12,10 @@ import com.nect.api.global.security.UserDetailsImpl; import com.nect.core.entity.team.enums.DocumentType; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -73,4 +77,26 @@ public ApiResponse deleteSharedDocument( facade.delete(projectId, userId, documentId); return ApiResponse.ok(null); } + + // 파일 첨부 + 업로드 + @PostMapping(value = "/shared-documents/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadSharedDocumentFile( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestPart("file") MultipartFile file + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(facade.uploadFile(projectId, userId, file)); + } + + // 링크 첨부 + @PostMapping("/shared-documents/links") + public ApiResponse createSharedDocumentLink( + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody SharedDocumentLinkCreateReqDto req + ) { + Long userId = userDetails.getUserId(); + return ApiResponse.ok(facade.createLink(projectId, userId, req)); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentLinkCreateReqDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentLinkCreateReqDto.java new file mode 100644 index 00000000..9da0f94d --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/req/SharedDocumentLinkCreateReqDto.java @@ -0,0 +1,11 @@ +package com.nect.api.domain.team.workspace.dto.req; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SharedDocumentLinkCreateReqDto( + @JsonProperty("title") + String title, + + @JsonProperty("link_url") + String linkUrl +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentCreatedResDto.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentCreatedResDto.java new file mode 100644 index 00000000..087f0293 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/dto/res/SharedDocumentCreatedResDto.java @@ -0,0 +1,31 @@ +package com.nect.api.domain.team.workspace.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; + +public record SharedDocumentCreatedResDto( + @JsonProperty("document_id") + Long documentId, + + @JsonProperty("document_type") + DocumentType documentType, + + @JsonProperty("title") + String title, + + @JsonProperty("link_url") + String linkUrl, + + @JsonProperty("file_name") + String fileName, + + @JsonProperty("file_ext") + FileExt fileExt, + + @JsonProperty("file_size") + Long fileSize, + + @JsonProperty("download_url") + String downloadUrl +) {} diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java index 5ea6fb73..200a410d 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/facade/BoardsSharedDocumentFacade.java @@ -1,20 +1,40 @@ package com.nect.api.domain.team.workspace.facade; +import com.nect.api.domain.team.file.dto.res.FileUploadResDto; +import com.nect.api.domain.team.file.service.FileService; +import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentLinkCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentCreatedResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; +import com.nect.api.domain.team.workspace.enums.BoardsErrorCode; import com.nect.api.domain.team.workspace.enums.SharedDocumentsSort; +import com.nect.api.domain.team.workspace.exception.BoardsException; import com.nect.api.domain.team.workspace.service.BoardsSharedDocumentService; +import com.nect.core.entity.team.SharedDocument; import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.history.enums.HistoryAction; +import com.nect.core.entity.team.history.enums.HistoryTargetType; +import com.nect.core.entity.user.User; +import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.LinkedHashMap; +import java.util.Map; @Service @RequiredArgsConstructor public class BoardsSharedDocumentFacade { + private final FileService fileService; private final BoardsSharedDocumentService service; + private final ProjectHistoryPublisher projectHistoryPublisher; + private final UserRepository userRepository; public SharedDocumentsPreviewResDto getPreview(Long projectId, Long userId, int limit) { return service.getPreview(projectId, userId, limit); @@ -36,4 +56,59 @@ public void delete(Long projectId, Long userId, Long documentId) { service.delete(projectId, userId, documentId); } + + // 공유 문서함: 파일 업로드 + @Transactional + public SharedDocumentCreatedResDto uploadFile(Long projectId, Long userId, MultipartFile file) { + FileUploadResDto uploaded = fileService.upload(projectId, userId, file); + + + Map meta = new LinkedHashMap<>(); + meta.put("documentId", uploaded.fileId()); + meta.put("type", "FILE"); + meta.put("title", uploaded.fileName()); + meta.put("fileName", uploaded.fileName()); + meta.put("fileExt", uploaded.fileType()); + meta.put("fileSize", uploaded.fileSize()); + + projectHistoryPublisher.publish( + projectId, + userId, + HistoryAction.DOCUMENT_CREATED, + HistoryTargetType.DOCUMENT, + uploaded.fileId(), + meta + ); + + return new SharedDocumentCreatedResDto( + uploaded.fileId(), + DocumentType.FILE, + uploaded.fileName(), + null, + uploaded.fileName(), + uploaded.fileType(), + uploaded.fileSize(), + uploaded.downloadUrl() + ); + } + + // 공유 문서함: 링크 생성 + @Transactional + public SharedDocumentCreatedResDto createLink(Long projectId, Long userId, SharedDocumentLinkCreateReqDto req) { + User actor = userRepository.findById(userId) + .orElseThrow(() -> new BoardsException(BoardsErrorCode.USER_NOT_FOUND, "userId=" + userId)); + + SharedDocument saved = service.createLink(projectId, userId, req, actor); + + return new SharedDocumentCreatedResDto( + saved.getId(), + saved.getDocumentType(), // LINK + saved.getTitle(), + saved.getLinkUrl(), + null, + null, + 0L, + null + ); + } } \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java index a58812d5..f6778011 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/BoardsSharedDocumentService.java @@ -1,6 +1,7 @@ package com.nect.api.domain.team.workspace.service; import com.nect.api.domain.team.history.service.ProjectHistoryPublisher; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentLinkCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; @@ -258,4 +259,50 @@ public void delete(Long projectId, Long userId, Long documentId) { meta ); } + + // 링크 생성 서비스 + @Transactional + public SharedDocument createLink(Long projectId, Long userId, SharedDocumentLinkCreateReqDto req, User actor) { + + if (req == null || req.linkUrl() == null || req.linkUrl().isBlank()) { + throw new BoardsException(BoardsErrorCode.INVALID_REQUEST, "link_url is required"); + } + if (req.title() == null || req.title().isBlank()) { + throw new BoardsException(BoardsErrorCode.INVALID_REQUEST, "title is required"); + } + + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BoardsException(BoardsErrorCode.PROJECT_NOT_FOUND, "projectId=" + projectId)); + + if (!projectUserRepository.existsByProjectIdAndUserId(projectId, userId)) { + throw new BoardsException(BoardsErrorCode.PROJECT_MEMBER_FORBIDDEN, + "projectId=" + projectId + ", userId=" + userId); + } + + SharedDocument doc = SharedDocument.ofLink( + actor, + project, + req.title().trim(), + req.linkUrl().trim() + ); + + SharedDocument saved = sharedDocumentRepository.save(doc); + + Map meta = new LinkedHashMap<>(); + meta.put("documentId", saved.getId()); + meta.put("type", "LINK"); + meta.put("title", saved.getTitle()); + meta.put("url", saved.getLinkUrl()); + + historyPublisher.publish( + projectId, + userId, + HistoryAction.LINK_CREATED, + HistoryTargetType.DOCUMENT, + saved.getId(), + meta + ); + + return saved; + } } \ No newline at end of file diff --git a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java index 410b1fff..fca638ab 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/workspace/service/PostService.java @@ -25,6 +25,7 @@ import com.nect.core.entity.user.User; import com.nect.core.repository.team.ProjectRepository; import com.nect.core.repository.team.ProjectUserRepository; +import com.nect.core.repository.team.process.ProcessSharedDocumentRepository; import com.nect.core.repository.team.workspace.PostLikeRepository; import com.nect.core.repository.team.workspace.PostMentionRepository; import com.nect.core.repository.team.workspace.PostRepository; @@ -38,6 +39,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.*; @Service @@ -51,6 +53,7 @@ public class PostService { private final PostLikeRepository postLikeRepository; private final PostMentionRepository postMentionRepository; private final PostSharedDocumentRepository postSharedDocumentRepository; + private final ProcessSharedDocumentRepository processSharedDocumentRepository; private final ProjectHistoryPublisher historyPublisher; private final NotificationFacade notificationFacade; @@ -585,6 +588,28 @@ public void deletePost(Long projectId, Long userId, Long postId) { .orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND, "projectId=" + projectId + ", postId=" + postId)); + // 삭제 전 첨부 목록 조회 + List attached = postSharedDocumentRepository.findAllActiveByPostIdWithDocument(postId); + + // soft delete + post.softDelete(); + + LocalDateTime now = LocalDateTime.now(); + + for (PostSharedDocument psd : attached) { + // 연결부터 끊기 + psd.softDelete(); + + SharedDocument doc = psd.getDocument(); + if (doc == null || doc.getDeletedAt() != null) continue; + + // 공유문서함에서도 삭제 + doc.softDelete(); + + // 프로세스 첨부도 끊기 + processSharedDocumentRepository.softDeleteAllAttachments(projectId, doc.getId()); + } + // 작성자만 삭제 가능 Long authorId = post.getAuthor().getUserId(); if (!authorId.equals(userId)) { @@ -596,9 +621,6 @@ public void deletePost(Long projectId, Long userId, Long postId) { final PostType beforeType = post.getPostType(); final String beforeTitle = post.getTitle(); - // soft delete - post.softDelete(); - // 멘션도 soft delete 처리 postMentionRepository.findAllByPostId(post.getId()).forEach(PostMention::softDelete); diff --git a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java index 6ec1f3b7..593bc90a 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/process/controller/ProcessControllerTest.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.nect.api.domain.team.process.dto.req.*; import com.nect.api.domain.team.process.dto.res.*; +import com.nect.api.domain.team.process.enums.AttachmentType; import com.nect.api.domain.team.process.enums.LaneType; import com.nect.api.domain.team.process.service.ProcessService; import com.nect.api.global.jwt.JwtUtil; @@ -23,6 +24,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; @@ -597,6 +599,37 @@ void getWeekProcesses() throws Exception { long projectId = 1L; long userId = 1L; + AttachmentSummaryDto summary1 = new AttachmentSummaryDto( + 3L, // total + 2L, // file + 1L, // link + List.of(FileExt.PDF, FileExt.PNG) + ); + + List metas1 = List.of( + new AttachmentMetaDto( + AttachmentType.FILE, + 9001L, + LocalDateTime.of(2026, 1, 20, 10, 0), + FileExt.PDF + ), + new AttachmentMetaDto( + AttachmentType.LINK, + 9002L, + LocalDateTime.of(2026, 1, 20, 11, 0), + null + ), + new AttachmentMetaDto( + AttachmentType.FILE, + 9003L, + LocalDateTime.of(2026, 1, 20, 12, 0), + FileExt.PNG + ) + ); + + AttachmentSummaryDto summaryEmpty = AttachmentSummaryDto.empty(); + List metasEmpty = List.of(); + ProcessCardResDto common1 = new ProcessCardResDto( 101L, ProcessStatus.PLANNING, @@ -613,7 +646,9 @@ void getWeekProcesses() throws Exception { List.of( new AssigneeResDto(1L, "홍길동", "길동", "https://img.com/u1.png"), new AssigneeResDto(2L, "김철수", "철수", null) - ) + ), + summary1, + metas1 ); ProcessCardResDto common2 = new ProcessCardResDto( @@ -631,7 +666,9 @@ void getWeekProcesses() throws Exception { true, List.of( new AssigneeResDto(3L, "박영희", "영희", "https://img.com/u3.png") - ) + ), + summaryEmpty, + metasEmpty ); ProcessCardResDto backend1 = new ProcessCardResDto( @@ -649,6 +686,15 @@ void getWeekProcesses() throws Exception { true, List.of( new AssigneeResDto(1L, "홍길동", "길동", "https://img.com/u1.png") + ), + new AttachmentSummaryDto(1L, 0L, 1L, List.of()), + List.of( + new AttachmentMetaDto( + AttachmentType.LINK, + 9101L, + LocalDateTime.of(2026, 1, 21, 9, 0), + null + ) ) ); @@ -667,7 +713,9 @@ void getWeekProcesses() throws Exception { false, List.of( new AssigneeResDto(4L, "이민수", "민수", null) - ) + ), + summaryEmpty, + metasEmpty ); FieldGroupResDto fgBackend = new FieldGroupResDto( @@ -705,6 +753,11 @@ void getWeekProcesses() throws Exception { false, List.of( new AssigneeResDto(2L, "김철수", "철수", "https://img.com/u2.png") + ), + new AttachmentSummaryDto(2L, 2L, 0L, List.of(FileExt.JPG, FileExt.SVG)), + List.of( + new AttachmentMetaDto(AttachmentType.FILE, 9201L, LocalDateTime.of(2026, 1, 27, 13, 0), FileExt.JPG), + new AttachmentMetaDto(AttachmentType.FILE, 9202L, LocalDateTime.of(2026, 1, 27, 13, 10), FileExt.SVG) ) ); @@ -717,7 +770,7 @@ void getWeekProcesses() throws Exception { ProcessWeekResDto w2 = new ProcessWeekResDto( LocalDate.of(2026, 1, 26), - List.of(), // common lane empty + List.of(), List.of(fgPlanner) ); @@ -762,48 +815,74 @@ void getWeekProcesses() throws Exception { fieldWithPath("body.weeks[].start_date").type(STRING).description("주 시작일(yyyy-MM-dd)"), - fieldWithPath("body.weeks[].common_lane").type(ARRAY).description("공통 레인 프로세스 카드 목록"), - fieldWithPath("body.weeks[].common_lane[].process_id").type(NUMBER).description("프로세스 ID"), - fieldWithPath("body.weeks[].common_lane[].process_status").type(STRING).description("프로세스 상태"), - fieldWithPath("body.weeks[].common_lane[].title").type(STRING).description("프로세스 제목"), - fieldWithPath("body.weeks[].common_lane[].complete_check_list").type(NUMBER).description("완료 체크리스트 개수"), - fieldWithPath("body.weeks[].common_lane[].whole_check_list").type(NUMBER).description("전체 체크리스트 개수"), - fieldWithPath("body.weeks[].common_lane[].start_date").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), - fieldWithPath("body.weeks[].common_lane[].dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)"), - fieldWithPath("body.weeks[].common_lane[].left_day").optional().type(NUMBER).description("남은 일수(null 가능)"), - fieldWithPath("body.weeks[].common_lane[].role_fields").type(ARRAY).description("역할 분야(RoleField enum) 목록"), - fieldWithPath("body.weeks[].common_lane[].custom_fields").type(ARRAY).description("커스텀 분야명 목록"), - fieldWithPath("body.weeks[].common_lane[].mission_number").optional().type(NUMBER).description("미션 번호(null 가능)"), - fieldWithPath("body.weeks[].common_lane[].has_open_feedback").type(BOOLEAN).description("OPEN 피드백 존재 여부"), - fieldWithPath("body.weeks[].common_lane[].assignee").type(ARRAY).description("담당자 목록"), - fieldWithPath("body.weeks[].common_lane[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), - fieldWithPath("body.weeks[].common_lane[].assignee[].user_name").type(STRING).description("담당자 이름"), - fieldWithPath("body.weeks[].common_lane[].assignee[].nickname").type(STRING).description("담당자 닉네임"), - fieldWithPath("body.weeks[].common_lane[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL"), - - fieldWithPath("body.weeks[].by_field").type(ARRAY).description("분야별(Field) 그룹 목록"), - fieldWithPath("body.weeks[].by_field[].field_id").type(STRING).description("fieldId (예: ROLE:BACKEND / CUSTOM:영상편집)"), - fieldWithPath("body.weeks[].by_field[].field_name").type(STRING).description("fieldName (예: BACKEND / 영상편집)"), - fieldWithPath("body.weeks[].by_field[].field_order").type(NUMBER).description("fieldOrder"), - fieldWithPath("body.weeks[].by_field[].processes").type(ARRAY).description("해당 그룹의 프로세스 목록"), - - fieldWithPath("body.weeks[].by_field[].processes[].process_id").type(NUMBER).description("프로세스 ID"), - fieldWithPath("body.weeks[].by_field[].processes[].process_status").type(STRING).description("프로세스 상태"), - fieldWithPath("body.weeks[].by_field[].processes[].title").type(STRING).description("프로세스 제목"), - fieldWithPath("body.weeks[].by_field[].processes[].complete_check_list").type(NUMBER).description("완료 체크리스트 개수"), - fieldWithPath("body.weeks[].by_field[].processes[].whole_check_list").type(NUMBER).description("전체 체크리스트 개수"), - fieldWithPath("body.weeks[].by_field[].processes[].start_date").optional().type(STRING).description("시작일(yyyy-MM-dd, null 가능)"), - fieldWithPath("body.weeks[].by_field[].processes[].dead_line").optional().type(STRING).description("마감일(yyyy-MM-dd, null 가능)"), - fieldWithPath("body.weeks[].by_field[].processes[].left_day").optional().type(NUMBER).description("남은 일수(null 가능)"), - fieldWithPath("body.weeks[].by_field[].processes[].role_fields").type(ARRAY).description("역할 분야(RoleField enum) 목록"), - fieldWithPath("body.weeks[].by_field[].processes[].custom_fields").type(ARRAY).description("커스텀 분야명 목록"), - fieldWithPath("body.weeks[].by_field[].processes[].mission_number").optional().type(NUMBER).description("미션 번호(null 가능)"), - fieldWithPath("body.weeks[].by_field[].processes[].has_open_feedback").type(BOOLEAN).description("OPEN 피드백 존재 여부"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee").type(ARRAY).description("담당자 목록"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee[].nickname").type(STRING).description("담당자 닉네임"), - fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL") + fieldWithPath("body.weeks[].common_lane").type(JsonFieldType.ARRAY).description("공통 레인 프로세스 목록"), + fieldWithPath("body.weeks[].common_lane[].process_id").type(JsonFieldType.NUMBER).description("프로세스 ID"), + fieldWithPath("body.weeks[].common_lane[].process_status").type(JsonFieldType.STRING).description("프로세스 상태"), + fieldWithPath("body.weeks[].common_lane[].title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("body.weeks[].common_lane[].complete_check_list").type(JsonFieldType.NUMBER).description("완료 체크리스트 수"), + fieldWithPath("body.weeks[].common_lane[].whole_check_list").type(JsonFieldType.NUMBER).description("전체 체크리스트 수"), + fieldWithPath("body.weeks[].common_lane[].start_date").type(JsonFieldType.STRING).description("시작일(YYYY-MM-DD)"), + fieldWithPath("body.weeks[].common_lane[].dead_line").type(JsonFieldType.STRING).description("마감일(YYYY-MM-DD)"), + fieldWithPath("body.weeks[].common_lane[].left_day").type(JsonFieldType.NUMBER).description("마감까지 남은 일수"), + fieldWithPath("body.weeks[].common_lane[].role_fields").type(JsonFieldType.ARRAY).description("역할 필드(enum)"), + fieldWithPath("body.weeks[].common_lane[].custom_fields").type(JsonFieldType.ARRAY).description("커스텀 필드"), + fieldWithPath("body.weeks[].common_lane[].mission_number").type(JsonFieldType.NUMBER).description("미션 번호"), + fieldWithPath("body.weeks[].common_lane[].has_open_feedback").type(JsonFieldType.BOOLEAN).description("열린 피드백 존재 여부"), + + fieldWithPath("body.weeks[].common_lane[].assignee").type(JsonFieldType.ARRAY).description("담당자 목록"), + fieldWithPath("body.weeks[].common_lane[].assignee[].user_id").type(JsonFieldType.NUMBER).description("유저 ID"), + fieldWithPath("body.weeks[].common_lane[].assignee[].user_name").type(JsonFieldType.STRING).description("유저 이름"), + fieldWithPath("body.weeks[].common_lane[].assignee[].nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("body.weeks[].common_lane[].assignee[].user_image").type(JsonFieldType.STRING).optional().description("프로필 이미지 URL(없으면 null)"), + + fieldWithPath("body.weeks[].common_lane[].attachment_summary").type(JsonFieldType.OBJECT).description("첨부 요약"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.total_count").type(JsonFieldType.NUMBER).description("첨부 총 개수"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.file_count").type(JsonFieldType.NUMBER).description("파일 첨부 개수"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.link_count").type(JsonFieldType.NUMBER).description("링크 첨부 개수"), + fieldWithPath("body.weeks[].common_lane[].attachment_summary.file_extensions").type(JsonFieldType.ARRAY).optional().description("첨부 파일 확장자 목록(enum, 예: PDF, PNG). 파일이 없으면 빈 배열"), + + fieldWithPath("body.weeks[].common_lane[].attachments_meta").type(JsonFieldType.ARRAY).description("첨부 메타 목록"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].type").type(JsonFieldType.STRING).description("첨부 타입(FILE/LINK)"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].document_id").type(JsonFieldType.NUMBER).description("문서 ID"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].attached_at").type(JsonFieldType.STRING).description("첨부 시각(ISO-8601)"), + fieldWithPath("body.weeks[].common_lane[].attachments_meta[].file_ext").type(JsonFieldType.STRING).optional().description("파일 확장자(enum). 링크면 null"), + + fieldWithPath("body.weeks[].by_field").type(JsonFieldType.ARRAY).description("필드별 레인 목록"), + fieldWithPath("body.weeks[].by_field[].field_id").type(JsonFieldType.STRING).description("필드 ID (예: ROLE:BACKEND, CUSTOM:영상편집)"), + fieldWithPath("body.weeks[].by_field[].field_name").type(JsonFieldType.STRING).description("필드명"), + fieldWithPath("body.weeks[].by_field[].field_order").type(JsonFieldType.NUMBER).description("필드 순서"), + fieldWithPath("body.weeks[].by_field[].processes").type(JsonFieldType.ARRAY).description("해당 필드의 프로세스 목록"), + + fieldWithPath("body.weeks[].by_field[].processes[].process_id").type(JsonFieldType.NUMBER).description("프로세스 ID"), + fieldWithPath("body.weeks[].by_field[].processes[].process_status").type(JsonFieldType.STRING).description("프로세스 상태"), + fieldWithPath("body.weeks[].by_field[].processes[].title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("body.weeks[].by_field[].processes[].complete_check_list").type(JsonFieldType.NUMBER).description("완료 체크리스트 수"), + fieldWithPath("body.weeks[].by_field[].processes[].whole_check_list").type(JsonFieldType.NUMBER).description("전체 체크리스트 수"), + fieldWithPath("body.weeks[].by_field[].processes[].start_date").type(JsonFieldType.STRING).description("시작일(YYYY-MM-DD)"), + fieldWithPath("body.weeks[].by_field[].processes[].dead_line").type(JsonFieldType.STRING).description("마감일(YYYY-MM-DD)"), + fieldWithPath("body.weeks[].by_field[].processes[].left_day").type(JsonFieldType.NUMBER).description("마감까지 남은 일수"), + fieldWithPath("body.weeks[].by_field[].processes[].role_fields").type(JsonFieldType.ARRAY).description("역할 필드(enum)"), + fieldWithPath("body.weeks[].by_field[].processes[].custom_fields").type(JsonFieldType.ARRAY).description("커스텀 필드"), + fieldWithPath("body.weeks[].by_field[].processes[].mission_number").type(JsonFieldType.NUMBER).description("미션 번호"), + fieldWithPath("body.weeks[].by_field[].processes[].has_open_feedback").type(JsonFieldType.BOOLEAN).description("열린 피드백 존재 여부"), + + fieldWithPath("body.weeks[].by_field[].processes[].assignee").type(JsonFieldType.ARRAY).description("담당자 목록"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_id").type(JsonFieldType.NUMBER).description("유저 ID"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_name").type(JsonFieldType.STRING).description("유저 이름"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("body.weeks[].by_field[].processes[].assignee[].user_image").type(JsonFieldType.STRING).optional().description("프로필 이미지 URL(없으면 null)"), + + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary").type(JsonFieldType.OBJECT).description("첨부 요약"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.total_count").type(JsonFieldType.NUMBER).description("첨부 총 개수"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.file_count").type(JsonFieldType.NUMBER).description("파일 첨부 개수"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.link_count").type(JsonFieldType.NUMBER).description("링크 첨부 개수"), + fieldWithPath("body.weeks[].by_field[].processes[].attachment_summary.file_extensions").type(JsonFieldType.ARRAY).optional().description("첨부 파일 확장자 목록(enum). 파일이 없으면 빈 배열"), + + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta").type(JsonFieldType.ARRAY).description("첨부 메타 목록"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].type").type(JsonFieldType.STRING).description("첨부 타입(FILE/LINK)"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].document_id").type(JsonFieldType.NUMBER).description("문서 ID"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].attached_at").type(JsonFieldType.STRING).description("첨부 시각(ISO-8601)"), + fieldWithPath("body.weeks[].by_field[].processes[].attachments_meta[].file_ext").type(JsonFieldType.STRING).optional().description("파일 확장자(enum). 링크면 null") ) .build() ) @@ -822,7 +901,12 @@ void getPartProcesses() throws Exception { AssigneeResDto a1 = new AssigneeResDto(1L, "유저1", "유저1닉", "https://img.com/1.png"); AssigneeResDto a2 = new AssigneeResDto(2L, "유저2", "유저2닉", "https://img.com/2.png"); - // IN_PROGRESS 카드 2개 + AttachmentSummaryDto s1 = new AttachmentSummaryDto(2L, 1L, 1L, List.of(FileExt.PDF)); + List m1 = List.of( + new AttachmentMetaDto(AttachmentType.FILE, 7001L, LocalDateTime.of(2026, 2, 4, 10, 0), FileExt.PDF), + new AttachmentMetaDto(AttachmentType.LINK, 7002L, LocalDateTime.of(2026, 2, 4, 10, 30), null) + ); + ProcessCardResDto p10 = new ProcessCardResDto( 10L, ProcessStatus.IN_PROGRESS, @@ -836,7 +920,9 @@ void getPartProcesses() throws Exception { List.of("AI"), 1, true, - List.of(a1, a2) // assignee + List.of(a1, a2), + s1, + m1 ); ProcessCardResDto p12 = new ProcessCardResDto( @@ -852,11 +938,11 @@ void getPartProcesses() throws Exception { List.of("DevOps"), null, true, - List.of(a2) + List.of(a2), + AttachmentSummaryDto.empty(), + List.of() ); - - ProcessStatusGroupResDto inProgressGroup = new ProcessStatusGroupResDto( ProcessStatus.IN_PROGRESS, 2, @@ -877,7 +963,9 @@ void getPartProcesses() throws Exception { List.of(), null, false, - List.of(a1) + List.of(a1), + AttachmentSummaryDto.empty(), + List.of() ); ProcessStatusGroupResDto planningGroup = new ProcessStatusGroupResDto( @@ -900,7 +988,9 @@ void getPartProcesses() throws Exception { List.of("Auth"), 1, false, - List.of(a1, a2) + List.of(a1, a2), + new AttachmentSummaryDto(1L, 1L, 0L, List.of(FileExt.SVG)), + List.of(new AttachmentMetaDto(AttachmentType.FILE, 7301L, LocalDateTime.of(2026, 1, 24, 18, 0), FileExt.SVG)) ); ProcessStatusGroupResDto doneGroup = new ProcessStatusGroupResDto( @@ -923,7 +1013,9 @@ void getPartProcesses() throws Exception { List.of("TechDebt"), 1, false, - List.of(a2) + List.of(a2), + AttachmentSummaryDto.empty(), + List.of() ); ProcessStatusGroupResDto backlogGroup = new ProcessStatusGroupResDto( @@ -999,7 +1091,18 @@ void getPartProcesses() throws Exception { fieldWithPath("body.groups[].processes[].assignee[].user_id").type(NUMBER).description("담당자 유저 ID"), fieldWithPath("body.groups[].processes[].assignee[].user_name").type(STRING).description("담당자 이름"), fieldWithPath("body.groups[].processes[].assignee[].nickname").type(STRING).description("담당자 닉네임"), - fieldWithPath("body.groups[].processes[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL") + fieldWithPath("body.groups[].processes[].assignee[].user_image").optional().type(STRING).description("담당자 이미지 URL"), + + fieldWithPath("body.groups[].processes[].attachment_summary").type(OBJECT).description("첨부 요약"), + fieldWithPath("body.groups[].processes[].attachment_summary.total_count").type(NUMBER).description("총 첨부 수(file+link)"), + fieldWithPath("body.groups[].processes[].attachment_summary.file_count").type(NUMBER).description("파일 첨부 수"), + fieldWithPath("body.groups[].processes[].attachment_summary.link_count").type(NUMBER).description("링크 첨부 수"), + fieldWithPath("body.groups[].processes[].attachment_summary.file_extensions").type(ARRAY).description("첨부된 파일 확장자 목록(중복 제거)"), + fieldWithPath("body.groups[].processes[].attachments_meta").type(ARRAY).description("첨부 메타 목록"), + fieldWithPath("body.groups[].processes[].attachments_meta[].type").type(STRING).description("첨부 타입(FILE/LINK)"), + fieldWithPath("body.groups[].processes[].attachments_meta[].document_id").type(NUMBER).description("SharedDocument ID"), + fieldWithPath("body.groups[].processes[].attachments_meta[].attached_at").type(STRING).description("첨부 시각(ISO LocalDateTime)"), + fieldWithPath("body.groups[].processes[].attachments_meta[].file_ext").optional().type(STRING).description("파일 확장자(FILE만, LINK는 null)") ) .build() ) diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java index 99b094e8..2b138d79 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/BoardsSharedDocumentControllerTest.java @@ -3,7 +3,9 @@ import com.epages.restdocs.apispec.ResourceDocumentation; import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nect.api.domain.team.workspace.dto.req.SharedDocumentLinkCreateReqDto; import com.nect.api.domain.team.workspace.dto.req.SharedDocumentNameUpdateReqDto; +import com.nect.api.domain.team.workspace.dto.res.SharedDocumentCreatedResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentNameUpdateResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsGetResDto; import com.nect.api.domain.team.workspace.dto.res.SharedDocumentsPreviewResDto; @@ -23,6 +25,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; @@ -30,6 +33,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.util.List; @@ -42,14 +46,12 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.payload.JsonFieldType.*; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @@ -454,4 +456,151 @@ void deleteSharedDocument() throws Exception { verify(facade).delete(eq(projectId), eq(userId), eq(documentId)); } + + @Test + @DisplayName("공유 문서함 파일 업로드") + void uploadSharedDocumentFile() throws Exception { + long projectId = 1L; + long userId = 1L; + + MockMultipartFile file = new MockMultipartFile( + "file", + "api-spec.pdf", + "application/pdf", + "dummy pdf content".getBytes() + ); + + SharedDocumentCreatedResDto res = new SharedDocumentCreatedResDto( + 500L, + DocumentType.FILE, + "api-spec.pdf", + null, + "api-spec.pdf", + FileExt.PDF, + 1234L, + "https://example.com/presigned/500" + ); + + given(facade.uploadFile(eq(projectId), eq(userId), any(MultipartFile.class))) + .willReturn(res); + + mockMvc.perform(multipart("/api/v1/projects/{projectId}/boards/shared-documents/files", projectId) + .file(file) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("boards-shared-documents-file-upload", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Boards") + .summary("공유 문서함 파일 업로드") + .description("공유 문서함에 파일을 업로드합니다. (R2 업로드 + SharedDocument(FILE) 생성)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.document_id").type(NUMBER).description("문서 ID"), + fieldWithPath("body.document_type").type(STRING).description("문서 타입(FILE)"), + fieldWithPath("body.title").type(STRING).description("표시명(title)"), + fieldWithPath("body.link_url").optional().type(STRING).description("링크 URL (FILE이면 null)"), + + fieldWithPath("body.file_name").type(STRING).description("파일명"), + fieldWithPath("body.file_ext").type(STRING).description("파일 확장자"), + fieldWithPath("body.file_size").type(NUMBER).description("파일 크기(byte)"), + fieldWithPath("body.download_url").type(STRING).description("Presigned 다운로드 URL") + ) + .build() + ) + )); + + verify(facade).uploadFile(eq(projectId), eq(userId), any(MultipartFile.class)); + } + + @Test + @DisplayName("공유 문서함 링크 생성") + void createSharedDocumentLink() throws Exception { + long projectId = 1L; + long userId = 1L; + + SharedDocumentLinkCreateReqDto req = new SharedDocumentLinkCreateReqDto( + "Backend Repo", + "https://github.com/nect/nect-backend" + ); + + SharedDocumentCreatedResDto res = new SharedDocumentCreatedResDto( + 600L, + DocumentType.LINK, + "Backend Repo", + "https://github.com/nect/nect-backend", + null, + null, + 0L, + null + ); + + given(facade.createLink(eq(projectId), eq(userId), any(SharedDocumentLinkCreateReqDto.class))) + .willReturn(res); + + mockMvc.perform(post("/api/v1/projects/{projectId}/boards/shared-documents/links", projectId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("boards-shared-documents-link-create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Boards") + .summary("공유 문서함 링크 생성") + .description("공유 문서함에 링크를 생성합니다. (SharedDocument(LINK) 생성)") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .requestFields( + fieldWithPath("title").type(STRING).description("표시명(title)"), + fieldWithPath("link_url").type(STRING).description("링크 URL") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + + fieldWithPath("body").type(OBJECT).description("응답 바디"), + fieldWithPath("body.document_id").type(NUMBER).description("문서 ID"), + fieldWithPath("body.document_type").type(STRING).description("문서 타입(LINK)"), + fieldWithPath("body.title").type(STRING).description("표시명(title)"), + fieldWithPath("body.link_url").type(STRING).description("링크 URL"), + + fieldWithPath("body.file_name").optional().type(STRING).description("파일명 (LINK면 null)"), + fieldWithPath("body.file_ext").optional().type(STRING).description("파일 확장자 (LINK면 null)"), + fieldWithPath("body.file_size").optional().type(NUMBER).description("파일 크기 (LINK면 0 또는 null)"), + fieldWithPath("body.download_url").optional().type(STRING).description("다운로드 URL (LINK면 null)") + ) + .build() + ) + )); + + verify(facade).createLink(eq(projectId), eq(userId), any(SharedDocumentLinkCreateReqDto.class)); + } + + } diff --git a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java index e1767fae..9996c0f3 100644 --- a/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/team/workspace/controller/PostControllerTest.java @@ -427,6 +427,50 @@ void updatePost() throws Exception { verify(postFacade).updatePost(eq(projectId), eq(userId), eq(postId), any(PostUpdateReqDto.class)); } + @Test + @DisplayName("게시글 삭제") + void deletePost() throws Exception { + long projectId = 1L; + long postId = 100L; + long userId = 1L; + + doNothing().when(postFacade).deletePost(eq(projectId), eq(userId), eq(postId)); + + mockMvc.perform(delete("/api/v1/projects/{projectId}/boards/posts/{postId}", projectId, postId) + .with(mockUser(userId)) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("post-delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Post") + .summary("게시글 삭제") + .description("게시글을 삭제합니다. 작성자만 삭제할 수 있습니다. (soft delete)") + .pathParameters( + ResourceDocumentation.parameterWithName("projectId").description("프로젝트 ID"), + ResourceDocumentation.parameterWithName("postId").description("게시글 ID") + ) + .requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ) + .responseFields( + fieldWithPath("status").type(OBJECT).description("응답 상태"), + fieldWithPath("status.statusCode").type(STRING).description("상태 코드"), + fieldWithPath("status.message").type(STRING).description("메시지"), + fieldWithPath("status.description").optional().type(STRING).description("상세 설명"), + fieldWithPath("body").optional().type(NULL).description("응답 바디(삭제는 null)") + ) + .build() + ) + )); + + verify(postFacade).deletePost(eq(projectId), eq(userId), eq(postId)); + } + + @Test @DisplayName("게시글 프리뷰 조회") void getPostsPreview() throws Exception { diff --git a/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java b/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java index 1d27fddb..aeb11898 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/history/enums/HistoryAction.java @@ -24,6 +24,9 @@ public enum HistoryAction { LINK_ATTACHED, LINK_DETACHED, + DOCUMENT_CREATED, + LINK_CREATED, + DOCUMENT_RENAMED, DOCUMENT_DELETED, diff --git a/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java b/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java index edf9b5a0..2c11b446 100644 --- a/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java +++ b/nect-core/src/main/java/com/nect/core/entity/user/UserTeamRole.java @@ -16,9 +16,7 @@ name = "user_team_roles", indexes = { @Index(name = "idx_user_team_roles_project_id", columnList = "project_id"), - @Index(name = "idx_user_team_roles_user_id", columnList = "user_id"), - @Index(name = "idx_user_team_roles_project_user", columnList = "project_id, user_id"), - @Index(name = "idx_user_team_roles_project_user_role", columnList = "project_id, user_id, role_field") + @Index(name = "idx_user_team_roles_project_role", columnList = "project_id, role_field") } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -33,10 +31,6 @@ public class UserTeamRole { @JoinColumn(name = "project_id", nullable = false) private Project project; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id", nullable = false) - private User user; - @Enumerated(EnumType.STRING) @Column(name = "role_field", nullable = false, length = 50) private RoleField roleField; @@ -53,24 +47,22 @@ public class UserTeamRole { private LocalDateTime deletedAt; @Builder - private UserTeamRole(Project project, User user, RoleField roleField, String customRoleFieldName, Integer requiredCount) { + private UserTeamRole(Project project, RoleField roleField, String customRoleFieldName, Integer requiredCount) { this.project = project; - this.user = user; this.roleField = roleField; this.customRoleFieldName = customRoleFieldName; this.requiredCount = requiredCount; } - public static UserTeamRole of(Project project, User user, RoleField roleField, String customRoleFieldName, Integer requiredCount) { + public static UserTeamRole of(Project project, RoleField roleField, String customRoleFieldName, Integer requiredCount) { return UserTeamRole.builder() .project(project) - .user(user) .roleField(roleField) .customRoleFieldName(customRoleFieldName) .requiredCount(requiredCount) .build(); } - + public boolean isDeleted() { return deletedAt != null; } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java index 0af622df..7b1247ef 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/process/ProcessSharedDocumentRepository.java @@ -1,11 +1,14 @@ package com.nect.core.repository.team.process; +import com.nect.core.entity.team.enums.DocumentType; +import com.nect.core.entity.team.enums.FileExt; import com.nect.core.entity.team.process.ProcessSharedDocument; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -32,4 +35,59 @@ public interface ProcessSharedDocumentRepository extends JpaRepository findAliveAttachmentsWithDoc(@Param("processId") Long processId); + + interface AttachmentAggRow { + Long getProcessId(); + DocumentType getDocumentType(); + FileExt getFileExt(); + Long getCnt(); + } + + @Query(""" + select + psd.process.id as processId, + doc.documentType as documentType, + doc.fileExt as fileExt, + count(psd.id) as cnt + from ProcessSharedDocument psd + join psd.document doc + where psd.deletedAt is null + and doc.deletedAt is null + and psd.process.id in :processIds + group by psd.process.id, doc.documentType, doc.fileExt + """) + List aggregateAttachmentsByProcessIds(@Param("processIds") List processIds); + + interface AttachmentMetaRow { + Long getProcessId(); + Long getDocumentId(); + DocumentType getDocumentType(); + FileExt getFileExt(); + LocalDateTime getAttachedAt(); + LocalDateTime getCreatedAt(); + } + + @Query(""" + select + psd.process.id as processId, + d.id as documentId, + d.documentType as documentType, + d.fileExt as fileExt, + psd.attachedAt as attachedAt, + psd.createdAt as createdAt + from ProcessSharedDocument psd + join psd.document d + where psd.deletedAt is null + and d.deletedAt is null + and psd.process.id in :processIds + """) + List findAttachmentMetasByProcessIds(@Param("processIds") List processIds); + + @Query(""" + select (count(psd) > 0) + from ProcessSharedDocument psd + where psd.document.id = :documentId + and psd.deletedAt is null + """) + boolean existsActiveByDocumentId(@Param("documentId") Long documentId); } diff --git a/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java index 9675f3fa..052ac77c 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/workspace/PostSharedDocumentRepository.java @@ -2,9 +2,11 @@ import com.nect.core.entity.team.workspace.PostSharedDocument; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -22,4 +24,25 @@ public interface PostSharedDocumentRepository extends JpaRepository findAllActiveByPostIdWithDocument(@Param("postId") Long postId); + + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update PostSharedDocument psd + set psd.deletedAt = :deletedAt + where psd.post.id = :postId + and psd.deletedAt is null + """) + int softDeleteAllByPostId(@Param("postId") Long postId, @Param("deletedAt") LocalDateTime deletedAt); + + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update PostSharedDocument psd + set psd.deletedAt = :deletedAt + where psd.document.id = :documentId + and psd.deletedAt is null +""") + int softDeleteAllByDocumentId(@Param("documentId") Long documentId, + @Param("deletedAt") LocalDateTime deletedAt); } diff --git a/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java b/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java index c33a54f4..3877bd25 100644 --- a/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/user/UserTeamRoleRepository.java @@ -9,16 +9,16 @@ public interface UserTeamRoleRepository extends JpaRepository { - boolean existsByProject_IdAndUser_UserIdAndRoleFieldAndDeletedAtIsNull( - Long projectId, Long userId, RoleField roleField + boolean existsByProject_IdAndRoleFieldAndDeletedAtIsNull( + Long projectId, RoleField roleField ); - boolean existsByProject_IdAndUser_UserIdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( - Long projectId, Long userId, RoleField roleField, String customRoleFieldName + boolean existsByProject_IdAndRoleFieldAndCustomRoleFieldNameIgnoreCaseAndDeletedAtIsNull( + Long projectId, RoleField roleField, String customRoleFieldName ); Optional findByIdAndProject_IdAndDeletedAtIsNull(Long id, Long projectId); - List findAllByProject_IdAndUser_UserIdAndDeletedAtIsNullOrderByIdAsc(Long projectId, Long userId); + List findAllByProject_IdAndDeletedAtIsNullOrderByIdAsc(Long projectId); }