Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,41 @@
import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.opensource.docgrid.domain.auth.annotation.CurrentUser;
import com.opensource.docgrid.domain.collection.dto.request.AddDocumentRequest;
import com.opensource.docgrid.domain.collection.dto.request.CreateCollectionRequest;
import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentListItemResponse;
import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentResponse;
import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse;
import com.opensource.docgrid.domain.collection.service.command.CollectionCommandService;
import com.opensource.docgrid.domain.collection.service.query.CollectionQueryService;
import com.opensource.docgrid.global.common.response.ApiResponse;
import com.opensource.docgrid.global.common.response.PageResponse;
import com.opensource.docgrid.global.common.response.ResponseUtils;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;

/**
* 컬렉션 생성·조회·삭제와 컬렉션 문서 구성 및 읽기 가능한 문서 목록 API를 제공한다.
*/
@Tag(name = "Collection", description = "컬렉션 관련 API")
@Validated
@RestController
@RequestMapping("/collections")
@RequiredArgsConstructor
Expand Down Expand Up @@ -95,6 +105,20 @@ public ResponseEntity<ApiResponse<CollectionResponse>> getCollection(
return ResponseUtils.ok(collectionQueryService.getCollection(userId, collectionId));
}

@Operation(
summary = "컬렉션 문서 목록 조회",
description = "컬렉션을 읽을 수 있는 사용자가 개별 문서 읽기 권한도 가진 항목만 추가 최신순으로 페이지 조회합니다. " +
"숨김 문서는 응답 데이터와 전체 개수에 포함하지 않습니다."
)
@GetMapping("/{collectionId}/documents")
public ResponseEntity<ApiResponse<PageResponse<CollectionDocumentListItemResponse>>> getCollectionDocuments(
@PathVariable Long collectionId,
@Parameter(hidden = true) @CurrentUser Long userId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return ResponseUtils.ok(collectionQueryService.getCollectionDocuments(userId, collectionId, page, size));
}

