From 975d0c858d0bb6fd88bde8ae3aacf68ddb10d797 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 18 Aug 2026 21:22:21 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=C2=B7=EA=B6=8C=ED=95=9C=EC=83=81=EC=86=8D?= =?UTF-8?q?=C2=B7=EB=AA=A9=EB=A1=9D=EC=A1=B0=ED=9A=8C=EC=9A=A9=20Repositor?= =?UTF-8?q?y=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CollectionRepository: 조상/후손 ID 재귀 조회(findAncestorIdsInclusive, findDescendantIdsInclusive), 문서 상속 판단용 findEffectiveCollectionIdsForDocument, 직계 자식 조회(findAllByParentCollectionIdAndStatus), 권한 반영 컬렉션 목록 조회(findReadableCollectionIds, keyword 검색 포함), 페이지 조회(findAllByIdIn) - CollectionPermissionRepository: 조상 ID 리스트 기반 ROLE/DEPARTMENT 권한 체크 6종 추가(기존 단일 ID 메서드는 유지), cascade 삭제용 findAllByCollectionIdIn - CollectionDocumentRepository: cascade 삭제용 findAllByCollectionIdIn Co-Authored-By: Claude Sonnet 5 --- .../CollectionDocumentRepository.java | 3 + .../repository/CollectionRepository.java | 112 +++++++++++++++++- .../CollectionPermissionRepository.java | 72 +++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java index f06ae58b..1f5f6c5d 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionDocumentRepository.java @@ -17,6 +17,9 @@ public interface CollectionDocumentRepository extends JpaRepository findAllByCollectionId(Long collectionId); + // cascade 삭제용 — 대상 컬렉션 ID 목록(자기 자신+후손 전체)에 속한 문서 매핑 전체 조회 + List findAllByCollectionIdIn(List collectionIds); + /** * 권한 선필터를 통과한 컬렉션 문서를 현재 버전 Metadata와 함께 페이지 조회한다. */ diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java index a0a43417..1358ff62 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java @@ -2,13 +2,121 @@ import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.opensource.docgrid.domain.collection.entity.DocumentCollection; import com.opensource.docgrid.domain.collection.enums.CollectionStatus; public interface CollectionRepository extends JpaRepository { - // 소유자 기준 상태별 컬렉션 목록 조회 (GET /collections) - List findAllByOwnerIdAndStatus(Long ownerId, CollectionStatus status); + /** + * 사용자가 읽을 수 있는 컬렉션 ID 전체 (GET /collections pre-filter). + * 4가지 접근 경로: OWNER / PUBLIC / USER 직접 권한 / ROLE·DEPARTMENT live(부모 컬렉션 체인 상속 포함). + * ACTIVE 상태만 대상으로 하며, keyword가 있으면 이름·설명 부분일치로도 필터링한다(keyword는 null 가능). + */ + @Query(value = """ + WITH RECURSIVE collection_ancestors AS ( + SELECT id AS collection_id, id AS ancestor_id FROM collections + UNION ALL + SELECT ca.collection_id, c.parent_collection_id AS ancestor_id + FROM collection_ancestors ca + JOIN collections c ON c.id = ca.ancestor_id + WHERE c.parent_collection_id IS NOT NULL + ) + SELECT c.id FROM collections c + WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_permissions cp ON cp.collection_id = c.id + WHERE cp.target_type = 'USER' AND cp.user_id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_ancestors ca ON ca.collection_id = c.id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id + JOIN user_roles ur ON ur.role_id = cp.role_id + WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_ancestors ca ON ca.collection_id = c.id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id + JOIN users u ON u.department_id = cp.department_id + WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + """, nativeQuery = true) + List findReadableCollectionIds(@Param("userId") Long userId, @Param("keyword") String keyword); + + // pre-filter로 걸러진 ID를 받아 정렬·페이징만 담당 (GET /collections) + @Query("SELECT c FROM DocumentCollection c JOIN FETCH c.owner WHERE c.id IN :ids") + Page findAllByIdIn(@Param("ids") List ids, Pageable pageable); + + // 직계 자식 컬렉션 목록 조회 (GET /collections/{id}/children) + List findAllByParentCollectionIdAndStatus(Long parentCollectionId, CollectionStatus status); + + /** + * 자기 자신 + 모든 조상 컬렉션 ID (권한 상속 판단용). + * 삭제된 조상도 결과에 포함한다 — 삭제된 컬렉션은 권한이 비어있어 무해하고, + * status 필터를 넣으면 중간 조상이 삭제됐을 때 그 위 조상으로 체인이 끊기는 문제가 생긴다. + */ + @Query(value = """ + WITH RECURSIVE ancestors AS ( + SELECT id, parent_collection_id FROM collections WHERE id = :collectionId + UNION ALL + SELECT c.id, c.parent_collection_id + FROM collections c + JOIN ancestors a ON c.id = a.parent_collection_id + ) + SELECT id FROM ancestors + """, nativeQuery = true) + List findAncestorIdsInclusive(@Param("collectionId") Long collectionId); + + /** + * 자기 자신 + 모든 후손 컬렉션 ID (cascade 삭제 대상 판단용). + */ + @Query(value = """ + WITH RECURSIVE descendants AS ( + SELECT id, parent_collection_id FROM collections WHERE id = :collectionId + UNION ALL + SELECT c.id, c.parent_collection_id + FROM collections c + JOIN descendants d ON c.parent_collection_id = d.id + ) + SELECT id FROM descendants + """, nativeQuery = true) + List findDescendantIdsInclusive(@Param("collectionId") Long collectionId); + + /** + * 문서가 속한 모든 컬렉션(N:M) + 그 컬렉션들 각각의 조상 전체 ID (문서 권한 상속 판단용). + */ + @Query(value = """ + WITH RECURSIVE ancestors AS ( + SELECT c.id, c.parent_collection_id + FROM collections c + WHERE c.id IN ( + SELECT DISTINCT cd.collection_id FROM collection_documents cd WHERE cd.document_id = :documentId + ) + UNION ALL + SELECT c.id, c.parent_collection_id + FROM collections c + JOIN ancestors a ON c.id = a.parent_collection_id + ) + SELECT DISTINCT id FROM ancestors + """, nativeQuery = true) + List findEffectiveCollectionIdsForDocument(@Param("documentId") Long documentId); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java index 656ecd35..413b803a 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/repository/CollectionPermissionRepository.java @@ -13,6 +13,9 @@ public interface CollectionPermissionRepository extends JpaRepository findAllByCollectionId(Long collectionId); + // cascade 삭제용 — 대상 컬렉션 ID 목록(자기 자신+후손 전체)에 걸린 권한 전체 조회 + List findAllByCollectionIdIn(List collectionIds); + /** * 컬렉션에 직접 부여된 권한을 대상·부여자 정보와 함께 최신순으로 조회한다. */ @@ -211,4 +214,73 @@ SELECT COUNT(cp) > 0 FROM CollectionPermission cp AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) """) boolean existsDeptAdminPermissionForCollection(@Param("userId") Long userId, @Param("collectionId") Long collectionId); + + // 컬렉션 트리 상속용 — 컬렉션 ID 목록(자기 자신+조상 또는 문서가 속한 컬렉션+조상) 중 + // 하나라도 ROLE/DEPARTMENT 권한이 있으면 true. 기존 단일-ID 메서드는 그대로 두고 추가로 병행한다. + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN UserRole ur ON ur.role = cp.role + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.ROLE + AND ur.user.id = :userId + AND cp.canRead = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsRoleReadPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN UserRole ur ON ur.role = cp.role + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.ROLE + AND ur.user.id = :userId + AND cp.canWrite = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsRoleWritePermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN UserRole ur ON ur.role = cp.role + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.ROLE + AND ur.user.id = :userId + AND cp.canAdmin = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsRoleAdminPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN User u ON u.department = cp.department + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.DEPARTMENT + AND u.id = :userId + AND cp.canRead = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsDeptReadPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN User u ON u.department = cp.department + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.DEPARTMENT + AND u.id = :userId + AND cp.canWrite = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsDeptWritePermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); + + @Query(""" + SELECT COUNT(cp) > 0 FROM CollectionPermission cp + JOIN User u ON u.department = cp.department + WHERE cp.collection.id IN :collectionIds + AND cp.targetType = com.opensource.docgrid.domain.permission.enums.PermissionTargetType.DEPARTMENT + AND u.id = :userId + AND cp.canAdmin = true + AND (cp.expiresAt IS NULL OR cp.expiresAt > CURRENT_TIMESTAMP) + """) + boolean existsDeptAdminPermissionForCollections(@Param("userId") Long userId, @Param("collectionIds") List collectionIds); } From 40dc5acdb1a986cdc44ba9d01596385083ca986e Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 18 Aug 2026 21:22:28 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=20?= =?UTF-8?q?=EB=B6=80=EB=AA=A8=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EA=B3=BC=20cascade=20=EC=82=AD=EC=A0=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createCollection: 부모 지정 시 존재 여부만 확인하던 걸 canWriteCollection() 검증까지 추가 (남의 컬렉션 밑에 마음대로 자식을 매달 수 있던 버그 수정) - deleteCollection: 자기 자신만 지우던 걸 하위 컬렉션 전체 + 문서 매핑까지 cascade soft delete하도록 확장. owner 체크는 삭제 대상 root 1회만 수행 Co-Authored-By: Claude Sonnet 5 --- .../command/CollectionCommandService.java | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java index bdc7fdd1..4b4dca80 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/command/CollectionCommandService.java @@ -53,7 +53,11 @@ public CollectionResponse createCollection(Long userId, CreateCollectionRequest DocumentCollection parentCollection = null; // 상위 폴더 지정은 선택 사항이라 null로 초기화 if (request.parentCollectionId() != null) { parentCollection = collectionRepository.findById(request.parentCollectionId()) + .filter(c -> c.getStatus() != CollectionStatus.DELETED) .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); + if (!permissionQueryService.canWriteCollection(userId, parentCollection)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } } VisibilityType visibility = request.visibility() != null ? request.visibility() : VisibilityType.PRIVATE; @@ -102,25 +106,34 @@ public CollectionDocumentResponse addDocument(Long collectionId, Long userId, Ad return collectionConverter.toDocumentResponse(collectionDocument); } - // 컬렉션 soft delete — 소유자만 가능 + // 컬렉션 soft delete — 소유자만 가능. 하위 컬렉션 전체와 그 안의 문서 매핑까지 cascade로 함께 삭제한다. + // owner 체크는 삭제 대상 최상위(root)에서만 하고 하위 각각은 재확인하지 않는다 + // (구글드라이브 공유폴더 삭제와 동일한 멘탈모델 — root에 대한 권한으로 하위 전체가 지워짐). public void deleteCollection(Long collectionId, Long userId) { - DocumentCollection collection = collectionRepository.findById(collectionId) + DocumentCollection root = collectionRepository.findById(collectionId) .filter(c -> c.getStatus() != CollectionStatus.DELETED) .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); - if (!collection.getOwner().getId().equals(userId)) { + if (!root.getOwner().getId().equals(userId)) { throw new DocGridException(ErrorCode.PERMISSION_DENIED); } - // 폴더에 속한 모든 권한 삭제 및 캐시 무효화 - List permissions = collectionPermissionRepository.findAllByCollectionId(collectionId); + List targetIds = collectionRepository.findDescendantIdsInclusive(collectionId); // 자기 자신 포함 + + // 대상 전체(자기 자신+하위)에 속한 권한 삭제 및 캐시 무효화 + List permissions = collectionPermissionRepository.findAllByCollectionIdIn(targetIds); permissions.stream() .filter(p -> p.getTargetType() == PermissionTargetType.USER) // 컬렉션 권한이 USER 대상인 경우에만 캐시 무효화 .forEach(p -> cacheService.bulkRevokeBySource(AccessSourceType.DIRECT_COLLECTION_PERMISSION, p.getId())); collectionPermissionRepository.deleteAll(permissions); // 컬렉션 권한 삭제 - collection.markDeleted(LocalDateTime.now()); // 폴더 상태를 DELETED로 변경 + // 대상 전체(자기 자신+하위)의 문서 매핑 삭제 + List mappings = collectionDocumentRepository.findAllByCollectionIdIn(targetIds); + collectionDocumentRepository.deleteAll(mappings); + + LocalDateTime now = LocalDateTime.now(); + collectionRepository.findAllById(targetIds).forEach(c -> c.markDeleted(now)); // 대상 전체 상태를 DELETED로 변경 } // 컬렉션에서 문서 제거 — 소유자만 가능 From 5e93f6faabfd2e64868a9dc8adf9dfc74ea19924 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 18 Aug 2026 21:22:37 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=9E=90=EC=8B=9D=20=EC=A1=B0=ED=9A=8C,=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=AA=A9=EB=A1=9D=C2=B7=EA=B2=80=EC=83=89?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CollectionQueryService: getChildren() 신규(부모 읽기권한 확인 후 직계 자식만, 자식마다 개별 읽기권한 재확인). getMyCollections()를 getCollections(userId, keyword, page, size)로 교체 — owner 전용이던 걸 owner+PUBLIC+권한부여(+상속) 전부 포함하도록 확장하고 페이지네이션·이름/설명 검색 추가 - CollectionController: GET /collections/{id}/children 신규, GET /collections에 keyword/page/size 파라미터 추가(PageResponse 반환), 삭제 API Swagger 설명을 cascade 동작에 맞게 갱신 Co-Authored-By: Claude Sonnet 5 --- .../controller/CollectionController.java | 30 ++++++++++++--- .../service/query/CollectionQueryService.java | 37 +++++++++++++++++-- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java index dc2e025c..02c631bc 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/controller/CollectionController.java @@ -47,19 +47,25 @@ public class CollectionController { private final CollectionQueryService collectionQueryService; @Operation( - summary = "내 컬렉션 목록 조회", - description = "현재 로그인한 사용자가 소유한 ACTIVE 상태의 컬렉션 목록을 반환합니다." + summary = "컬렉션 목록 조회", + description = "현재 로그인한 사용자가 읽을 수 있는 ACTIVE 상태의 컬렉션을 최신 생성순으로 페이지 조회합니다. " + + "소유한 컬렉션, PUBLIC 컬렉션, 직접·역할·부서 단위로 권한을 부여받은 컬렉션(부모 컬렉션 상속 포함)을 모두 포함합니다. " + + "keyword를 입력하면 이름·설명에 포함된 것만 필터링합니다." ) @GetMapping - public ResponseEntity>> getMyCollections( - @Parameter(hidden = true) @CurrentUser Long userId) { - return ResponseUtils.ok(collectionQueryService.getMyCollections(userId)); + public ResponseEntity>> getCollections( + @Parameter(hidden = true) @CurrentUser Long userId, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseUtils.ok(collectionQueryService.getCollections(userId, keyword, page, size)); } @Operation( summary = "컬렉션 삭제", description = "컬렉션을 soft delete합니다. 소유자(owner)만 가능합니다. " + - "소속 권한(collection_permissions)이 모두 삭제되고, USER 대상 권한이 있었다면 캐시도 무효화됩니다." + "하위 컬렉션 전체와 그 안의 문서 매핑까지 함께 삭제됩니다(cascade). " + + "대상 전체의 소속 권한(collection_permissions)이 모두 삭제되고, USER 대상 권한이 있었다면 캐시도 무효화됩니다." ) @DeleteMapping("/{collectionId}") public ResponseEntity> deleteCollection( @@ -105,6 +111,18 @@ public ResponseEntity> getCollection( return ResponseUtils.ok(collectionQueryService.getCollection(userId, collectionId)); } + @Operation( + summary = "직계 자식 컬렉션 목록 조회", + description = "이 컬렉션 바로 아래에 있는 하위 컬렉션 목록을 반환합니다. 하위 컬렉션 자체까지만 반환하며, " + + "더 아래 단계를 보려면 반환된 하위 컬렉션 ID로 이 API를 다시 호출해야 합니다." + ) + @GetMapping("/{collectionId}/children") + public ResponseEntity>> getChildren( + @PathVariable Long collectionId, + @Parameter(hidden = true) @CurrentUser Long userId) { + return ResponseUtils.ok(collectionQueryService.getChildren(userId, collectionId)); + } + @Operation( summary = "컬렉션 문서 목록 조회", description = "컬렉션을 읽을 수 있는 사용자가 개별 문서 읽기 권한도 가진 항목만 추가 최신순으로 페이지 조회합니다. " + diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java index e789cb1d..662c52e9 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java @@ -39,6 +39,10 @@ public class CollectionQueryService { Sort.Order.desc("addedAt"), Sort.Order.desc("id") ); + private static final Sort COLLECTION_SORT = Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ); private final CollectionRepository collectionRepository; private final CollectionDocumentRepository collectionDocumentRepository; @@ -57,10 +61,37 @@ public CollectionResponse getCollection(Long userId, Long collectionId) { return collectionConverter.toResponse(collection); } - // 내 컬렉션 목록 조회 (ACTIVE 상태만) - public List getMyCollections(Long userId) { - return collectionRepository.findAllByOwnerIdAndStatus(userId, CollectionStatus.ACTIVE) + /** + * 사용자가 읽을 수 있는 컬렉션 목록 페이지 조회 (owner + PUBLIC + 권한부여 + 부모 상속, ACTIVE만). + * keyword가 있으면 이름·설명 부분일치로도 필터링한다. + */ + public PageResponse getCollections(Long userId, String keyword, int page, int size) { + Pageable pageable = PageRequest.of(page, size, COLLECTION_SORT); + List readableIds = collectionRepository.findReadableCollectionIds(userId, keyword); + if (readableIds.isEmpty()) { + return PageResponse.from(Page.empty(pageable), List.of()); + } + + Page collections = collectionRepository.findAllByIdIn(readableIds, pageable); + List content = collections.getContent().stream() + .map(collectionConverter::toResponse) + .toList(); + return PageResponse.from(collections, content); + } + + // 직계 자식 컬렉션 목록 조회 — 부모 읽기 권한 확인 후, 자식 각각의 읽기 권한도 확인 + // (자식 owner/visibility가 부모와 다를 수 있으므로 부모 권한만으로 자식을 노출하면 안 됨) + public List getChildren(Long userId, Long collectionId) { + DocumentCollection parent = collectionRepository.findById(collectionId) + .filter(c -> c.getStatus() != CollectionStatus.DELETED) + .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); + if (!permissionQueryService.canReadCollection(userId, parent)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + + return collectionRepository.findAllByParentCollectionIdAndStatus(collectionId, CollectionStatus.ACTIVE) .stream() + .filter(child -> permissionQueryService.canReadCollection(userId, child)) .map(collectionConverter::toResponse) .toList(); } From 36056a918a83e3a9a5c19d0cde4d8caea50ac821 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 18 Aug 2026 21:22:45 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=C2=B7=EC=BB=AC?= =?UTF-8?q?=EB=A0=89=EC=85=98=20=EA=B6=8C=ED=95=9C=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=97=90=20=EB=B6=80=EB=AA=A8=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=86=8D=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canReadDocument/canWriteDocument/canAdminDocument/checkDocumentPermission (4종)와 canReadCollection/canWriteCollection/canAdminCollection(엔티티 오버로드 3종) 전부에 마지막 단계로 부모 컬렉션 체인 상속 확인을 추가했다. 기존 5단계(문서)/기존 로직(컬렉션)은 한 줄도 안 건드리고 끝에 새 블록만 이어붙이는 방식으로 넣어서, 기존 PermissionQueryServiceTest(47개 케이스)를 전혀 수정하지 않고 그대로 통과시켰다. checkDocumentPermission()의 상속 출처는 새 enum 값 없이 기존 ROLE/DEPARTMENT를 재사용해 API 응답 스키마를 바꾸지 않았다. Co-Authored-By: Claude Sonnet 5 --- .../service/query/PermissionQueryService.java | 68 ++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java index 99abedfc..44662774 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/query/PermissionQueryService.java @@ -120,6 +120,15 @@ public boolean canReadDocument(Long userId, Long documentId) { return true; } + // 6단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); + if (!effectiveCollectionIds.isEmpty() + && (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds) + || collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, effectiveCollectionIds))) { + log.info("[PERM] canRead inherited=true doc={} user={} elapsed={}ms", documentId, userId, ms(start)); + return true; + } + double step5Ms = (System.nanoTime() - t5) / 1_000_000.0; log.info("[PERM] canRead denied doc={} user={} step5={}ms elapsed={}ms", documentId, userId, step5Ms, ms(start)); @@ -164,6 +173,15 @@ public boolean canWriteDocument(Long userId, Long documentId) { return true; } + // 5단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); + if (!effectiveCollectionIds.isEmpty() + && (collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, effectiveCollectionIds) + || collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, effectiveCollectionIds))) { + log.info("[PERM] canWrite inherited=true doc={} user={} elapsed={}ms", documentId, userId, ms(start)); + return true; + } + double step4Ms = (System.nanoTime() - t4) / 1_000_000.0; log.info("[PERM] canWrite denied doc={} user={} step4={}ms elapsed={}ms", documentId, userId, step4Ms, ms(start)); @@ -208,6 +226,15 @@ public boolean canAdminDocument(Long userId, Long documentId) { return true; } + // 5단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); + if (!effectiveCollectionIds.isEmpty() + && (collectionPermissionRepository.existsRoleAdminPermissionForCollections(userId, effectiveCollectionIds) + || collectionPermissionRepository.existsDeptAdminPermissionForCollections(userId, effectiveCollectionIds))) { + log.info("[PERM] canAdmin inherited=true doc={} user={} elapsed={}ms", documentId, userId, ms(start)); + return true; + } + double step4Ms = (System.nanoTime() - t4) / 1_000_000.0; log.info("[PERM] canAdmin denied doc={} user={} step4={}ms elapsed={}ms", documentId, userId, step4Ms, ms(start)); @@ -275,6 +302,26 @@ public DocumentPermissionSummaryResponse checkDocumentPermission(Long userId, Lo if (deptAdmin) canAdmin = true; } + // 6단계: 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) — 기존 ROLE/DEPARTMENT 출처 값을 그대로 재사용한다 + List effectiveCollectionIds = collectionRepository.findEffectiveCollectionIdsForDocument(documentId); + if (!effectiveCollectionIds.isEmpty()) { + boolean inheritedRoleRead = collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, effectiveCollectionIds); + boolean inheritedRoleWrite = collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, effectiveCollectionIds); + boolean inheritedRoleAdmin = collectionPermissionRepository.existsRoleAdminPermissionForCollections(userId, effectiveCollectionIds); + boolean inheritedDeptRead = collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, effectiveCollectionIds); + boolean inheritedDeptWrite = collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, effectiveCollectionIds); + boolean inheritedDeptAdmin = collectionPermissionRepository.existsDeptAdminPermissionForCollections(userId, effectiveCollectionIds); + if ((inheritedRoleRead || inheritedRoleWrite || inheritedRoleAdmin) && !sources.contains(PermissionSourceType.ROLE)) { + sources.add(PermissionSourceType.ROLE); + } + if ((inheritedDeptRead || inheritedDeptWrite || inheritedDeptAdmin) && !sources.contains(PermissionSourceType.DEPARTMENT)) { + sources.add(PermissionSourceType.DEPARTMENT); + } + if (inheritedRoleRead || inheritedDeptRead) canRead = true; + if (inheritedRoleWrite || inheritedDeptWrite) canWrite = true; + if (inheritedRoleAdmin || inheritedDeptAdmin) canAdmin = true; + } + log.info("[PERM] checkDoc doc={} user={} canRead={} canWrite={} canAdmin={} sources={} elapsed={}ms", documentId, userId, canRead, canWrite, canAdmin, sources, ms(start)); return new DocumentPermissionSummaryResponse(documentId, canRead, canWrite, canAdmin, sources); @@ -293,7 +340,12 @@ public boolean canReadCollection(Long userId, DocumentCollection collection) { Long collectionId = collection.getId(); if (collectionPermissionRepository.existsUserReadPermission(userId, collectionId)) return true; if (collectionPermissionRepository.existsRoleReadPermissionForCollection(userId, collectionId)) return true; - return collectionPermissionRepository.existsDeptReadPermissionForCollection(userId, collectionId); + if (collectionPermissionRepository.existsDeptReadPermissionForCollection(userId, collectionId)) return true; + + // 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId); + if (collectionPermissionRepository.existsRoleReadPermissionForCollections(userId, ancestorIds)) return true; + return collectionPermissionRepository.existsDeptReadPermissionForCollections(userId, ancestorIds); } // 컬렉션 쓰기 권한 판단 (소유자, USER/ROLE/DEPT 직접 권한) @@ -307,7 +359,12 @@ public boolean canWriteCollection(Long userId, DocumentCollection collection) { Long collectionId = collection.getId(); if (collectionPermissionRepository.existsUserWritePermission(userId, collectionId)) return true; if (collectionPermissionRepository.existsRoleWritePermissionForCollection(userId, collectionId)) return true; - return collectionPermissionRepository.existsDeptWritePermissionForCollection(userId, collectionId); + if (collectionPermissionRepository.existsDeptWritePermissionForCollection(userId, collectionId)) return true; + + // 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId); + if (collectionPermissionRepository.existsRoleWritePermissionForCollections(userId, ancestorIds)) return true; + return collectionPermissionRepository.existsDeptWritePermissionForCollections(userId, ancestorIds); } // 컬렉션 관리 권한 판단 (소유자, USER/ROLE/DEPT 직접 권한) @@ -321,7 +378,12 @@ public boolean canAdminCollection(Long userId, DocumentCollection collection) { Long collectionId = collection.getId(); if (collectionPermissionRepository.existsUserAdminPermission(userId, collectionId)) return true; if (collectionPermissionRepository.existsRoleAdminPermissionForCollection(userId, collectionId)) return true; - return collectionPermissionRepository.existsDeptAdminPermissionForCollection(userId, collectionId); + if (collectionPermissionRepository.existsDeptAdminPermissionForCollection(userId, collectionId)) return true; + + // 부모 컬렉션 체인 상속 (ROLE/DEPARTMENT) + List ancestorIds = collectionRepository.findAncestorIdsInclusive(collectionId); + if (collectionPermissionRepository.existsRoleAdminPermissionForCollections(userId, ancestorIds)) return true; + return collectionPermissionRepository.existsDeptAdminPermissionForCollections(userId, ancestorIds); } // collectionId로 조회하되, status가 DELETED인 컬렉션은 필터링해서 제외한다 (없는 것으로 취급). From 29a9aaeef0bc2693fbf3f42d46352f419758c247 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 18 Aug 2026 21:22:55 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EA=B2=80=EC=83=89=20pre-filter=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EC=97=90=EB=8F=84=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=86=8D=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findReadableDocumentIds(전체 목록/검색), findReadableDocumentIdsInCollection (컬렉션 내 목록) 두 native 쿼리에 collection_ancestors closure CTE를 추가하고, 컬렉션 ROLE/DEPARTMENT 브랜치가 이 closure를 거치도록 JOIN 조건을 바꿨다. 문서 단건 조회만 상속되고 목록/검색엔 부모 권한으로 접근 가능한 문서가 안 뜨는 불일치를 막기 위한 것 — closure의 ancestor_id가 자기 자신을 포함하므로 기존 "직접 권한" 케이스도 그대로 커버된다(별도 브랜치 추가 아닌 순수 대체). 컬렉션 내 목록 쪽 바깥 필터("이 컬렉션에 직접 속한 문서만")는 그대로 유지해 하위 폴더 문서가 상위 목록에 섞이지 않게 했다. Co-Authored-By: Claude Sonnet 5 --- .../repository/DocumentRepository.java | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java index 637c74a1..0c7e0111 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java @@ -73,8 +73,17 @@ List findDocumentStatus( // 검색 pre-filter — 사용자가 읽을 수 있는 문서 ID 전체 (컬렉션 미지정) // 5가지 접근 경로: OWNER / PUBLIC / USER캐시 / ROLE live / DEPT live (문서·컬렉션 권한 모두 포함) + // 컬렉션 ROLE/DEPT 권한은 collection_ancestors closure를 통해 부모 컬렉션 체인까지 상속된다. // statuses는 DocumentStatus.name() 문자열 목록. 검색은 INDEXED만, 문서 목록은 처리 중 상태까지 넘긴다. @Query(value = """ + WITH RECURSIVE collection_ancestors AS ( + SELECT id AS collection_id, id AS ancestor_id FROM collections + UNION ALL + SELECT ca.collection_id, c.parent_collection_id AS ancestor_id + FROM collection_ancestors ca + JOIN collections c ON c.id = ca.ancestor_id + WHERE c.parent_collection_id IS NOT NULL + ) SELECT d.id FROM documents d WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION @@ -103,7 +112,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id - JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id JOIN user_roles ur ON ur.role_id = cp.role_id WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) @@ -111,7 +121,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id - JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id JOIN users u ON u.department_id = cp.department_id WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) @@ -123,7 +134,17 @@ List findReadableDocumentIds( ); // 검색 pre-filter — 특정 컬렉션 내에서 사용자가 읽을 수 있는 문서 ID + // 컬렉션 ROLE/DEPT 권한은 collection_ancestors closure를 통해 부모 컬렉션 체인까지 상속된다. + // 바깥쪽 WHERE는 "이 컬렉션에 직접 속한 문서만" 필터 — 하위 폴더 문서가 상위 폴더 목록에 섞이지 않게 한다. @Query(value = """ + WITH RECURSIVE collection_ancestors AS ( + SELECT id AS collection_id, id AS ancestor_id FROM collections + UNION ALL + SELECT ca.collection_id, c.parent_collection_id AS ancestor_id + FROM collection_ancestors ca + JOIN collections c ON c.id = ca.ancestor_id + WHERE c.parent_collection_id IS NOT NULL + ) SELECT sub.id FROM ( SELECT d.id FROM documents d WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status IN (:statuses) @@ -153,7 +174,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id - JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id JOIN user_roles ur ON ur.role_id = cp.role_id WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) @@ -161,7 +183,8 @@ AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id - JOIN collection_permissions cp ON cp.collection_id = cd.collection_id + JOIN collection_ancestors ca ON ca.collection_id = cd.collection_id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id JOIN users u ON u.department_id = cp.department_id WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) From a715b9f87c02fabfb0a8e84bbd7840b390e44c43 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 18 Aug 2026 21:23:04 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20ROLE=3DUSER=20=EB=8C=80=EC=83=81?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=EB=B6=80=EC=97=AC=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USER role은 가입 시 전원에게 자동 부여되는 기본 role이라, targetType=ROLE로 이 role을 대상 지정하면 사실상 전체공개(PUBLIC보다도 넓은 범위 — WRITE/ADMIN 까지 전체에 열릴 수 있음)가 되는 위험한 함정이었다. ErrorCode.ROLE_NOT_GRANTABLE(PERMISSION-004, 400) 추가, Document/Collection PermissionCommandService의 grantPermission()에서 targetType=ROLE로 조회한 role의 code가 "USER"면 예외를 던지도록 가드 추가. Co-Authored-By: Claude Sonnet 5 --- .../service/command/CollectionPermissionCommandService.java | 4 ++++ .../service/command/DocumentPermissionCommandService.java | 4 ++++ .../com/opensource/docgrid/global/exception/ErrorCode.java | 2 ++ 3 files changed, 10 insertions(+) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java index 8d8d4765..5760c2ec 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/CollectionPermissionCommandService.java @@ -73,6 +73,10 @@ public CollectionPermissionResponse grantPermission(Long collectionId, Long gran } else if (request.targetType() == PermissionTargetType.ROLE) { targetRole = roleRepository.findById(request.roleId()) .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND)); + // 모든 사용자가 기본으로 가진 USER role을 대상으로 지정하면 사실상 전체 공개가 되므로 차단한다. + if ("USER".equals(targetRole.getCode())) { + throw new DocGridException(ErrorCode.ROLE_NOT_GRANTABLE); + } } else { targetDepartment = departmentRepository.findById(request.departmentId()) .orElseThrow(() -> new DocGridException(ErrorCode.DEPARTMENT_NOT_FOUND)); diff --git a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java index 25113094..61ec58fa 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/permission/service/command/DocumentPermissionCommandService.java @@ -66,6 +66,10 @@ public DocumentPermissionResponse grantPermission(Long documentId, Long grantorI } else if (request.targetType() == PermissionTargetType.ROLE) { targetRole = roleRepository.findById(request.roleId()) .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND)); + // 모든 사용자가 기본으로 가진 USER role을 대상으로 지정하면 사실상 전체 공개가 되므로 차단한다. + if ("USER".equals(targetRole.getCode())) { + throw new DocGridException(ErrorCode.ROLE_NOT_GRANTABLE); + } } else { targetDepartment = departmentRepository.findById(request.departmentId()) .orElseThrow(() -> new DocGridException(ErrorCode.DEPARTMENT_NOT_FOUND)); diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index 31015146..edfcc1d2 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -173,6 +173,8 @@ public enum ErrorCode { INVALID_TARGET_TYPE(HttpStatus.BAD_REQUEST, "PERMISSION-001", "target_type과 ID 필드 조합이 올바르지 않습니다."), COLLECTION_PERMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "PERMISSION-002", "컬렉션 권한을 찾을 수 없습니다."), DOCUMENT_PERMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "PERMISSION-003", "문서 권한을 찾을 수 없습니다."), + ROLE_NOT_GRANTABLE(HttpStatus.BAD_REQUEST, "PERMISSION-004", + "USER role은 모든 사용자가 보유하고 있어 권한 부여 대상으로 지정할 수 없습니다. 전체 공개가 목적이면 visibility를 PUBLIC으로 설정하세요."), // WORKER // Claim 요청의 Worker 식별자가 등록된 실행 인스턴스와 연결되지 않은 경우 사용한다. From 644380f9ee69e81c3b0dc22d8349162a3dda10a8 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 18 Aug 2026 21:23:12 +0900 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=EC=97=AD=ED=95=A0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DepartmentController와 동일한 패턴으로 GET /roles 신규(RoleController/ RoleQueryService/RoleResponse). 권한 부여 폼에서 ROLE 대상 ID를 숫자로 외워서 입력해야 하던 UX 문제 해결용 — 이름 드롭다운 구현에 필요. Co-Authored-By: Claude Sonnet 5 --- .../user/controller/RoleController.java | 32 +++++++++++++++++++ .../user/dto/response/RoleResponse.java | 15 +++++++++ .../user/service/query/RoleQueryService.java | 26 +++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/user/controller/RoleController.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/RoleResponse.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/user/service/query/RoleQueryService.java diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/controller/RoleController.java b/backend/src/main/java/com/opensource/docgrid/domain/user/controller/RoleController.java new file mode 100644 index 00000000..c6a0bf86 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/controller/RoleController.java @@ -0,0 +1,32 @@ +package com.opensource.docgrid.domain.user.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.opensource.docgrid.domain.user.dto.response.RoleResponse; +import com.opensource.docgrid.domain.user.service.query.RoleQueryService; +import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.ResponseUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Role", description = "역할 관련 API") +@RestController +@RequestMapping("/roles") +@RequiredArgsConstructor +public class RoleController { + + private final RoleQueryService roleQueryService; + + @Operation(summary = "역할 목록 조회", description = "권한 부여 대상 선택 등에 쓰는 전체 역할 목록을 반환합니다.") + @GetMapping + public ResponseEntity>> getRoles() { + return ResponseUtils.ok(roleQueryService.getRoles()); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/RoleResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/RoleResponse.java new file mode 100644 index 00000000..a7e54f84 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/dto/response/RoleResponse.java @@ -0,0 +1,15 @@ +package com.opensource.docgrid.domain.user.dto.response; + +import com.opensource.docgrid.domain.user.entity.Role; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record RoleResponse( + @Schema(description = "역할 ID") Long id, + @Schema(description = "역할명") String name, + @Schema(description = "역할 코드") String code +) { + public static RoleResponse from(Role role) { + return new RoleResponse(role.getId(), role.getName(), role.getCode()); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/RoleQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/RoleQueryService.java new file mode 100644 index 00000000..e1dfb218 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/service/query/RoleQueryService.java @@ -0,0 +1,26 @@ +package com.opensource.docgrid.domain.user.service.query; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.user.dto.response.RoleResponse; +import com.opensource.docgrid.domain.user.repository.RoleRepository; + +import lombok.RequiredArgsConstructor; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class RoleQueryService { + + private final RoleRepository roleRepository; + + public List getRoles() { + return roleRepository.findAll() + .stream() + .map(RoleResponse::from) + .toList(); + } +} From 7c934e8a24bbb669aa76a1dd786b4cede66f86af Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 18 Aug 2026 21:23:20 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20=EC=BB=AC=EB=A0=89=EC=85=98=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=C2=B7=EB=AA=A9=EB=A1=9D/=EA=B2=80=EC=83=89=C2=B7=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=EB=B6=80=EC=97=AC=20=ED=8F=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CollectionsPage: 생성모달에 상위 폴더 드롭다운, 상세페이지에 하위 컬렉션 섹션(클릭 시 이동), 하위 컬렉션 있을 때 cascade 삭제 경고, 목록 페이지네이션·이름/설명 검색창 추가 - PermissionsPage: 대상 타입 ROLE/DEPARTMENT 선택 시 숫자 입력 대신 이름 드롭다운(ROLE은 USER 필터링됨)으로 변경 - SearchPage: 컬렉션 드롭다운을 PageResponse 응답 형태에 맞게 수정 - api-types: Role 타입 추가 Co-Authored-By: Claude Sonnet 5 --- frontend/app/features/CollectionsPage.tsx | 56 ++++++++++++++++++----- frontend/app/features/PermissionsPage.tsx | 13 ++++-- frontend/app/features/SearchPage.tsx | 4 +- frontend/app/lib/api-types.ts | 2 + 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/frontend/app/features/CollectionsPage.tsx b/frontend/app/features/CollectionsPage.tsx index 0beb238c..993fc1a7 100644 --- a/frontend/app/features/CollectionsPage.tsx +++ b/frontend/app/features/CollectionsPage.tsx @@ -4,12 +4,16 @@ /* eslint-disable @next/next/no-html-link-for-pages */ import { FormEvent, useCallback, useEffect, useState } from "react"; -import { apiRequest, errorMessage } from "../lib/api"; +import { apiRequest, errorMessage, toQuery } from "../lib/api"; import type { Collection, CollectionDocument, DocumentSummary, PageResponse } from "../lib/api-types"; import { EmptyState, ErrorState, LoadingState, PageHeading, StatusPill, formatDate } from "../components/ui"; export function CollectionsPage({ notify }: { notify: (message: string) => void }) { - const [collections, setCollections] = useState([]); + const [collections, setCollections] = useState | null>(null); + const [parentCandidates, setParentCandidates] = useState([]); + const [keywordInput, setKeywordInput] = useState(""); + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(0); const [creating, setCreating] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [loading, setLoading] = useState(true); @@ -18,22 +22,39 @@ export function CollectionsPage({ notify }: { notify: (message: string) => void const load = useCallback(async () => { setLoading(true); setError(""); - try { setCollections(await apiRequest("/collections")); } + try { setCollections(await apiRequest>(`/collections${toQuery({ keyword, page, size: 20 })}`)); } catch (reason) { setError(errorMessage(reason)); } finally { setLoading(false); } - }, []); + }, [keyword, page]); + + function search(event: FormEvent) { + event.preventDefault(); + const nextKeyword = keywordInput.trim(); + setPage(0); + if (nextKeyword === keyword && page === 0) void load(); + else setKeyword(nextKeyword); + } useEffect(() => { const timer = window.setTimeout(() => void load(), 0); return () => window.clearTimeout(timer); }, [load]); + async function openCreateModal() { + try { + // 상위 폴더 후보는 페이지네이션과 무관하게 넉넉히 한 번에 가져온다. + setParentCandidates((await apiRequest>("/collections?page=0&size=100")).content); + } catch (reason) { setError(errorMessage(reason)); } + setModalOpen(true); + } + async function create(event: FormEvent) { event.preventDefault(); setCreating(true); const form = new FormData(event.currentTarget); + const parentCollectionId = form.get("parentCollectionId"); try { - await apiRequest("/collections", { method: "POST", body: { name: String(form.get("name")), description: String(form.get("description") || ""), parentCollectionId: null, visibility: String(form.get("visibility")) } }); + await apiRequest("/collections", { method: "POST", body: { name: String(form.get("name")), description: String(form.get("description") || ""), parentCollectionId: parentCollectionId ? Number(parentCollectionId) : null, visibility: String(form.get("visibility")) } }); setModalOpen(false); notify("새 컬렉션을 만들었습니다."); await load(); @@ -42,17 +63,22 @@ export function CollectionsPage({ notify }: { notify: (message: string) => void } return
- setModalOpen(true)}>+ 새 컬렉션} /> + void openCreateModal()}>+ 새 컬렉션} /> +
{error ? void load()} /> : null} {loading ? : null} - {!loading && !error && !collections.length ? : null} - {!loading && collections.length ? : null} - {modalOpen ?

새 컬렉션

관련 문서를 하나의 검색 범위로 묶습니다.