@Operation(
summary = "컬렉션에 문서 추가",
description = "컬렉션에 문서를 추가합니다. 컬렉션 쓰기 권한(WRITE 또는 ADMIN, 소유자 포함)이 있는 사용자만 가능합니다. 이미 추가된 문서면 409를 반환합니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,25 @@

import org.springframework.stereotype.Component;

import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentListItemResponse;
import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentResponse;
import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse;
import com.opensource.docgrid.domain.collection.entity.CollectionDocument;
import com.opensource.docgrid.domain.collection.entity.DocumentCollection;
import com.opensource.docgrid.domain.document.converter.DocumentSummaryConverter;

import lombok.RequiredArgsConstructor;

/**
* 컬렉션과 컬렉션-문서 매핑 Entity를 공개 응답 DTO로 변환한다.
* 문서 목록 응답은 읽기 권한 검증이 끝난 매핑만 전달받는다.
*/
@Component
@RequiredArgsConstructor
public class CollectionConverter {

private final DocumentSummaryConverter documentSummaryConverter;

public CollectionResponse toResponse(DocumentCollection collection) {
Long parentId = collection.getParentCollection() != null
? collection.getParentCollection().getId()
Expand Down Expand Up @@ -37,4 +48,17 @@ public CollectionDocumentResponse toDocumentResponse(CollectionDocument cd) {
cd.getAddedAt()
);
}

public CollectionDocumentListItemResponse toDocumentListItemResponse(CollectionDocument collectionDocument) {
Long addedById = collectionDocument.getAddedBy() != null
? collectionDocument.getAddedBy().getId()
: null;

return new CollectionDocumentListItemResponse(
collectionDocument.getCollection().getId(),
documentSummaryConverter.toResponse(collectionDocument.getDocument()),
addedById,
collectionDocument.getAddedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.opensource.docgrid.domain.collection.dto.response;

import java.time.LocalDateTime;

import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse;

import io.swagger.v3.oas.annotations.media.Schema;

/**
* 컬렉션 문서 목록에서 문서 Metadata와 컬렉션 추가 이력을 함께 노출하는 응답이다.
* 개별 문서 읽기 권한을 통과한 문서만 이 응답으로 변환된다.
*/
@Schema(description = "컬렉션에 포함된 읽기 가능한 문서")
public record CollectionDocumentListItemResponse(
@Schema(description = "컬렉션 ID") Long collectionId,
@Schema(description = "문서 Metadata와 현재 버전 요약") DocumentSummaryResponse document,
@Schema(description = "컬렉션에 문서를 추가한 사용자 ID") Long addedBy,
@Schema(description = "컬렉션에 문서를 추가한 시각") LocalDateTime addedAt
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import java.util.List;
import java.util.Optional;

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.CollectionDocument;

Expand All @@ -13,6 +17,34 @@ public interface CollectionDocumentRepository extends JpaRepository<CollectionDo

List<CollectionDocument> findAllByCollectionId(Long collectionId);

/**
* 권한 선필터를 통과한 컬렉션 문서를 현재 버전 Metadata와 함께 페이지 조회한다.
*/
@Query(
value = """
SELECT cd
FROM CollectionDocument cd
JOIN FETCH cd.collection
JOIN FETCH cd.document d
JOIN FETCH d.owner
LEFT JOIN FETCH d.currentVersion
LEFT JOIN FETCH cd.addedBy
WHERE cd.collection.id = :collectionId
AND d.id IN :documentIds
""",
countQuery = """
SELECT COUNT(cd)
FROM CollectionDocument cd
WHERE cd.collection.id = :collectionId
AND cd.document.id IN :documentIds
"""
)
Page<CollectionDocument> findReadableDocuments(
@Param("collectionId") Long collectionId,
@Param("documentIds") List<Long> documentIds,
Pageable pageable
);

// 컬렉션-문서 연결 단건 조회 (문서 제거용)
Optional<CollectionDocument> findByCollectionIdAndDocumentId(Long collectionId, Long documentId);
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
package com.opensource.docgrid.domain.collection.service.query;

import java.util.EnumSet;
import java.util.List;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.opensource.docgrid.domain.collection.converter.CollectionConverter;
import com.opensource.docgrid.domain.collection.dto.response.CollectionDocumentListItemResponse;
import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse;
import com.opensource.docgrid.domain.collection.entity.CollectionDocument;
import com.opensource.docgrid.domain.collection.entity.DocumentCollection;
import com.opensource.docgrid.domain.collection.enums.CollectionStatus;
import com.opensource.docgrid.domain.collection.repository.CollectionDocumentRepository;
import com.opensource.docgrid.domain.collection.repository.CollectionRepository;
import com.opensource.docgrid.domain.document.enums.DocumentStatus;
import com.opensource.docgrid.domain.document.repository.DocumentRepository;
import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService;
import com.opensource.docgrid.global.common.response.PageResponse;
import com.opensource.docgrid.global.exception.DocGridException;
import com.opensource.docgrid.global.exception.ErrorCode;

Expand All @@ -21,7 +32,17 @@
@RequiredArgsConstructor
public class CollectionQueryService {

private static final List<String> LISTABLE_DOCUMENT_STATUSES = EnumSet.complementOf(
EnumSet.of(DocumentStatus.DELETED)
).stream().map(DocumentStatus::name).toList();
private static final Sort COLLECTION_DOCUMENT_SORT = Sort.by(
Sort.Order.desc("addedAt"),
Sort.Order.desc("id")
);

private final CollectionRepository collectionRepository;
private final CollectionDocumentRepository collectionDocumentRepository;
private final DocumentRepository documentRepository;
private final CollectionConverter collectionConverter;
private final PermissionQueryService permissionQueryService;

Expand All @@ -43,4 +64,43 @@ public List<CollectionResponse> getMyCollections(Long userId) {
.map(collectionConverter::toResponse)
.toList();
}

/**
* 컬렉션을 볼 수 있고 각 문서도 읽을 수 있는 항목만 페이지 응답으로 반환한다.
*/
public PageResponse<CollectionDocumentListItemResponse> getCollectionDocuments(
Long userId,
Long collectionId,
int page,
int size) {
// 1. 컬렉션 자체의 존재·삭제·읽기 권한을 문서 Metadata 조회보다 먼저 검증한다.
DocumentCollection collection = collectionRepository.findById(collectionId)
.filter(c -> c.getStatus() != CollectionStatus.DELETED)
.orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND));
if (!permissionQueryService.canReadCollection(userId, collection)) {
throw new DocGridException(ErrorCode.PERMISSION_DENIED);
}

// 2. 읽기 가능한 문서 ID만 선별해 숨김 문서가 Content와 전체 개수 모두에 포함되지 않게 한다.
Pageable pageable = PageRequest.of(page, size, COLLECTION_DOCUMENT_SORT);
List<Long> readableDocumentIds = documentRepository.findReadableDocumentIdsInCollection(
userId,
collectionId,
LISTABLE_DOCUMENT_STATUSES
);
if (readableDocumentIds.isEmpty()) {
return PageResponse.from(Page.empty(pageable), List.of());
}

// 3. Transaction 안에서 현재 버전과 추가 이력을 공개 DTO로 변환한다.
Page<CollectionDocument> collectionDocuments = collectionDocumentRepository.findReadableDocuments(
collectionId,
readableDocumentIds,
pageable
);
List<CollectionDocumentListItemResponse> content = collectionDocuments.getContent().stream()
.map(collectionConverter::toDocumentListItemResponse)
.toList();
return PageResponse.from(collectionDocuments, content);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.opensource.docgrid.domain.permission.controller;

import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
Expand Down Expand Up @@ -49,6 +51,30 @@ public ResponseEntity<ApiResponse<DocumentPermissionSummaryResponse>> getMyDocum
return ResponseUtils.ok(permissionQueryService.checkDocumentPermission(userId, documentId));
}

@Operation(
summary = "문서 직접 권한 목록 조회",
description = "문서 ADMIN 권한 보유자가 해당 문서에 직접 부여된 USER/ROLE/DEPARTMENT 권한 전체를 조회합니다. " +
"계산되거나 컬렉션에서 상속된 권한은 포함하지 않으며 만료된 직접 권한은 포함합니다."
)
@GetMapping("/documents/{documentId}")
public ResponseEntity<ApiResponse<List<DocumentPermissionResponse>>> getDocumentPermissions(
@PathVariable Long documentId,
@Parameter(hidden = true) @CurrentUser Long userId) {
return ResponseUtils.ok(permissionQueryService.getDocumentPermissions(userId, documentId));
}

@Operation(
summary = "컬렉션 직접 권한 목록 조회",
description = "컬렉션 ADMIN 권한 보유자가 해당 컬렉션에 직접 부여된 USER/ROLE/DEPARTMENT 권한 전체를 조회합니다. " +
"계산된 권한은 포함하지 않으며 만료된 직접 권한은 포함합니다."
)
@GetMapping("/collections/{collectionId}")
public ResponseEntity<ApiResponse<List<CollectionPermissionResponse>>> getCollectionPermissions(
@PathVariable Long collectionId,
@Parameter(hidden = true) @CurrentUser Long userId) {
return ResponseUtils.ok(permissionQueryService.getCollectionPermissions(userId, collectionId));
}

@Operation(
summary = "컬렉션 권한 부여",
description = "컬렉션에 USER/ROLE/DEPARTMENT 단위로 권한을 부여합니다. 컬렉션 ADMIN 권한 보유자(소유자 포함)만 가능합니다. " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import com.opensource.docgrid.domain.permission.entity.CollectionPermission;
import com.opensource.docgrid.domain.permission.entity.DocumentPermission;

/**
* 문서·컬렉션 직접 권한 Entity를 권한 관리 API의 공개 응답으로 변환한다.
*/
@Component
public class PermissionConverter {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ public interface CollectionPermissionRepository extends JpaRepository<Collection
// 컬렉션에 속한 권한 전체 조회 (soft delete 시 캐시 무효화 + 권한 삭제용)
List<CollectionPermission> findAllByCollectionId(Long collectionId);

/**
* 컬렉션에 직접 부여된 권한을 대상·부여자 정보와 함께 최신순으로 조회한다.
*/
@Query("""
SELECT cp
FROM CollectionPermission cp
JOIN FETCH cp.collection
LEFT JOIN FETCH cp.user
LEFT JOIN FETCH cp.role
LEFT JOIN FETCH cp.department
LEFT JOIN FETCH cp.grantedBy
WHERE cp.collection.id = :collectionId
ORDER BY cp.grantedAt DESC, cp.id DESC
""")
List<CollectionPermission> findAllWithTargetsByCollectionId(@Param("collectionId") Long collectionId);

// ROLE live — 사용자 역할 기반 컬렉션→문서 읽기 권한 존재 여부
@Query("""
SELECT COUNT(cp) > 0 FROM CollectionPermission cp
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.opensource.docgrid.domain.permission.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -8,6 +10,22 @@

public interface DocumentPermissionRepository extends JpaRepository<DocumentPermission, Long> {

/**
* 문서에 직접 부여된 권한을 대상·부여자 정보와 함께 최신순으로 조회한다.
*/
@Query("""
SELECT dp
FROM DocumentPermission dp
JOIN FETCH dp.document
LEFT JOIN FETCH dp.user
LEFT JOIN FETCH dp.role
LEFT JOIN FETCH dp.department
LEFT JOIN FETCH dp.grantedBy
WHERE dp.document.id = :documentId
ORDER BY dp.grantedAt DESC, dp.id DESC
""")
List<DocumentPermission> findAllWithTargetsByDocumentId(@Param("documentId") Long documentId);

// ROLE live — 사용자 역할 기반 문서 읽기 권한 존재 여부
@Query("""
SELECT COUNT(dp) > 0 FROM DocumentPermission dp
Expand Down
Loading