diff --git a/.gitignore b/.gitignore index 228ec49..73cb6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules/ .env .env.local .env.*.local +config.json # Turbo .turbo diff --git a/api/src/main/java/com/nextdocs/api/auth/security/SecurityConfig.java b/api/src/main/java/com/nextdocs/api/auth/security/SecurityConfig.java index b93fb3b..6c48509 100644 --- a/api/src/main/java/com/nextdocs/api/auth/security/SecurityConfig.java +++ b/api/src/main/java/com/nextdocs/api/auth/security/SecurityConfig.java @@ -40,6 +40,7 @@ public class SecurityConfig { "/api/v1/auth/login", "/api/v1/auth/refresh", "/api/v1/documents/*/public", + "/api/v1/documents/*/public/path", // OpenAPI / Swagger UI "/v3/api-docs/**", "/swagger-ui/**", diff --git a/api/src/main/java/com/nextdocs/api/common/exception/GlobalExceptionHandler.java b/api/src/main/java/com/nextdocs/api/common/exception/GlobalExceptionHandler.java index 58e4eb7..b9957e0 100644 --- a/api/src/main/java/com/nextdocs/api/common/exception/GlobalExceptionHandler.java +++ b/api/src/main/java/com/nextdocs/api/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.nextdocs.api.common.exception; import com.nextdocs.api.common.response.ApiResponse; +import java.util.Objects; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -23,8 +24,12 @@ public ResponseEntity> handleApiException(ApiException ex) { } else { log.debug("ApiException [{}]: {}", ex.getErrorCode(), ex.getMessage()); } + String detail = ex.getMessage(); + if (detail != null && Objects.equals(detail, ex.getErrorCode().defaultMessage())) { + detail = null; + } return ResponseEntity.status(ex.getErrorCode().httpStatus()) - .body(ApiResponse.error(ex.getErrorCode().defaultMessage())); + .body(ApiResponse.error(ex.getErrorCode().defaultMessage(), detail)); } @ExceptionHandler(MethodArgumentNotValidException.class) @@ -55,6 +60,14 @@ public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMi .body(ApiResponse.error(ErrorCode.VALIDATION_FAILED.defaultMessage(), detail)); } + @ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class) + public ResponseEntity> handleNoResourceFound( + org.springframework.web.servlet.resource.NoResourceFoundException ex) { + log.debug("Resource not found: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(ErrorCode.NOT_FOUND.defaultMessage())); + } + @ExceptionHandler(AccessDeniedException.class) public ResponseEntity> handleAccessDenied(AccessDeniedException ex) { log.warn("Access denied: {}", ex.getMessage(), ex); diff --git a/api/src/main/java/com/nextdocs/api/document/controller/DocumentController.java b/api/src/main/java/com/nextdocs/api/document/controller/DocumentController.java index 8bc2a48..db66256 100644 --- a/api/src/main/java/com/nextdocs/api/document/controller/DocumentController.java +++ b/api/src/main/java/com/nextdocs/api/document/controller/DocumentController.java @@ -4,14 +4,18 @@ import com.nextdocs.api.common.response.ApiResponse; import com.nextdocs.api.common.response.PagedResponse; import com.nextdocs.api.document.dto.request.DocumentCreateRequest; +import com.nextdocs.api.document.dto.request.DocumentMoveRequest; import com.nextdocs.api.document.dto.request.DocumentUpdateRequest; +import com.nextdocs.api.document.dto.response.DocumentBreadcrumbResponse; import com.nextdocs.api.document.dto.response.DocumentResponse; import com.nextdocs.api.document.service.DocumentService; +import com.nextdocs.api.document.service.DocumentTreeService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -30,6 +34,7 @@ public class DocumentController { private final DocumentService documentService; + private final DocumentTreeService documentTreeService; @Operation( summary = "Create a document", @@ -58,14 +63,18 @@ public ResponseEntity> create( } @Operation( - summary = "List current user's documents", - description = "Returns a paged list of documents owned by the authenticated user. " - + "By default only active documents are returned (ordered by last update). " - + "Use trashed=true to list documents in trash (ordered by time moved to trash).", + summary = "List documents", + description = "Returns a paged list of documents filtered by parentId, scope, and trashed. " + + "Use parentId=root for root-level documents, or parentId= for direct children. " + + "Use scope=private for unshared owned documents, scope=shared for shared documents, " + + "or scope=all for all owned documents. Use trashed=true for trash.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", description = "Documents returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid filter parameters"), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "401", description = "Authentication required") @@ -73,18 +82,20 @@ public ResponseEntity> create( @GetMapping public ResponseEntity>> list( @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(required = false) String parentId, + @RequestParam(required = false, defaultValue = "all") String scope, @RequestParam(required = false) Boolean trashed, @PageableDefault(size = 20) Pageable pageable) { - boolean trashedOnly = Boolean.TRUE.equals(trashed); - Page page = documentService.list(principal.getId(), pageable, trashedOnly); + Page page = documentService.list(principal.getId(), parentId, scope, trashed, pageable); return ResponseEntity.ok(ApiResponse.ok(PagedResponse.from(page))); } @Operation( summary = "Get a single document", - description = "Returns one document if it exists and belongs to the authenticated user. " + description = "Returns one document the authenticated user can access. " + "Trashed documents are omitted by default (404) so realtime access checks stay strict. " - + "Pass includeTrashed=true to load a trashed document (e.g. trash UI or restore).", + + "Pass includeTrashed=true to load a trashed document (e.g. trash UI or restore); trashed " + + "documents are visible to their owner and collaborators with at least EDIT access.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", @@ -122,6 +133,43 @@ public ResponseEntity> getPublic(@PathVariable UUI return ResponseEntity.ok(ApiResponse.ok(documentService.getPublic(id))); } + @Operation( + summary = "Get document breadcrumbs hierarchy", + description = "Returns the ancestor hierarchy path from the root document down to the specified document.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Hierarchy path returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document not found") + }) + @GetMapping("/{id}/path") + public ResponseEntity>> getBreadcrumbs( + @AuthenticationPrincipal UserPrincipal principal, @PathVariable UUID id) { + return ResponseEntity.ok(ApiResponse.ok(documentService.getBreadcrumbs(principal.getId(), id))); + } + + @Operation( + summary = "Get public document breadcrumbs hierarchy", + description = "Returns the ancestor hierarchy path for a publicly accessible document.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Public hierarchy path returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document not found or not shared publicly") + }) + @SecurityRequirements({}) + @GetMapping("/{id}/public/path") + public ResponseEntity>> getPublicBreadcrumbs(@PathVariable UUID id) { + return ResponseEntity.ok(ApiResponse.ok(documentService.getPublicBreadcrumbs(id))); + } + @Operation( summary = "Update a document", description = "Updates metadata and/or Yjs state for an active document owned by the authenticated user. " @@ -153,8 +201,10 @@ public ResponseEntity> update( @Operation( summary = "Move a document to trash or delete permanently", - description = "By default moves the document to trash (soft delete). " - + "Use permanent=true to permanently delete a document that is already in trash.", + description = "By default moves the document to trash (soft delete). Requires EDIT access. " + + "Use permanent=true to permanently delete a document that is already in trash; " + + "permanently deleting also requires EDIT access and verifies the explicit resource ID " + + "against the caller's permission chain.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "204", @@ -180,7 +230,9 @@ public ResponseEntity delete( @Operation( summary = "Restore a document from trash", - description = "Clears trash state for a document owned by the authenticated user.", + description = "Clears trash state for a document. Requires EDIT access in the trash scope: " + + "the owner and EDIT collaborators of the trashed document (or its nearest untrashed " + + "ancestor chain) may restore it.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", @@ -198,4 +250,37 @@ public ResponseEntity> restore( DocumentResponse response = documentService.restore(principal.getId(), id); return ResponseEntity.ok(ApiResponse.ok(response, "Document restored.")); } + + @Operation( + summary = "Move a document to a new parent / position", + description = + "Relocates a document within the tree by updating its parent or personal navigation order. " + + "When newParentId is present the caller must have EDIT access to both the document and the target parent " + + "(owner or collaborator via ancestor sharing). " + + "When newParentId is null the document is reordered in the caller's root navigation: owners may un-parent their own documents, " + + "while collaborators with at least VIEW access may reorder a shared root document in their personal Shared section.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Document moved"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Cycle detected or invalid sibling references"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "Caller lacks required access"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document or sibling not found") + }) + @PostMapping("/{id}/move") + public ResponseEntity> move( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID id, + @Valid @RequestBody DocumentMoveRequest request) { + return ResponseEntity.ok(ApiResponse.ok(documentTreeService.move(principal.getId(), id, request))); + } } diff --git a/api/src/main/java/com/nextdocs/api/document/controller/DocumentSharingController.java b/api/src/main/java/com/nextdocs/api/document/controller/DocumentSharingController.java index ddb072c..8acc9cd 100644 --- a/api/src/main/java/com/nextdocs/api/document/controller/DocumentSharingController.java +++ b/api/src/main/java/com/nextdocs/api/document/controller/DocumentSharingController.java @@ -2,13 +2,11 @@ import com.nextdocs.api.auth.security.UserPrincipal; import com.nextdocs.api.common.response.ApiResponse; -import com.nextdocs.api.common.response.PagedResponse; import com.nextdocs.api.document.dto.request.CollaboratorAccessUpdateRequest; import com.nextdocs.api.document.dto.request.CollaboratorUpsertRequest; import com.nextdocs.api.document.dto.request.SharingSettingsUpdateRequest; import com.nextdocs.api.document.dto.response.CollaboratorResponse; import com.nextdocs.api.document.dto.response.DocumentAccessResponse; -import com.nextdocs.api.document.dto.response.DocumentResponse; import com.nextdocs.api.document.dto.response.SharingSettingsResponse; import com.nextdocs.api.document.service.DocumentSharingService; import io.swagger.v3.oas.annotations.Operation; @@ -18,9 +16,6 @@ import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -202,24 +197,6 @@ public ResponseEntity> updateSharingSetting return ResponseEntity.ok(ApiResponse.ok(sharingService.updateSharingSettings(principal.getId(), id, request))); } - @Operation( - summary = "List documents shared with me", - description = "Returns a paged list of active documents shared with the authenticated user.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "Shared documents returned"), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "Authentication required") - }) - @GetMapping("/shared-with-me") - public ResponseEntity>> listSharedWithMe( - @AuthenticationPrincipal UserPrincipal principal, @PageableDefault(size = 20) Pageable pageable) { - Page page = sharingService.listSharedWithMe(principal.getId(), pageable); - return ResponseEntity.ok(ApiResponse.ok(PagedResponse.from(page))); - } - @Operation( summary = "Get my effective access", description = "Returns the authenticated user's effective access level for the specified document.", diff --git a/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentCreateRequest.java b/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentCreateRequest.java index 59ebc8d..da15475 100644 --- a/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentCreateRequest.java +++ b/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentCreateRequest.java @@ -24,4 +24,13 @@ public record DocumentCreateRequest( @Schema(description = "Optional creator label", example = "Anonymous") @Size(max = 255, message = "createdBy must be at most 255 characters") - String createdBy) {} + String createdBy, + + @Schema(description = "Parent document ID. Null creates the document at root level.") + UUID parentId, + + @Schema(description = "ID of the sibling immediately before this document's initial position.") + UUID prevSiblingId, + + @Schema(description = "ID of the sibling immediately after this document's initial position.") + UUID nextSiblingId) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentMoveRequest.java b/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentMoveRequest.java new file mode 100644 index 0000000..9cf5830 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentMoveRequest.java @@ -0,0 +1,15 @@ +package com.nextdocs.api.document.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; + +@Schema(description = "Request body for moving a document") +public record DocumentMoveRequest( + @Schema(description = "Target parent document ID. Null means move to root level.") + UUID newParentId, + + @Schema(description = "ID of the sibling immediately before the new position. Null means prepend.") + UUID prevSiblingId, + + @Schema(description = "ID of the sibling immediately after the new position. Null means append.") + UUID nextSiblingId) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentAccessResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentAccessResponse.java index c99b680..18a6e5b 100644 --- a/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentAccessResponse.java +++ b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentAccessResponse.java @@ -11,4 +11,15 @@ public record DocumentAccessResponse( @Schema(description = "Effective access level") DocumentAccessLevel accessLevel, @Schema(description = "Whether current user is owner") - boolean owner) {} + boolean owner, + + @Schema( + description = + "Whether the document is currently in trash. Trash-scope responses report the caller's pre-trash access level.", + defaultValue = "false") + boolean trashed) { + + public DocumentAccessResponse(UUID documentId, boolean allowed, DocumentAccessLevel accessLevel, boolean owner) { + this(documentId, allowed, accessLevel, owner, false); + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentBreadcrumbResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentBreadcrumbResponse.java new file mode 100644 index 0000000..9b1089d --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentBreadcrumbResponse.java @@ -0,0 +1,14 @@ +package com.nextdocs.api.document.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; + +@Schema(description = "Document breadcrumb path item") +public record DocumentBreadcrumbResponse( + @Schema(description = "Document ID") UUID id, + @Schema(description = "Document title") String title, + // Document icon is reserved for future icon/cover support; title is used primarily for now + @Schema(description = "Document icon if present") String icon, + + @Schema(description = "Parent document ID, null for root-level") + UUID parentId) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentResponse.java index b68c08d..0b716c9 100644 --- a/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentResponse.java +++ b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentResponse.java @@ -1,5 +1,6 @@ package com.nextdocs.api.document.dto.response; +import com.nextdocs.api.document.entity.DocumentAccessLevel; import io.swagger.v3.oas.annotations.media.Schema; import java.time.OffsetDateTime; import java.util.UUID; @@ -12,6 +13,20 @@ public record DocumentResponse( @Schema(description = "Base64-encoded Yjs state when requested") String yjsState, + @Schema(description = "Parent document ID, null for root-level") + UUID parentId, + + @Schema(description = "Fractional ordering key") String orderKey, + + @Schema(description = "Whether this document has active non-trashed children") + boolean hasChildren, + + @Schema(description = "Whether this document has external collaborators") + boolean hasCollaborators, + + @Schema(description = "Effective access level of the requesting user") + DocumentAccessLevel accessLevel, + @Schema(description = "Creator label") String createdBy, @Schema(description = "Creation timestamp") OffsetDateTime createdAt, @Schema(description = "Last update timestamp") OffsetDateTime updatedAt, diff --git a/api/src/main/java/com/nextdocs/api/document/entity/Document.java b/api/src/main/java/com/nextdocs/api/document/entity/Document.java index ef26c42..25823f0 100644 --- a/api/src/main/java/com/nextdocs/api/document/entity/Document.java +++ b/api/src/main/java/com/nextdocs/api/document/entity/Document.java @@ -15,7 +15,8 @@ name = "documents", indexes = { @Index(name = "idx_documents_user_created", columnList = "user_id,created_at"), - @Index(name = "idx_documents_user_updated", columnList = "user_id,updated_at") + @Index(name = "idx_documents_user_updated", columnList = "user_id,updated_at"), + @Index(name = "idx_documents_parent_sibling_order", columnList = "parent_id,sibling_order_key") }) @Getter @Setter @@ -54,6 +55,13 @@ public class Document { @Builder.Default private DocumentAccessLevel linkAccessLevel = DocumentAccessLevel.VIEW; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Document parent; + + @Column(name = "sibling_order_key") + private String siblingOrderKey; + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private OffsetDateTime createdAt; diff --git a/api/src/main/java/com/nextdocs/api/document/entity/UserDocumentOrder.java b/api/src/main/java/com/nextdocs/api/document/entity/UserDocumentOrder.java new file mode 100644 index 0000000..b546a67 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/entity/UserDocumentOrder.java @@ -0,0 +1,55 @@ +package com.nextdocs.api.document.entity; + +import com.nextdocs.api.auth.entity.User; +import jakarta.persistence.*; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table( + name = "user_document_orders", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_user_document_orders_user_doc", + columnNames = {"user_id", "document_id"}), + @UniqueConstraint( + name = "idx_user_document_orders_user_order_key", + columnNames = {"user_id", "order_key"}) + }, + indexes = { + @Index(name = "idx_user_document_orders_user_order", columnList = "user_id,order_key"), + @Index(name = "idx_user_document_orders_doc", columnList = "document_id") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserDocumentOrder { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "document_id", nullable = false) + private Document document; + + @Column(name = "order_key", nullable = false) + private String orderKey; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; +} diff --git a/api/src/main/java/com/nextdocs/api/document/repository/DocumentCollaboratorRepository.java b/api/src/main/java/com/nextdocs/api/document/repository/DocumentCollaboratorRepository.java index 3bbf1c9..089b055 100644 --- a/api/src/main/java/com/nextdocs/api/document/repository/DocumentCollaboratorRepository.java +++ b/api/src/main/java/com/nextdocs/api/document/repository/DocumentCollaboratorRepository.java @@ -16,5 +16,17 @@ public interface DocumentCollaboratorRepository extends JpaRepository findDocumentIdsWithCollaborators( + @org.springframework.data.repository.query.Param("documentIds") java.util.Collection documentIds); } diff --git a/api/src/main/java/com/nextdocs/api/document/repository/DocumentRepository.java b/api/src/main/java/com/nextdocs/api/document/repository/DocumentRepository.java index d13cff3..0a570a7 100644 --- a/api/src/main/java/com/nextdocs/api/document/repository/DocumentRepository.java +++ b/api/src/main/java/com/nextdocs/api/document/repository/DocumentRepository.java @@ -2,6 +2,8 @@ import com.nextdocs.api.document.entity.Document; import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.domain.Page; @@ -17,23 +19,118 @@ public interface DocumentRepository extends JpaRepository { Page findAllByUser_IdAndDeletedAtIsNull(UUID userId, Pageable pageable); - Page findAllByUser_IdAndDeletedAtIsNotNull(UUID userId, Pageable pageable); - Optional findByIdAndUser_IdAndDeletedAtIsNull(UUID id, UUID userId); Optional findByIdAndUser_Id(UUID id, UUID userId); - Optional findByIdAndUser_IdAndDeletedAtIsNotNull(UUID id, UUID userId); - Optional findByIdAndDeletedAtIsNull(UUID id); - @Query("SELECT d FROM Document d " - + "JOIN DocumentCollaborator c ON c.document.id = d.id " - + "WHERE c.user.id = :userId AND d.deletedAt IS NULL " - + "ORDER BY d.updatedAt DESC, d.createdAt DESC, d.id ASC") + @Query( + value = "SELECT d FROM Document d " + + "LEFT JOIN FETCH d.parent " + + "JOIN DocumentCollaborator c ON c.document.id = d.id " + + "WHERE c.user.id = :userId AND d.deletedAt IS NULL " + + "ORDER BY d.updatedAt DESC, d.createdAt DESC, d.id ASC", + countQuery = "SELECT count(d) FROM Document d " + + "JOIN DocumentCollaborator c ON c.document.id = d.id " + + "WHERE c.user.id = :userId AND d.deletedAt IS NULL") Page findSharedWithUserId(@Param("userId") UUID userId, Pageable pageable); + // All direct children of a given parent, non-trashed only; Pageable should sort by siblingOrderKey. + Page findAllByParent_IdAndDeletedAtIsNull(UUID parentId, Pageable pageable); + + // All direct children for a collection of parents, including trashed + List findAllByParent_IdIn(Collection parentIds); + + // Private root documents owned by userId without collaborators with personal navigation order + @Query("SELECT d, udo.orderKey FROM Document d " + + "LEFT JOIN UserDocumentOrder udo ON udo.document.id = d.id AND udo.user.id = :userId " + + "WHERE d.user.id = :userId AND d.parent IS NULL AND d.deletedAt IS NULL " + + "AND NOT EXISTS (SELECT 1 FROM DocumentCollaborator c WHERE c.document.id = d.id) " + + "ORDER BY udo.orderKey ASC NULLS LAST, d.createdAt ASC, d.id ASC") + Page findPrivateRootDocuments(@Param("userId") UUID userId, Pageable pageable); + + // All owned root documents (private + owner-shared) owned by userId with personal navigation order + @Query("SELECT d, udo.orderKey FROM Document d " + + "LEFT JOIN UserDocumentOrder udo ON udo.document.id = d.id AND udo.user.id = :userId " + + "WHERE d.user.id = :userId AND d.parent IS NULL AND d.deletedAt IS NULL " + + "ORDER BY udo.orderKey ASC NULLS LAST, d.createdAt ASC, d.id ASC") + Page findAllRootDocuments(@Param("userId") UUID userId, Pageable pageable); + + // Shared root documents (shared with userId OR owned by userId with collaborators) + @Query("SELECT d, udo.orderKey FROM Document d " + + "LEFT JOIN UserDocumentOrder udo ON udo.document.id = d.id AND udo.user.id = :userId " + + "WHERE d.deletedAt IS NULL " + + "AND (EXISTS (SELECT 1 FROM DocumentCollaborator c WHERE c.document.id = d.id AND c.user.id = :userId) " + + " OR (d.user.id = :userId AND d.parent IS NULL AND EXISTS (SELECT 1 FROM DocumentCollaborator c WHERE c.document.id = d.id))) " + + "ORDER BY udo.orderKey ASC NULLS LAST, d.createdAt ASC, d.id ASC") + Page findSharedRootDocuments(@Param("userId") UUID userId, Pageable pageable); + + // Sibling max key among direct children under a parent + @Query("SELECT MAX(d.siblingOrderKey) FROM Document d " + + "WHERE d.parent.id = :parentId " + + "AND d.deletedAt IS NULL " + + "AND d.id <> :excludeId") + Optional findMaxSiblingOrderKey(@Param("parentId") UUID parentId, @Param("excludeId") UUID excludeId); + + // Sibling min key among direct children under a parent + @Query("SELECT MIN(d.siblingOrderKey) FROM Document d " + + "WHERE d.parent.id = :parentId " + + "AND d.deletedAt IS NULL " + + "AND d.id <> :excludeId") + Optional findMinSiblingOrderKey(@Param("parentId") UUID parentId, @Param("excludeId") UUID excludeId); + + // Sibling key for a specific child document + @Query("SELECT d.siblingOrderKey FROM Document d WHERE d.id = :id AND d.deletedAt IS NULL") + Optional findSiblingOrderKeyById(@Param("id") UUID id); + + // Fetch all non-trashed siblings under a parent to re-index + @Query("SELECT d FROM Document d " + + "WHERE d.parent.id = :parentId " + + "AND d.deletedAt IS NULL " + + "ORDER BY d.siblingOrderKey ASC, d.createdAt ASC, d.id ASC") + List findAllSiblingsForReindex(@Param("parentId") UUID parentId); + + // Check whether a document has at least one non-trashed child + @Query("SELECT CASE WHEN COUNT(d) > 0 THEN TRUE ELSE FALSE END " + + "FROM Document d WHERE d.parent.id = :parentId AND d.deletedAt IS NULL") + boolean existsNonTrashedChildrenByParentId(@Param("parentId") UUID parentId); + + // Non-trashed child counts per parent, for batch tree listing + @Query("SELECT d.parent.id AS parentId, COUNT(d) FROM Document d " + + "WHERE d.parent.id IN :parentIds AND d.deletedAt IS NULL GROUP BY d.parent.id") + List countNonTrashedChildrenByParentIds(@Param("parentIds") Collection parentIds); + + // Effective access level per document, for batch tree listing + @Query( + value = "SELECT u.id::uuid AS document_id, resolve_effective_access(:userId, u.id::uuid) AS access_level " + + "FROM unnest(string_to_array(:ids, ',')) AS u(id)", + nativeQuery = true) + List resolveEffectiveAccessBatch(@Param("userId") UUID userId, @Param("ids") String ids); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("DELETE FROM Document d WHERE d.deletedAt IS NOT NULL AND d.deletedAt < :cutoff") int deleteExpiredTrash(@Param("cutoff") OffsetDateTime cutoff); + + @Query(value = "SELECT resolve_effective_access(:userId, :documentId)", nativeQuery = true) + String resolveEffectiveAccess(@Param("userId") UUID userId, @Param("documentId") UUID documentId); + + // Effective access level including trashed documents: resolves against the trash bundle + // root (topmost contiguous trashed ancestor, or the document itself). + @Query(value = "SELECT resolve_trash_access(:userId, :documentId)", nativeQuery = true) + String resolveTrashAccess(@Param("userId") UUID userId, @Param("documentId") UUID documentId); + + @Query( + value = "SELECT u.id::uuid AS document_id, resolve_trash_access(:userId, u.id::uuid) AS access_level " + + "FROM unnest(string_to_array(:ids, ',')) AS u(id)", + nativeQuery = true) + List resolveTrashAccessBatch(@Param("userId") UUID userId, @Param("ids") String ids); + + // Trashed documents the user may manage: EDIT-level trash access on the trash bundle root. + // Documents grafted into another user's trashed subtree follow that subtree's fate and are + // not listed for creators who cannot manage the bundle. + @Query("SELECT d FROM Document d " + + "WHERE d.deletedAt IS NOT NULL " + + "AND FUNCTION('resolve_trash_access', :userId, d.id) IN ('EDIT', 'OWNER')") + Page findAccessibleTrashedDocuments(@Param("userId") UUID userId, Pageable pageable); } diff --git a/api/src/main/java/com/nextdocs/api/document/repository/UserDocumentOrderRepository.java b/api/src/main/java/com/nextdocs/api/document/repository/UserDocumentOrderRepository.java new file mode 100644 index 0000000..527210f --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/repository/UserDocumentOrderRepository.java @@ -0,0 +1,65 @@ +package com.nextdocs.api.document.repository; + +import com.nextdocs.api.document.entity.UserDocumentOrder; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +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 org.springframework.stereotype.Repository; + +@Repository +public interface UserDocumentOrderRepository extends JpaRepository { + + Optional findByUser_IdAndDocument_Id(UUID userId, UUID documentId); + + boolean existsByUser_IdAndDocument_Id(UUID userId, UUID documentId); + + boolean existsByUser_IdAndOrderKey(UUID userId, String orderKey); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM UserDocumentOrder udo WHERE udo.user.id = :userId AND udo.document.id = :documentId") + void deleteByUser_IdAndDocument_Id(@Param("userId") UUID userId, @Param("documentId") UUID documentId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM UserDocumentOrder udo WHERE udo.document.id = :documentId") + void deleteByDocument_Id(@Param("documentId") UUID documentId); + + @Query("SELECT MAX(udo.orderKey) FROM UserDocumentOrder udo " + + "WHERE udo.user.id = :userId AND udo.document.deletedAt IS NULL AND udo.document.id <> :excludeDocId") + Optional findMaxOrderKeyByUserId(@Param("userId") UUID userId, @Param("excludeDocId") UUID excludeDocId); + + @Query("SELECT MAX(udo.orderKey) FROM UserDocumentOrder udo " + + "WHERE udo.user.id = :userId AND udo.document.deletedAt IS NULL " + + "AND udo.document.id <> :excludeDocId AND udo.orderKey < :key") + Optional findMaxOrderKeyLessThan( + @Param("userId") UUID userId, @Param("key") String key, @Param("excludeDocId") UUID excludeDocId); + + @Query("SELECT MIN(udo.orderKey) FROM UserDocumentOrder udo " + + "WHERE udo.user.id = :userId AND udo.document.deletedAt IS NULL " + + "AND udo.document.id <> :excludeDocId AND udo.orderKey > :key") + Optional findMinOrderKeyGreaterThan( + @Param("userId") UUID userId, @Param("key") String key, @Param("excludeDocId") UUID excludeDocId); + + @Query("SELECT MIN(udo.orderKey) FROM UserDocumentOrder udo " + + "WHERE udo.user.id = :userId AND udo.document.deletedAt IS NULL AND udo.document.id <> :excludeDocId") + Optional findMinOrderKeyByUserId(@Param("userId") UUID userId, @Param("excludeDocId") UUID excludeDocId); + + @Query("SELECT udo.orderKey FROM UserDocumentOrder udo " + + "WHERE udo.user.id = :userId AND udo.document.id = :documentId AND udo.document.deletedAt IS NULL") + Optional findOrderKeyByUserIdAndDocumentId( + @Param("userId") UUID userId, @Param("documentId") UUID documentId); + + @Query("SELECT udo.document.id, udo.orderKey FROM UserDocumentOrder udo " + + "WHERE udo.user.id = :userId AND udo.document.id IN :documentIds AND udo.document.deletedAt IS NULL") + List findOrderKeysByUserIdAndDocumentIds( + @Param("userId") UUID userId, @Param("documentIds") Collection documentIds); + + @Query("SELECT udo FROM UserDocumentOrder udo " + + "WHERE udo.user.id = :userId " + + "ORDER BY udo.orderKey ASC, udo.createdAt ASC, udo.id ASC") + List findAllForReindex(@Param("userId") UUID userId); +} diff --git a/api/src/main/java/com/nextdocs/api/document/service/DocumentListQueryHelper.java b/api/src/main/java/com/nextdocs/api/document/service/DocumentListQueryHelper.java new file mode 100644 index 0000000..b6da771 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentListQueryHelper.java @@ -0,0 +1,469 @@ +package com.nextdocs.api.document.service; + +import com.nextdocs.api.common.exception.ApiException; +import com.nextdocs.api.common.exception.ErrorCode; +import com.nextdocs.api.document.config.DocumentProperties; +import com.nextdocs.api.document.dto.response.DocumentResponse; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; +import com.nextdocs.api.document.repository.DocumentRepository; +import com.nextdocs.api.document.repository.UserDocumentOrderRepository; +import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class DocumentListQueryHelper { + + private final DocumentRepository documentRepository; + private final DocumentCollaboratorRepository collaboratorRepository; + private final UserDocumentOrderRepository userDocumentOrderRepository; + private final DocumentProperties documentProperties; + private final PermissionService permissionService; + + @Transactional(readOnly = true) + public Page list(UUID userId, String parentId, String scope, Boolean trashed, Pageable pageable) { + boolean trashedOnly = Boolean.TRUE.equals(trashed); + if (trashedOnly) { + if (parentId != null && !parentId.trim().isEmpty()) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "parentId cannot be combined with trashed=true"); + } + if (scope != null && !scope.isBlank() && !"all".equalsIgnoreCase(scope.trim())) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "scope cannot be combined with trashed=true"); + } + return listTrashed(userId, pageable); + } + + if (parentId != null) { + String trimmedParentId = parentId.trim(); + if ("root".equalsIgnoreCase(trimmedParentId)) { + return listRootDocuments(userId, scope, pageable); + } + + return listChildDocuments(userId, trimmedParentId, scope, pageable); + } + + return listFlatDocuments(userId, scope, pageable); + } + + private Page listTrashed(UUID userId, Pageable pageable) { + Pageable effectivePageable = pageable; + if (effectivePageable == null || effectivePageable.getSort().isUnsorted()) { + Sort sort = Sort.by(Sort.Order.desc("deletedAt"), Sort.Order.asc("id")); + effectivePageable = effectivePageable != null + ? PageRequest.of(effectivePageable.getPageNumber(), effectivePageable.getPageSize(), sort) + : PageRequest.of(0, 20, sort); + } + + Page page = documentRepository.findAccessibleTrashedDocuments(userId, effectivePageable); + List docs = page.getContent(); + if (docs.isEmpty()) { + return new PageImpl<>(List.of(), effectivePageable, page.getTotalElements()); + } + + List docIds = docs.stream().map(Document::getId).toList(); + Set collaboratorDocIds = new HashSet<>(collaboratorRepository.findDocumentIdsWithCollaborators(docIds)); + Map trashAccessLevels = fetchTrashAccessLevels(userId, docIds); + + return page.map(doc -> { + OffsetDateTime deletedAt = doc.getDeletedAt(); + OffsetDateTime purgeAt = + deletedAt != null ? deletedAt.plusDays(documentProperties.getTrashRetentionDays()) : null; + DocumentAccessLevel access = trashAccessLevels.get(doc.getId()); + boolean hasCollaborators = collaboratorDocIds.contains(doc.getId()); + UUID parentDocId = doc.getParent() != null ? doc.getParent().getId() : null; + + return new DocumentResponse( + doc.getId(), + doc.getTitle(), + null, + parentDocId, + doc.getSiblingOrderKey(), + false, + hasCollaborators, + access, + doc.getCreatedBy(), + doc.getCreatedAt(), + doc.getUpdatedAt(), + deletedAt, + purgeAt); + }); + } + + private Page listRootDocuments(UUID userId, String scope, Pageable pageable) { + String normalizedScope = + scope != null && !scope.isBlank() ? scope.trim().toLowerCase() : "all"; + + if ("shared".equals(normalizedScope)) { + Page rows = documentRepository.findSharedRootDocuments(userId, pageable); + List docs = + rows.getContent().stream().map(r -> (Document) r[0]).toList(); + if (docs.isEmpty()) { + return new PageImpl<>(List.of(), pageable, rows.getTotalElements()); + } + + List docIds = docs.stream().map(Document::getId).toList(); + Map childCounts = fetchChildCounts(docIds); + Set collaboratorDocIds = + new HashSet<>(collaboratorRepository.findDocumentIdsWithCollaborators(docIds)); + List nonOwnedIds = docs.stream() + .filter(d -> !d.getUser().getId().equals(userId)) + .map(Document::getId) + .toList(); + Map accessLevels = fetchAccessLevels(userId, nonOwnedIds); + + List responses = rows.getContent().stream() + .map(r -> { + Document doc = (Document) r[0]; + String orderKey = (String) r[1]; + boolean hasChildren = childCounts.getOrDefault(doc.getId(), 0L) > 0; + boolean hasCollaborators = collaboratorDocIds.contains(doc.getId()); + DocumentAccessLevel access = doc.getUser().getId().equals(userId) + ? DocumentAccessLevel.OWNER + : accessLevels.getOrDefault(doc.getId(), null); + UUID parentDocId = + doc.getParent() != null ? doc.getParent().getId() : null; + + return new DocumentResponse( + doc.getId(), + doc.getTitle(), + null, + parentDocId, + orderKey, + hasChildren, + hasCollaborators, + access, + doc.getCreatedBy(), + doc.getCreatedAt(), + doc.getUpdatedAt(), + null, + null); + }) + .toList(); + + return new PageImpl<>(responses, pageable, rows.getTotalElements()); + } + + if ("all".equals(normalizedScope)) { + Page rows = documentRepository.findAllRootDocuments(userId, pageable); + List docs = + rows.getContent().stream().map(r -> (Document) r[0]).toList(); + if (docs.isEmpty()) { + return new PageImpl<>(List.of(), pageable, rows.getTotalElements()); + } + + List docIds = docs.stream().map(Document::getId).toList(); + Map childCounts = fetchChildCounts(docIds); + Set collaboratorDocIds = + new HashSet<>(collaboratorRepository.findDocumentIdsWithCollaborators(docIds)); + + List responses = rows.getContent().stream() + .map(r -> { + Document doc = (Document) r[0]; + String orderKey = (String) r[1]; + boolean hasChildren = childCounts.getOrDefault(doc.getId(), 0L) > 0; + boolean hasCollaborators = collaboratorDocIds.contains(doc.getId()); + + return new DocumentResponse( + doc.getId(), + doc.getTitle(), + null, + null, + orderKey, + hasChildren, + hasCollaborators, + DocumentAccessLevel.OWNER, + doc.getCreatedBy(), + doc.getCreatedAt(), + doc.getUpdatedAt(), + null, + null); + }) + .toList(); + + return new PageImpl<>(responses, pageable, rows.getTotalElements()); + } + + if ("private".equals(normalizedScope)) { + Page rows = documentRepository.findPrivateRootDocuments(userId, pageable); + List docs = + rows.getContent().stream().map(r -> (Document) r[0]).toList(); + if (docs.isEmpty()) { + return new PageImpl<>(List.of(), pageable, rows.getTotalElements()); + } + + List docIds = docs.stream().map(Document::getId).toList(); + Map childCounts = fetchChildCounts(docIds); + + List responses = rows.getContent().stream() + .map(r -> { + Document doc = (Document) r[0]; + String orderKey = (String) r[1]; + boolean hasChildren = childCounts.getOrDefault(doc.getId(), 0L) > 0; + + return new DocumentResponse( + doc.getId(), + doc.getTitle(), + null, + null, + orderKey, + hasChildren, + false, + DocumentAccessLevel.OWNER, + doc.getCreatedBy(), + doc.getCreatedAt(), + doc.getUpdatedAt(), + null, + null); + }) + .toList(); + + return new PageImpl<>(responses, pageable, rows.getTotalElements()); + } + + throw new ApiException( + ErrorCode.VALIDATION_FAILED, + "Invalid scope value for root documents: " + scope + ". Must be 'private', 'shared', or 'all'."); + } + + private Page listChildDocuments( + UUID userId, String parentIdStr, String scope, Pageable pageable) { + if (scope != null && !scope.isBlank() && !"all".equalsIgnoreCase(scope.trim())) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, + "The scope parameter is only valid for root-level or flat queries, not with a specific parentId."); + } + + UUID parentId; + try { + parentId = UUID.fromString(parentIdStr); + } catch (IllegalArgumentException e) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "Invalid parentId format: " + parentIdStr); + } + + permissionService.requireReadAccess(userId, parentId); + + Pageable effectivePageable = pageable; + if (effectivePageable == null) { + effectivePageable = PageRequest.of(0, 50, Sort.by("siblingOrderKey")); + } else if (effectivePageable.getSort().isUnsorted()) { + effectivePageable = PageRequest.of( + effectivePageable.getPageNumber(), + effectivePageable.getPageSize(), + Sort.by(Sort.Order.asc("siblingOrderKey"), Sort.Order.asc("id"))); + } + + Page page = documentRepository.findAllByParent_IdAndDeletedAtIsNull(parentId, effectivePageable); + List docs = page.getContent(); + if (docs.isEmpty()) { + return new PageImpl<>(List.of(), effectivePageable, page.getTotalElements()); + } + + List docIds = docs.stream().map(Document::getId).toList(); + Map childCounts = fetchChildCounts(docIds); + Set collaboratorDocIds = new HashSet<>(collaboratorRepository.findDocumentIdsWithCollaborators(docIds)); + + List nonOwnedIds = docs.stream() + .filter(d -> !d.getUser().getId().equals(userId)) + .map(Document::getId) + .toList(); + Map accessLevels = fetchAccessLevels(userId, nonOwnedIds); + + return page.map(doc -> { + boolean hasChildren = childCounts.getOrDefault(doc.getId(), 0L) > 0; + boolean hasCollaborators = collaboratorDocIds.contains(doc.getId()); + DocumentAccessLevel access = doc.getUser().getId().equals(userId) + ? DocumentAccessLevel.OWNER + : accessLevels.getOrDefault(doc.getId(), null); + + return new DocumentResponse( + doc.getId(), + doc.getTitle(), + null, + parentId, + doc.getSiblingOrderKey(), + hasChildren, + hasCollaborators, + access, + doc.getCreatedBy(), + doc.getCreatedAt(), + doc.getUpdatedAt(), + null, + null); + }); + } + + private Page listFlatDocuments(UUID userId, String scope, Pageable pageable) { + Pageable effectivePageable = pageable; + if (effectivePageable == null || effectivePageable.getSort().isUnsorted()) { + Sort sort = Sort.by(Sort.Order.desc("updatedAt"), Sort.Order.desc("createdAt"), Sort.Order.asc("id")); + effectivePageable = effectivePageable != null + ? PageRequest.of(effectivePageable.getPageNumber(), effectivePageable.getPageSize(), sort) + : PageRequest.of(0, 20, sort); + } + + String normalizedScope = + scope != null && !scope.isBlank() ? scope.trim().toLowerCase() : "all"; + + if ("shared".equals(normalizedScope)) { + Page page = documentRepository.findSharedWithUserId(userId, effectivePageable); + List docs = page.getContent(); + if (docs.isEmpty()) { + return new PageImpl<>(List.of(), effectivePageable, page.getTotalElements()); + } + + List docIds = docs.stream().map(Document::getId).toList(); + Map childCounts = fetchChildCounts(docIds); + Set collaboratorDocIds = + new HashSet<>(collaboratorRepository.findDocumentIdsWithCollaborators(docIds)); + Map accessLevels = fetchAccessLevels(userId, docIds); + + Set parentIds = docs.stream() + .map(Document::getParent) + .filter(Objects::nonNull) + .map(Document::getId) + .collect(Collectors.toSet()); + Map parentAccess = fetchAccessLevels(userId, parentIds); + + List rootAndFloatedDocIds = docs.stream() + .filter(d -> d.getParent() == null + || parentAccess.get(d.getParent().getId()) == null) + .map(Document::getId) + .toList(); + Map userOrderKeys = fetchUserOrderKeys(userId, rootAndFloatedDocIds); + + return page.map(doc -> { + boolean hasChildren = childCounts.getOrDefault(doc.getId(), 0L) > 0; + boolean hasCollaborators = collaboratorDocIds.contains(doc.getId()); + DocumentAccessLevel access = accessLevels.getOrDefault(doc.getId(), null); + boolean isRootForUser = doc.getParent() == null + || parentAccess.get(doc.getParent().getId()) == null; + String orderKey = isRootForUser ? userOrderKeys.get(doc.getId()) : doc.getSiblingOrderKey(); + UUID parentDocId = doc.getParent() != null ? doc.getParent().getId() : null; + + return new DocumentResponse( + doc.getId(), + doc.getTitle(), + null, + parentDocId, + orderKey, + hasChildren, + hasCollaborators, + access, + doc.getCreatedBy(), + doc.getCreatedAt(), + doc.getUpdatedAt(), + null, + null); + }); + } + + Page page = documentRepository.findAllByUser_IdAndDeletedAtIsNull(userId, effectivePageable); + List docs = page.getContent(); + if (docs.isEmpty()) { + return new PageImpl<>(List.of(), effectivePageable, page.getTotalElements()); + } + + List docIds = docs.stream().map(Document::getId).toList(); + Map childCounts = fetchChildCounts(docIds); + Set collaboratorDocIds = new HashSet<>(collaboratorRepository.findDocumentIdsWithCollaborators(docIds)); + Map rootOrderKeys = fetchRootOrderKeys(userId, docs); + + return page.map(doc -> { + boolean hasChildren = childCounts.getOrDefault(doc.getId(), 0L) > 0; + boolean hasCollaborators = collaboratorDocIds.contains(doc.getId()); + String orderKey = doc.getParent() != null ? doc.getSiblingOrderKey() : rootOrderKeys.get(doc.getId()); + UUID parentDocId = doc.getParent() != null ? doc.getParent().getId() : null; + + return new DocumentResponse( + doc.getId(), + doc.getTitle(), + null, + parentDocId, + orderKey, + hasChildren, + hasCollaborators, + DocumentAccessLevel.OWNER, + doc.getCreatedBy(), + doc.getCreatedAt(), + doc.getUpdatedAt(), + null, + null); + }); + } + + private Map fetchChildCounts(Collection docIds) { + if (docIds.isEmpty()) return Map.of(); + Map childCounts = new HashMap<>(); + for (Object[] row : documentRepository.countNonTrashedChildrenByParentIds(docIds)) { + if (row[0] != null && row[1] != null) { + UUID parentId = row[0] instanceof UUID u ? u : UUID.fromString(row[0].toString()); + long count = ((Number) row[1]).longValue(); + childCounts.put(parentId, count); + } + } + return childCounts; + } + + private Map fetchAccessLevels(UUID userId, Collection docIds) { + if (docIds.isEmpty()) return Map.of(); + Map accessLevels = new HashMap<>(); + String joinedIds = docIds.stream().map(UUID::toString).collect(Collectors.joining(",")); + for (Object[] row : documentRepository.resolveEffectiveAccessBatch(userId, joinedIds)) { + if (row[0] != null && row[1] != null) { + UUID docId = row[0] instanceof UUID u ? u : UUID.fromString(row[0].toString()); + accessLevels.put(docId, DocumentAccessLevel.valueOf(row[1].toString())); + } + } + return accessLevels; + } + + private Map fetchTrashAccessLevels(UUID userId, Collection docIds) { + if (docIds.isEmpty()) return Map.of(); + Map accessLevels = new HashMap<>(); + String joinedIds = docIds.stream().map(UUID::toString).collect(Collectors.joining(",")); + for (Object[] row : documentRepository.resolveTrashAccessBatch(userId, joinedIds)) { + if (row[0] != null && row[1] != null) { + UUID docId = row[0] instanceof UUID u ? u : UUID.fromString(row[0].toString()); + accessLevels.put(docId, DocumentAccessLevel.valueOf(row[1].toString())); + } + } + return accessLevels; + } + + private Map fetchUserOrderKeys(UUID userId, Collection docIds) { + if (docIds.isEmpty()) { + return Map.of(); + } + Map orderKeys = new HashMap<>(); + for (Object[] row : userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(userId, docIds)) { + orderKeys.put((UUID) row[0], (String) row[1]); + } + return orderKeys; + } + + private Map fetchRootOrderKeys(UUID userId, List docs) { + List rootIds = docs.stream() + .filter(document -> document.getParent() == null) + .map(Document::getId) + .toList(); + return fetchUserOrderKeys(userId, rootIds); + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java b/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java index 0dfe484..728dcb7 100644 --- a/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java @@ -7,25 +7,30 @@ import com.nextdocs.api.document.config.DocumentProperties; import com.nextdocs.api.document.dto.request.DocumentCreateRequest; import com.nextdocs.api.document.dto.request.DocumentUpdateRequest; +import com.nextdocs.api.document.dto.response.DocumentBreadcrumbResponse; import com.nextdocs.api.document.dto.response.DocumentResponse; import com.nextdocs.api.document.entity.Document; import com.nextdocs.api.document.entity.DocumentAccessLevel; import com.nextdocs.api.document.entity.DocumentCollaborator; import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import com.nextdocs.api.document.entity.UserDocumentOrder; import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; import com.nextdocs.api.document.repository.DocumentRepository; +import com.nextdocs.api.document.repository.UserDocumentOrderRepository; +import com.nextdocs.api.document.util.FractionalIndex; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.ArrayList; import java.util.Base64; +import java.util.Collections; +import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.dao.DataIntegrityViolationException; 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; @@ -35,83 +40,118 @@ public class DocumentService { public record CreateDocumentResult(DocumentResponse document, boolean created) {} + private static final int MAX_CREATE_ATTEMPTS = 3; + private static final int MAX_RESTORE_ATTEMPTS = 3; + + // Mirrors the depth cap of resolve_effective_access / resolve_trash_access in the DB. + private static final int MAX_TREE_DEPTH = 100; + private final DocumentRepository documentRepository; private final DocumentCollaboratorRepository collaboratorRepository; + private final UserDocumentOrderRepository userDocumentOrderRepository; private final UserRepository userRepository; private final DocumentProperties documentProperties; + private final PermissionService permissionService; + private final DocumentListQueryHelper queryHelper; @Autowired @Lazy private DocumentService selfProxy; - @Transactional public CreateDocumentResult create(UUID userId, DocumentCreateRequest request) { + int attempt = 0; + while (true) { + try { + return selfProxy != null ? selfProxy.insertDocument(userId, request) : insertDocument(userId, request); + } catch (DataIntegrityViolationException ex) { + if (request.id() != null) { + Document existing = + documentRepository.findById(request.id()).orElse(null); + if (existing != null) { + return existingDocumentForCreate(existing, userId); + } + } + attempt++; + if (attempt >= MAX_CREATE_ATTEMPTS) { + throw new ApiException( + ErrorCode.CONFLICT, "Could not assign a unique tree position. Please retry."); + } + } + } + } + + /** + * Idempotent handling when a client-provided ID already exists. Nested documents belong + * to their host tree's owner, so the original creator may no longer match user_id; + * anyone who still holds access gets the existing document back, strangers get a conflict. + */ + private CreateDocumentResult existingDocumentForCreate(Document existing, UUID userId) { + if (existing.getDeletedAt() != null) { + throw new ApiException( + ErrorCode.CONFLICT, + "A trashed document already exists with this ID. Restore or permanently delete it first."); + } + boolean isOwner = existing.getUser().getId().equals(userId); + if (!isOwner && permissionService.resolveAccess(userId, existing.getId()) == null) { + throw new ApiException(ErrorCode.CONFLICT, "A document already exists with this ID."); + } + return new CreateDocumentResult(toResponse(existing, true, userId), false); + } + + @Transactional + public CreateDocumentResult insertDocument(UUID userId, DocumentCreateRequest request) { User user = userRepository.findById(userId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); String yjsState = request.yjsState(); UUID documentId = request.id() != null ? request.id() : UUID.randomUUID(); if (request.id() != null) { - Document existing = - documentRepository.findByIdAndUser_Id(documentId, userId).orElse(null); + Document existing = documentRepository.findById(documentId).orElse(null); if (existing != null) { - if (existing.getDeletedAt() != null) { - throw new ApiException( - ErrorCode.CONFLICT, - "A trashed document already exists with this ID. Restore or permanently delete it first."); - } - return new CreateDocumentResult(toResponse(existing, true), false); + return existingDocumentForCreate(existing, userId); } } + Document parent = null; + String siblingOrderKey = null; + if (request.parentId() != null) { + parent = permissionService.requireEditAccess(userId, request.parentId()); + siblingOrderKey = resolveInitialSiblingOrderKey( + request.parentId(), request.prevSiblingId(), request.nextSiblingId(), documentId); + } + Document document = Document.builder() .id(documentId) - .user(user) + // Location authority: a nested document belongs to its host tree, so it + // inherits the parent's owner and, through ancestor resolution, the parent's + // access chain. The creator is recorded in `createdBy`. + .user(parent != null ? parent.getUser() : user) .title(normalizeTitle(request.title())) .yjsState(decodeBase64State(yjsState)) .createdBy(request.createdBy()) + .parent(parent) + .siblingOrderKey(siblingOrderKey) .build(); - try { - return new CreateDocumentResult(toResponse(documentRepository.saveAndFlush(document), true), true); - } catch (DataIntegrityViolationException ex) { - if (request.id() == null) { - throw ex; - } - - Document existing = documentRepository.findById(documentId).orElseThrow(() -> ex); - if (!existing.getUser().getId().equals(userId)) { - throw new ApiException(ErrorCode.CONFLICT, "A document already exists with this ID."); - } - if (existing.getDeletedAt() != null) { - throw new ApiException( - ErrorCode.CONFLICT, - "A trashed document already exists with this ID. Restore or permanently delete it first."); - } - return new CreateDocumentResult(toResponse(existing, true), false); + Document saved = documentRepository.saveAndFlush(document); + + if (parent == null) { + String userOrderKey = + resolveInitialUserOrderKey(userId, request.prevSiblingId(), request.nextSiblingId(), documentId); + UserDocumentOrder udo = UserDocumentOrder.builder() + .user(user) + .document(saved) + .orderKey(userOrderKey) + .build(); + userDocumentOrderRepository.saveAndFlush(udo); } + + return new CreateDocumentResult(toResponse(saved, true, userId), true); } @Transactional(readOnly = true) - public Page list(UUID userId, Pageable pageable, boolean trashedOnly) { - Pageable effectivePageable = pageable; - if (effectivePageable == null) { - effectivePageable = PageRequest.of(0, 20); - } - - if (effectivePageable.getSort().isUnsorted()) { - Sort sort = trashedOnly - ? Sort.by(Sort.Order.desc("deletedAt"), Sort.Order.asc("id")) - : Sort.by(Sort.Order.desc("updatedAt"), Sort.Order.desc("createdAt"), Sort.Order.asc("id")); - effectivePageable = - PageRequest.of(effectivePageable.getPageNumber(), effectivePageable.getPageSize(), sort); - } - - Page page = trashedOnly - ? documentRepository.findAllByUser_IdAndDeletedAtIsNotNull(userId, effectivePageable) - : documentRepository.findAllByUser_IdAndDeletedAtIsNull(userId, effectivePageable); - - return page.map(document -> toResponse(document, false)); + public Page list(UUID userId, String parentId, String scope, Boolean trashed, Pageable pageable) { + return queryHelper.list(userId, parentId, scope, trashed, pageable); } @Transactional(readOnly = true) @@ -121,23 +161,23 @@ public DocumentResponse get(UUID userId, UUID documentId, boolean includeTrashed document = documentRepository.findById(documentId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); if (document.getDeletedAt() != null) { - // Document is in trash - only the owner can access it - if (!document.getUser().getId().equals(userId)) { + // Document is in trash - readable (read-only) for anyone who held any + // pre-trash access; restore/purge remain EDIT-gated elsewhere. + DocumentAccessLevel access = permissionService.resolveTrashAccess(userId, documentId); + if (access == null) { throw new ApiException(ErrorCode.NOT_FOUND); } } else { - // Active document - check if the user is the owner or has valid collaborator/public access - if (!document.getUser().getId().equals(userId)) { - DocumentAccessLevel effectiveAccess = resolveEffectiveNonOwnerAccess(userId, document); - if (effectiveAccess == null) { - throw new ApiException(ErrorCode.NOT_FOUND); - } + // Active document - check if user has access + DocumentAccessLevel access = permissionService.resolveAccess(userId, documentId); + if (access == null) { + throw new ApiException(ErrorCode.NOT_FOUND); } } } else { - document = findAccessibleActiveDocument(userId, documentId, false); + document = permissionService.requireReadAccess(userId, documentId); } - return toResponse(document, true); + return toResponse(document, true, userId); } @Transactional(readOnly = true) @@ -146,33 +186,119 @@ public DocumentResponse getPublic(UUID documentId) { .findByIdAndDeletedAtIsNull(documentId) .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); - if (resolveGeneralAccessLevel(document) == null) { + if (document.getGeneralAccessMode() != DocumentGeneralAccessMode.ANYONE_WITH_LINK) { throw new ApiException(ErrorCode.NOT_FOUND); } return toResponse(document, true); } + @Transactional(readOnly = true) + public List getBreadcrumbs(UUID userId, UUID documentId) { + Document target = + documentRepository.findById(documentId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + DocumentAccessLevel targetAccess = target.getDeletedAt() != null + ? permissionService.resolveTrashAccess(userId, documentId) + : permissionService.resolveAccess(userId, documentId); + if (targetAccess == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + + List path = new ArrayList<>(); + Document current = target; + int depth = 0; + while (current != null && depth < MAX_TREE_DEPTH) { + Document parent = current.getParent(); + UUID parentId = null; + + if (parent != null) { + DocumentAccessLevel parentAccess = parent.getDeletedAt() != null + ? permissionService.resolveTrashAccess(userId, parent.getId()) + : permissionService.resolveAccess(userId, parent.getId()); + if (parentAccess != null) { + parentId = parent.getId(); + } + } + + // Document icon is reserved for future icon/cover support when introduced to the Document entity model + path.add(new DocumentBreadcrumbResponse( + current.getId(), formatBreadcrumbTitle(current.getTitle()), null, parentId)); + + if (parentId == null) { + // Reached the top of the user's accessible hierarchy + break; + } + + current = parent; + depth++; + } + Collections.reverse(path); + return path; + } + + @Transactional(readOnly = true) + public List getPublicBreadcrumbs(UUID documentId) { + Document target = documentRepository + .findByIdAndDeletedAtIsNull(documentId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + if (target.getGeneralAccessMode() != DocumentGeneralAccessMode.ANYONE_WITH_LINK) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + + List path = new ArrayList<>(); + Document current = target; + int depth = 0; + while (current != null && depth < MAX_TREE_DEPTH) { + Document parent = current.getParent(); + UUID parentId = null; + + if (parent != null) { + // For public access, the parent must also be non-trashed and shared as ANYONE_WITH_LINK. + // If the parent is private/restricted or trashed, we stop here so public viewers cannot see private + // parent titles. + if (parent.getDeletedAt() == null + && parent.getGeneralAccessMode() == DocumentGeneralAccessMode.ANYONE_WITH_LINK) { + parentId = parent.getId(); + } + } + + // Document icon is reserved for future icon/cover support when introduced to the Document entity model + path.add(new DocumentBreadcrumbResponse( + current.getId(), formatBreadcrumbTitle(current.getTitle()), null, parentId)); + + if (parentId == null) { + // Reached the top of public access + break; + } + + current = parent; + depth++; + } + Collections.reverse(path); + return path; + } + @Transactional public DocumentResponse update(UUID userId, UUID documentId, DocumentUpdateRequest request) { Document document = - documentRepository.findByIdAndDeletedAtIsNull(documentId).orElse(null); - if (document == null) { - if (documentRepository.findByIdAndUser_Id(documentId, userId).isPresent()) { + documentRepository.findById(documentId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + if (document.getDeletedAt() != null) { + // Users with any pre-trash access recognize the document; nobody may edit it in trash. + if (permissionService.resolveTrashAccess(userId, documentId) != null) { throw new ApiException(ErrorCode.CONFLICT, "Cannot update a document in trash. Restore it first."); } throw new ApiException(ErrorCode.NOT_FOUND); } - if (!document.getUser().getId().equals(userId)) { - DocumentAccessLevel effectiveAccess = resolveEffectiveNonOwnerAccess(userId, document); - if (effectiveAccess == null) { - throw new ApiException(ErrorCode.NOT_FOUND); - } - - if (!effectiveAccess.allowsEdit()) { - throw new ApiException(ErrorCode.FORBIDDEN); - } + // Active document: require edit access + DocumentAccessLevel access = permissionService.resolveAccess(userId, documentId); + if (access == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (!access.allowsEdit()) { + throw new ApiException(ErrorCode.FORBIDDEN); } if (request.title() != null) { @@ -187,38 +313,149 @@ public DocumentResponse update(UUID userId, UUID documentId, DocumentUpdateReque document.setCreatedBy(request.createdBy()); } - return toResponse(documentRepository.save(document), true); + return toResponse(documentRepository.save(document), true, userId); } @Transactional public void delete(UUID userId, UUID documentId, boolean permanent) { if (permanent) { - Document document = documentRepository - .findByIdAndUser_Id(documentId, userId) - .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + // Verify the explicit resource ID against the caller's permission chain before purging. + Document document = permissionService.requireTrashEditAccess(userId, documentId); if (document.getDeletedAt() == null) { throw new ApiException( ErrorCode.VALIDATION_FAILED, "Permanent delete is only allowed for documents already in trash."); } + if (document.getParent() != null && document.getParent().getDeletedAt() != null) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, + "Cannot permanently delete a child of a trashed document directly. Delete the parent document instead."); + } + + List descendants = collectAllDescendants(documentId); + // Delete in reverse hierarchy order (leaves first) + Collections.reverse(descendants); + for (Document descendant : descendants) { + collaboratorRepository.deleteByDocument_Id(descendant.getId()); + userDocumentOrderRepository.deleteByDocument_Id(descendant.getId()); + documentRepository.delete(descendant); + } + + collaboratorRepository.deleteByDocument_Id(documentId); + userDocumentOrderRepository.deleteByDocument_Id(documentId); documentRepository.delete(document); return; } - Document document = documentRepository - .findByIdAndUser_IdAndDeletedAtIsNull(documentId, userId) - .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); - document.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC)); + Document document = permissionService.requireEditAccess(userId, documentId); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + document.setDeletedAt(now); + userDocumentOrderRepository.deleteByDocument_Id(documentId); documentRepository.save(document); + + // Cascade soft delete to all active descendants + List descendants = collectAllDescendants(documentId); + for (Document descendant : descendants) { + if (descendant.getDeletedAt() == null) { + descendant.setDeletedAt(now); + userDocumentOrderRepository.deleteByDocument_Id(descendant.getId()); + documentRepository.save(descendant); + } + } } - @Transactional public DocumentResponse restore(UUID userId, UUID documentId) { - Document document = documentRepository - .findByIdAndUser_IdAndDeletedAtIsNotNull(documentId, userId) - .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + int attempt = 0; + while (true) { + try { + return selfProxy != null + ? selfProxy.restoreAndPersist(userId, documentId, attempt > 0) + : restoreAndPersist(userId, documentId, attempt > 0); + } catch (DataIntegrityViolationException ex) { + attempt++; + if (attempt >= MAX_RESTORE_ATTEMPTS) { + throw new ApiException( + ErrorCode.CONFLICT, "Could not restore the document to a unique position. Please retry."); + } + } + } + } + + @Transactional + public DocumentResponse restoreAndPersist(UUID userId, UUID documentId, boolean forceRegenerate) { + Document document = permissionService.requireTrashEditAccess(userId, documentId); + if (document.getDeletedAt() == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + + if (document.getParent() != null && document.getParent().getDeletedAt() != null) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, + "Cannot restore a child of a trashed document directly. Restore the parent document instead."); + } + document.setDeletedAt(null); - return toResponse(documentRepository.save(document), true); + + if (document.getParent() != null) { + if (forceRegenerate || !FractionalIndex.isValidOrderKey(document.getSiblingOrderKey())) { + String maxKey = documentRepository + .findMaxSiblingOrderKey(document.getParent().getId(), document.getId()) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + document.setSiblingOrderKey(FractionalIndex.keyBetween(maxKey, null)); + } + } else { + UUID ownerId = document.getUser().getId(); + java.util.Optional existingOpt = + userDocumentOrderRepository.findByUser_IdAndDocument_Id(ownerId, documentId); + String existingKey = existingOpt.map(UserDocumentOrder::getOrderKey).orElse(null); + boolean needsRegenerate = + forceRegenerate || existingOpt.isEmpty() || !FractionalIndex.isValidOrderKey(existingKey); + if (needsRegenerate) { + String maxKey = userDocumentOrderRepository + .findMaxOrderKeyByUserId(ownerId, documentId) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + String newKey = FractionalIndex.keyBetween(maxKey, null); + UserDocumentOrder udo = existingOpt.orElseGet(() -> UserDocumentOrder.builder() + .user(document.getUser()) + .document(document) + .build()); + udo.setOrderKey(newKey); + userDocumentOrderRepository.saveAndFlush(udo); + } + + for (DocumentCollaborator collaborator : collaboratorRepository.findAllByDocument_Id(documentId)) { + ensureCollaboratorOrderRow(document, collaborator); + } + } + + Document savedRoot = documentRepository.saveAndFlush(document); + + // Restore all descendants that were in trash + List descendants = collectAllDescendants(documentId); + for (Document descendant : descendants) { + if (descendant.getDeletedAt() != null) { + descendant.setDeletedAt(null); + if (!FractionalIndex.isValidOrderKey(descendant.getSiblingOrderKey())) { + String maxKey = documentRepository + .findMaxSiblingOrderKey(descendant.getParent().getId(), descendant.getId()) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + descendant.setSiblingOrderKey(FractionalIndex.keyBetween(maxKey, null)); + } + documentRepository.saveAndFlush(descendant); + + // Soft delete wiped every user's ordering rows; give collaborators of + // restored descendants back a row so their Shared-section placement survives. + for (DocumentCollaborator collaborator : + collaboratorRepository.findAllByDocument_Id(descendant.getId())) { + ensureCollaboratorOrderRow(descendant, collaborator); + } + } + } + + return toResponse(savedRoot, true, userId); } @Transactional @@ -244,6 +481,10 @@ private static String normalizeTitle(String title) { return value; } + private static String formatBreadcrumbTitle(String title) { + return (title == null || title.isBlank()) ? "Untitled" : title.strip(); + } + private static byte[] decodeBase64State(String yjsState) { if (yjsState == null) { return null; @@ -257,11 +498,67 @@ private static byte[] decodeBase64State(String yjsState) { } private DocumentResponse toResponse(Document document, boolean includeState) { + return toResponse(document, includeState, null); + } + + /** + * Converts a single Document entity to DocumentResponse DTO. + * Note: This method accesses document.getParent() lazily and makes individual permission checks, + * so it MUST be executed within an active @Transactional context. It is intended solely for + * single-document operations (create, get, update, reorder); batch listings must use batch queries + * via DocumentListQueryHelper instead. + */ + private DocumentResponse toResponse(Document document, boolean includeState, UUID callerUserId) { OffsetDateTime deletedAt = document.getDeletedAt(); OffsetDateTime purgeAt = null; if (deletedAt != null) { purgeAt = deletedAt.plusDays(documentProperties.getTrashRetentionDays()); } + + String orderKey; + if (callerUserId != null && !document.getUser().getId().equals(callerUserId)) { + boolean isFloatedOrRoot; + if (document.getParent() == null) { + isFloatedOrRoot = true; + } else { + DocumentAccessLevel parentAccess = + (document.getParent().getDeletedAt() != null || document.getDeletedAt() != null) + ? permissionService.resolveTrashAccess( + callerUserId, document.getParent().getId()) + : permissionService.resolveAccess( + callerUserId, document.getParent().getId()); + isFloatedOrRoot = (parentAccess == null); + } + if (isFloatedOrRoot) { + orderKey = userDocumentOrderRepository + .findOrderKeyByUserIdAndDocumentId(callerUserId, document.getId()) + .orElse(null); + } else { + orderKey = document.getSiblingOrderKey(); + } + } else if (document.getParent() != null) { + orderKey = document.getSiblingOrderKey(); + } else if (callerUserId != null) { + orderKey = userDocumentOrderRepository + .findOrderKeyByUserIdAndDocumentId(callerUserId, document.getId()) + .orElse(null); + } else { + orderKey = null; + } + + boolean hasChildren = documentRepository.existsNonTrashedChildrenByParentId(document.getId()); + boolean hasCollaborators = collaboratorRepository.existsByDocument_Id(document.getId()); + DocumentAccessLevel accessLevel; + if (callerUserId == null) { + accessLevel = DocumentAccessLevel.VIEW; + } else if (document.getUser().getId().equals(callerUserId)) { + accessLevel = DocumentAccessLevel.OWNER; + } else if (document.getDeletedAt() != null) { + accessLevel = permissionService.resolveTrashAccess(callerUserId, document.getId()); + } else { + accessLevel = permissionService.resolveAccess(callerUserId, document.getId()); + } + return new DocumentResponse( document.getId(), document.getTitle(), @@ -270,6 +567,11 @@ private DocumentResponse toResponse(Document document, boolean includeState) { ? Base64.getEncoder().encodeToString(document.getYjsState()) : null) : null, + document.getParent() != null ? document.getParent().getId() : null, + orderKey, + hasChildren, + hasCollaborators, + accessLevel, document.getCreatedBy(), document.getCreatedAt(), document.getUpdatedAt(), @@ -277,46 +579,159 @@ private DocumentResponse toResponse(Document document, boolean includeState) { purgeAt); } - private Document findAccessibleActiveDocument(UUID userId, UUID documentId, boolean requireEdit) { - Document document = documentRepository - .findByIdAndDeletedAtIsNull(documentId) - .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + private List collectAllDescendants(UUID rootId) { + List allDescendants = new ArrayList<>(); + List currentParentIds = List.of(rootId); + int depth = 0; + while (!currentParentIds.isEmpty()) { + if (depth >= MAX_TREE_DEPTH) { + // Cycles are prevented by move validation, but concurrent moves could race past + // the check-then-act window. Bail out instead of looping forever. + throw new ApiException(ErrorCode.VALIDATION_FAILED, "Document tree is too deep or contains a cycle."); + } + List children = documentRepository.findAllByParent_IdIn(currentParentIds); + if (children.isEmpty()) { + break; + } + allDescendants.addAll(children); + currentParentIds = children.stream().map(Document::getId).toList(); + depth++; + } + return allDescendants; + } - if (document.getUser().getId().equals(userId)) { - return document; + private void ensureCollaboratorOrderRow(Document document, DocumentCollaborator collaborator) { + UUID collaboratorId = collaborator.getUser().getId(); + if (userDocumentOrderRepository.existsByUser_IdAndDocument_Id(collaboratorId, document.getId())) { + return; } + String minKey = userDocumentOrderRepository + .findMinOrderKeyByUserId(collaboratorId, document.getId()) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + UserDocumentOrder cudo = UserDocumentOrder.builder() + .user(collaborator.getUser()) + .document(document) + .orderKey(FractionalIndex.keyBetween(null, minKey)) + .build(); + userDocumentOrderRepository.saveAndFlush(cudo); + } - DocumentAccessLevel effectiveAccess = resolveEffectiveNonOwnerAccess(userId, document); - if (effectiveAccess == null) { - throw new ApiException(ErrorCode.NOT_FOUND); + private String resolveInitialSiblingOrderKey(UUID parentId, UUID prevSiblingId, UUID nextSiblingId, UUID selfId) { + String prevKey = null; + if (prevSiblingId != null) { + Document prevDoc = documentRepository + .findByIdAndDeletedAtIsNull(prevSiblingId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "prevSiblingId not found.")); + if (prevDoc.getParent() == null || !prevDoc.getParent().getId().equals(parentId)) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, "prevSiblingId does not belong to the specified parent."); + } + String rawPrev = prevDoc.getSiblingOrderKey(); + if (rawPrev == null || !FractionalIndex.isValidOrderKey(rawPrev)) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, "prevSiblingId has an invalid order key; reindex required."); + } + prevKey = rawPrev; } - if (requireEdit && !effectiveAccess.allowsEdit()) { - throw new ApiException(ErrorCode.FORBIDDEN); + String nextKey = null; + if (nextSiblingId != null) { + Document nextDoc = documentRepository + .findByIdAndDeletedAtIsNull(nextSiblingId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "nextSiblingId not found.")); + if (nextDoc.getParent() == null || !nextDoc.getParent().getId().equals(parentId)) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, "nextSiblingId does not belong to the specified parent."); + } + String rawNext = nextDoc.getSiblingOrderKey(); + if (rawNext == null || !FractionalIndex.isValidOrderKey(rawNext)) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, "nextSiblingId has an invalid order key; reindex required."); + } + nextKey = rawNext; + } + + if (prevKey != null && nextKey != null) { + if (prevKey.compareTo(nextKey) > 0) { + String temp = prevKey; + prevKey = nextKey; + nextKey = temp; + } else if (prevKey.equals(nextKey)) { + throw new ApiException(ErrorCode.CONFLICT, "Sibling order keys are identical. Please retry."); + } + } + + if (prevKey == null && nextKey == null) { + String minKey = documentRepository + .findMinSiblingOrderKey(parentId, selfId) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + return FractionalIndex.keyBetween(null, minKey); } - return document; + try { + return FractionalIndex.keyBetween(prevKey, nextKey); + } catch (IllegalArgumentException ex) { + throw new ApiException(ErrorCode.CONFLICT, "The sibling ordering has changed concurrently. Please retry."); + } } - private DocumentAccessLevel resolveEffectiveNonOwnerAccess(UUID userId, Document document) { - DocumentAccessLevel collaboratorAccess = collaboratorRepository - .findByDocument_IdAndUser_Id(document.getId(), userId) - .map(DocumentCollaborator::getAccessLevel) - .orElse(null); + private String resolveInitialUserOrderKey(UUID userId, UUID prevSiblingId, UUID nextSiblingId, UUID selfId) { + String prevKey = null; + if (prevSiblingId != null) { + documentRepository + .findByIdAndDeletedAtIsNull(prevSiblingId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "prevSiblingId not found.")); + String rawPrev = userDocumentOrderRepository + .findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId) + .orElseThrow(() -> new ApiException( + ErrorCode.VALIDATION_FAILED, "sibling does not belong to root navigation")); + if (!FractionalIndex.isValidOrderKey(rawPrev)) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, "prevSiblingId has an invalid order key; reindex required."); + } + prevKey = rawPrev; + } - // Explicit collaborator access takes precedence over general link access. - if (collaboratorAccess != null) { - return collaboratorAccess; + String nextKey = null; + if (nextSiblingId != null) { + documentRepository + .findByIdAndDeletedAtIsNull(nextSiblingId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "nextSiblingId not found.")); + String rawNext = userDocumentOrderRepository + .findOrderKeyByUserIdAndDocumentId(userId, nextSiblingId) + .orElseThrow(() -> new ApiException( + ErrorCode.VALIDATION_FAILED, "sibling does not belong to root navigation")); + if (!FractionalIndex.isValidOrderKey(rawNext)) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, "nextSiblingId has an invalid order key; reindex required."); + } + nextKey = rawNext; } - return resolveGeneralAccessLevel(document); - } + if (prevKey != null && nextKey != null) { + if (prevKey.compareTo(nextKey) > 0) { + String temp = prevKey; + prevKey = nextKey; + nextKey = temp; + } else if (prevKey.equals(nextKey)) { + throw new ApiException(ErrorCode.CONFLICT, "Sibling order keys are identical. Please retry."); + } + } - private DocumentAccessLevel resolveGeneralAccessLevel(Document document) { - if (document.getGeneralAccessMode() != DocumentGeneralAccessMode.ANYONE_WITH_LINK) { - return null; + if (prevKey == null && nextKey == null) { + String minKey = userDocumentOrderRepository + .findMinOrderKeyByUserId(userId, selfId) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + return FractionalIndex.keyBetween(null, minKey); } - return document.getLinkAccessLevel(); + try { + return FractionalIndex.keyBetween(prevKey, nextKey); + } catch (IllegalArgumentException ex) { + throw new ApiException(ErrorCode.CONFLICT, "The sibling ordering has changed concurrently. Please retry."); + } } } diff --git a/api/src/main/java/com/nextdocs/api/document/service/DocumentSharingService.java b/api/src/main/java/com/nextdocs/api/document/service/DocumentSharingService.java index f1ca795..6b53e92 100644 --- a/api/src/main/java/com/nextdocs/api/document/service/DocumentSharingService.java +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentSharingService.java @@ -9,19 +9,22 @@ import com.nextdocs.api.document.dto.request.SharingSettingsUpdateRequest; import com.nextdocs.api.document.dto.response.CollaboratorResponse; import com.nextdocs.api.document.dto.response.DocumentAccessResponse; -import com.nextdocs.api.document.dto.response.DocumentResponse; import com.nextdocs.api.document.dto.response.SharingSettingsResponse; import com.nextdocs.api.document.entity.Document; import com.nextdocs.api.document.entity.DocumentAccessLevel; import com.nextdocs.api.document.entity.DocumentCollaborator; import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import com.nextdocs.api.document.entity.UserDocumentOrder; import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; import com.nextdocs.api.document.repository.DocumentRepository; +import com.nextdocs.api.document.repository.UserDocumentOrderRepository; +import com.nextdocs.api.document.util.FractionalIndex; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,13 +32,21 @@ @RequiredArgsConstructor public class DocumentSharingService { + private static final int MAX_ORDER_UPSERT_ATTEMPTS = 3; + private final DocumentRepository documentRepository; private final DocumentCollaboratorRepository collaboratorRepository; + private final UserDocumentOrderRepository userDocumentOrderRepository; private final UserRepository userRepository; + private final PermissionService permissionService; + + @Autowired + @Lazy + private DocumentSharingService selfProxy; @Transactional(readOnly = true) public List listCollaborators(UUID requesterId, UUID documentId) { - Document doc = requireAccessibleActiveDocument(requesterId, documentId); + Document doc = permissionService.requireReadAccessOrTrashOwner(requesterId, documentId); CollaboratorResponse owner = new CollaboratorResponse( doc.getUser().getId(), @@ -57,9 +68,34 @@ public List listCollaborators(UUID requesterId, UUID docum .toList(); } - @Transactional public CollaboratorResponse upsertCollaborator(UUID ownerId, UUID documentId, CollaboratorUpsertRequest request) { - Document doc = requireOwnedActiveDocument(ownerId, documentId); + int attempt = 0; + while (true) { + try { + return selfProxy != null + ? selfProxy.upsertCollaboratorAndPersist(ownerId, documentId, request) + : upsertCollaboratorAndPersist(ownerId, documentId, request); + } catch (DataIntegrityViolationException ex) { + attempt++; + if (attempt >= MAX_ORDER_UPSERT_ATTEMPTS) { + throw ex; + } + } + } + } + + /** + * Adds or updates a collaborator on a document. + * + *

TODO(full-access): sharing administration is direct-owner-only because no + * FULL_ACCESS access level exists yet - collaborators cannot re-share documents + * shared with them. Until that level is implemented, moving documents between two + * shared trees is intentionally blocked in the web UI for non-owners. + */ + @Transactional + public CollaboratorResponse upsertCollaboratorAndPersist( + UUID ownerId, UUID documentId, CollaboratorUpsertRequest request) { + Document doc = permissionService.requireOwnerAccessIncludingTrash(ownerId, documentId); DocumentAccessLevel requestedLevel = normalizeCollaboratorAccess(request.accessLevel()); User targetUser = userRepository @@ -82,6 +118,10 @@ public CollaboratorResponse upsertCollaborator(UUID ownerId, UUID documentId, Co DocumentCollaborator saved = collaboratorRepository.save(collaborator); + // Ensure the collaborator has a UserDocumentOrder entry for their Shared + // section so root documents and floated nested documents can be reordered. + ensureCollaboratorOrder(doc, targetUser); + return new CollaboratorResponse( saved.getUser().getId(), saved.getUser().getEmail(), @@ -93,7 +133,7 @@ public CollaboratorResponse upsertCollaborator(UUID ownerId, UUID documentId, Co @Transactional public CollaboratorResponse updateCollaboratorAccess( UUID ownerId, UUID documentId, UUID collaboratorUserId, CollaboratorAccessUpdateRequest request) { - requireOwnedActiveDocument(ownerId, documentId); + permissionService.requireOwnerAccessIncludingTrash(ownerId, documentId); if (ownerId.equals(collaboratorUserId)) { throw new ApiException(ErrorCode.CONFLICT, "Owner access cannot be changed."); @@ -116,7 +156,7 @@ public CollaboratorResponse updateCollaboratorAccess( @Transactional public void removeCollaborator(UUID ownerId, UUID documentId, UUID collaboratorUserId) { - requireOwnedActiveDocument(ownerId, documentId); + permissionService.requireOwnerAccessIncludingTrash(ownerId, documentId); if (ownerId.equals(collaboratorUserId)) { throw new ApiException(ErrorCode.CONFLICT, "Owner cannot be removed from collaborators."); @@ -128,11 +168,12 @@ public void removeCollaborator(UUID ownerId, UUID documentId, UUID collaboratorU } collaboratorRepository.deleteByDocument_IdAndUser_Id(documentId, collaboratorUserId); + userDocumentOrderRepository.deleteByUser_IdAndDocument_Id(collaboratorUserId, documentId); } @Transactional public void leaveSharedDocument(UUID userId, UUID documentId) { - Document doc = requireAccessibleActiveDocument(userId, documentId); + Document doc = permissionService.requireReadAccess(userId, documentId); if (doc.getUser().getId().equals(userId)) { throw new ApiException(ErrorCode.CONFLICT, "Owners cannot leave their own documents."); @@ -144,11 +185,12 @@ public void leaveSharedDocument(UUID userId, UUID documentId) { } collaboratorRepository.deleteByDocument_IdAndUser_Id(documentId, userId); + userDocumentOrderRepository.deleteByUser_IdAndDocument_Id(userId, documentId); } @Transactional(readOnly = true) public SharingSettingsResponse getSharingSettings(UUID ownerId, UUID documentId) { - Document doc = requireOwnedActiveDocument(ownerId, documentId); + Document doc = permissionService.requireOwnerAccessIncludingTrash(ownerId, documentId); boolean hasActiveLink = doc.getGeneralAccessMode() == DocumentGeneralAccessMode.ANYONE_WITH_LINK; return new SharingSettingsResponse(doc.getGeneralAccessMode(), doc.getLinkAccessLevel(), hasActiveLink); @@ -157,7 +199,7 @@ public SharingSettingsResponse getSharingSettings(UUID ownerId, UUID documentId) @Transactional public SharingSettingsResponse updateSharingSettings( UUID ownerId, UUID documentId, SharingSettingsUpdateRequest request) { - Document doc = requireOwnedActiveDocument(ownerId, documentId); + Document doc = permissionService.requireOwnerAccessIncludingTrash(ownerId, documentId); DocumentGeneralAccessMode mode = request.generalAccessMode(); if (mode == null) { @@ -175,14 +217,22 @@ public SharingSettingsResponse updateSharingSettings( return new SharingSettingsResponse(doc.getGeneralAccessMode(), doc.getLinkAccessLevel(), hasActiveLink); } - @Transactional(readOnly = true) - public Page listSharedWithMe(UUID userId, Pageable pageable) { - return documentRepository.findSharedWithUserId(userId, pageable).map(this::toDocumentSummaryResponse); - } - @Transactional(readOnly = true) public DocumentAccessResponse getMyAccess(UUID userId, UUID documentId) { - return computeAccess(userId, documentId); + Document active = + documentRepository.findByIdAndDeletedAtIsNull(documentId).orElse(null); + if (active != null) { + return computeActiveAccess(userId, documentId, active); + } + + // Trashed documents: report the caller's pre-trash access so the UI can offer a + // read-only trash view (any level) versus manage actions (EDIT and above). + DocumentAccessLevel trashAccess = permissionService.resolveTrashAccess(userId, documentId); + if (trashAccess == null) { + return new DocumentAccessResponse(documentId, false, null, false, true); + } + boolean owner = trashAccess == DocumentAccessLevel.OWNER; + return new DocumentAccessResponse(documentId, true, trashAccess, owner, true); } @Transactional(readOnly = true) @@ -190,62 +240,42 @@ public DocumentAccessResponse accessCheck(UUID userId, UUID documentId) { return computeAccess(userId, documentId); } - private DocumentAccessResponse computeAccess(UUID userId, UUID documentId) { - Document doc = documentRepository.findByIdAndDeletedAtIsNull(documentId).orElse(null); - if (doc == null) { - return new DocumentAccessResponse(documentId, false, null, false); - } - - if (doc.getUser().getId().equals(userId)) { - return new DocumentAccessResponse(documentId, true, DocumentAccessLevel.OWNER, true); + private void ensureCollaboratorOrder(Document doc, User targetUser) { + if (userDocumentOrderRepository.existsByUser_IdAndDocument_Id(targetUser.getId(), doc.getId())) { + return; } - - DocumentCollaborator collaborator = collaboratorRepository - .findByDocument_IdAndUser_Id(documentId, userId) + String minKey = userDocumentOrderRepository + .findMinOrderKeyByUserId(targetUser.getId(), doc.getId()) + .filter(FractionalIndex::isValidOrderKey) .orElse(null); - DocumentAccessLevel collaboratorAccess = collaborator == null ? null : collaborator.getAccessLevel(); - if (collaboratorAccess != null) { - return new DocumentAccessResponse(documentId, true, collaboratorAccess, false); - } - - DocumentAccessLevel effectiveAccess = resolveGeneralAccessLevel(doc); - - if (effectiveAccess != null) { - return new DocumentAccessResponse(documentId, true, effectiveAccess, false); - } - - return new DocumentAccessResponse(documentId, false, null, false); + UserDocumentOrder udo = UserDocumentOrder.builder() + .user(targetUser) + .document(doc) + .orderKey(FractionalIndex.keyBetween(null, minKey)) + .build(); + userDocumentOrderRepository.saveAndFlush(udo); } - private DocumentAccessLevel resolveGeneralAccessLevel(Document document) { - if (document.getGeneralAccessMode() != DocumentGeneralAccessMode.ANYONE_WITH_LINK) { - return null; + private DocumentAccessResponse computeAccess(UUID userId, UUID documentId) { + Document doc = documentRepository.findByIdAndDeletedAtIsNull(documentId).orElse(null); + if (doc == null) { + // Strict: realtime connection gating relies on trashed documents being denied here. + return new DocumentAccessResponse(documentId, false, null, false, true); } - - return document.getLinkAccessLevel(); + return computeActiveAccess(userId, documentId, doc); } - private Document requireOwnedActiveDocument(UUID ownerId, UUID documentId) { - return documentRepository - .findByIdAndUser_IdAndDeletedAtIsNull(documentId, ownerId) - .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); - } - - private Document requireAccessibleActiveDocument(UUID userId, UUID documentId) { - Document doc = documentRepository - .findByIdAndDeletedAtIsNull(documentId) - .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); - - if (doc.getUser().getId().equals(userId)) { - return doc; + private DocumentAccessResponse computeActiveAccess(UUID userId, UUID documentId, Document doc) { + boolean isOwner = doc.getUser().getId().equals(userId); + if (isOwner) { + return new DocumentAccessResponse(documentId, true, DocumentAccessLevel.OWNER, true, false); } - boolean isCollaborator = collaboratorRepository.existsByDocument_IdAndUser_Id(documentId, userId); - if (!isCollaborator) { - throw new ApiException(ErrorCode.NOT_FOUND); + DocumentAccessLevel level = permissionService.resolveAccess(userId, documentId); + if (level == null) { + return new DocumentAccessResponse(documentId, false, null, false, false); } - - return doc; + return new DocumentAccessResponse(documentId, true, level, false, false); } private static DocumentAccessLevel normalizeCollaboratorAccess(DocumentAccessLevel accessLevel) { @@ -267,16 +297,4 @@ private static DocumentAccessLevel normalizeLinkAccess(DocumentAccessLevel acces } return accessLevel; } - - private DocumentResponse toDocumentSummaryResponse(Document document) { - return new DocumentResponse( - document.getId(), - document.getTitle(), - null, - document.getCreatedBy(), - document.getCreatedAt(), - document.getUpdatedAt(), - document.getDeletedAt(), - null); - } } diff --git a/api/src/main/java/com/nextdocs/api/document/service/DocumentTreeService.java b/api/src/main/java/com/nextdocs/api/document/service/DocumentTreeService.java new file mode 100644 index 0000000..7e7f321 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentTreeService.java @@ -0,0 +1,438 @@ +package com.nextdocs.api.document.service; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.auth.repository.UserRepository; +import com.nextdocs.api.common.exception.ApiException; +import com.nextdocs.api.common.exception.ErrorCode; +import com.nextdocs.api.document.dto.request.DocumentMoveRequest; +import com.nextdocs.api.document.dto.response.DocumentResponse; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.entity.DocumentCollaborator; +import com.nextdocs.api.document.entity.UserDocumentOrder; +import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; +import com.nextdocs.api.document.repository.DocumentRepository; +import com.nextdocs.api.document.repository.UserDocumentOrderRepository; +import com.nextdocs.api.document.util.FractionalIndex; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DocumentTreeService { + + private static final int MAX_MOVE_ATTEMPTS = 3; + + private static final int REINDEX_GAP = 8; + + private static final int MAX_TREE_DEPTH = 100; + + private final DocumentRepository documentRepository; + private final DocumentCollaboratorRepository collaboratorRepository; + private final UserDocumentOrderRepository userDocumentOrderRepository; + private final UserRepository userRepository; + private final PermissionService permissionService; + + @Autowired + @Lazy + private DocumentTreeService selfProxy; + + public DocumentResponse move(UUID userId, UUID documentId, DocumentMoveRequest request) { + int attempt = 0; + while (true) { + try { + return selfProxy != null + ? selfProxy.moveAndPersist(userId, documentId, request, attempt > 0) + : moveAndPersist(userId, documentId, request, attempt > 0); + } catch (DataIntegrityViolationException ex) { + log.warn( + "Concurrent position change during move attempt={} userId={} documentId={}", + attempt, + userId, + documentId, + ex); + attempt++; + if (attempt >= MAX_MOVE_ATTEMPTS) { + throw new ApiException(ErrorCode.CONFLICT, "The position changed concurrently. Please retry."); + } + } + } + } + + @Transactional + public DocumentResponse moveAndPersist( + UUID userId, UUID documentId, DocumentMoveRequest request, boolean rebuildFirst) { + Document doc; + if (request.newParentId() != null) { + doc = permissionService.requireEditAccess(userId, documentId); + validateNoCycle(documentId, request.newParentId()); + Document newParent = permissionService.requireEditAccess(userId, request.newParentId()); + + if (rebuildFirst) { + reindexSiblings(request.newParentId()); + } + + String prevKey = resolveSiblingNeighborKey(request.prevSiblingId(), documentId, request.newParentId()); + String nextKey = resolveSiblingNeighborKey(request.nextSiblingId(), documentId, request.newParentId()); + + if (prevKey != null && nextKey != null && prevKey.compareTo(nextKey) > 0) { + String lower = prevKey; + prevKey = nextKey; + nextKey = lower; + } + + if (prevKey != null && !FractionalIndex.isValidOrderKey(prevKey) + || nextKey != null && !FractionalIndex.isValidOrderKey(nextKey) + || prevKey != null && prevKey.equals(nextKey)) { + reindexSiblings(request.newParentId()); + prevKey = resolveSiblingNeighborKey(request.prevSiblingId(), documentId, request.newParentId()); + nextKey = resolveSiblingNeighborKey(request.nextSiblingId(), documentId, request.newParentId()); + + if (prevKey != null && nextKey != null && prevKey.compareTo(nextKey) > 0) { + String lower = prevKey; + prevKey = nextKey; + nextKey = lower; + } + } + + if (prevKey == null && nextKey == null) { + prevKey = documentRepository + .findMaxSiblingOrderKey(request.newParentId(), documentId) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + } + + String newSiblingOrderKey; + try { + newSiblingOrderKey = FractionalIndex.keyBetween(prevKey, nextKey); + } catch (IllegalArgumentException ex) { + log.warn("Invalid sibling key interval: prevKey={} nextKey={}", prevKey, nextKey, ex); + throw new ApiException(ErrorCode.CONFLICT, "The position changed concurrently. Please retry."); + } + // Location authority: a subtree grafted into another user's tree joins that + // tree - the host becomes the owner of the moved document and everything + // beneath it, so access keeps flowing from the new parent chain. + User previousOwner = doc.getUser(); + if (!newParent.getUser().getId().equals(doc.getUser().getId())) { + adoptHostTreeOwnership(doc, newParent.getUser()); + } + + doc.setParent(newParent); + doc.setSiblingOrderKey(newSiblingOrderKey); + Document saved = documentRepository.saveAndFlush(doc); + + // Reparenting to a new parent: delete the owner's root UserDocumentOrder row. + // Collaborators' UserDocumentOrder rows MUST NOT be wiped, because direct + // collaborators still see this document floated in their Shared section. + userDocumentOrderRepository.deleteByUser_IdAndDocument_Id( + newParent.getUser().getId(), documentId); + if (!previousOwner.getId().equals(newParent.getUser().getId())) { + userDocumentOrderRepository.deleteByUser_IdAndDocument_Id(previousOwner.getId(), documentId); + } + + boolean hasChildren = documentRepository.existsNonTrashedChildrenByParentId(documentId); + boolean hasCollaborators = collaboratorRepository.existsByDocument_Id(documentId); + DocumentAccessLevel access = permissionService.resolveAccess(userId, documentId); + return new DocumentResponse( + saved.getId(), + saved.getTitle(), + null, + newParent.getId(), + newSiblingOrderKey, + hasChildren, + hasCollaborators, + access, + saved.getCreatedBy(), + saved.getCreatedAt(), + saved.getUpdatedAt(), + null, + null); + } else { + // Root-level move or personal Shared section reordering + Document targetDoc = documentRepository + .findByIdAndDeletedAtIsNull(documentId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + if (targetDoc.getUser().getId().equals(userId)) { + permissionService.requireEditAccess(userId, documentId); + } else { + permissionService.requireReadAccess(userId, documentId); + } + doc = targetDoc; + + if (rebuildFirst) { + reindexUserOrders(userId); + } + + String prevKey = resolveUserNeighborKey(userId, request.prevSiblingId(), documentId); + String nextKey = resolveUserNeighborKey(userId, request.nextSiblingId(), documentId); + + if (prevKey != null && nextKey != null && prevKey.compareTo(nextKey) > 0) { + String lower = prevKey; + prevKey = nextKey; + nextKey = lower; + } + + if (prevKey != null && !FractionalIndex.isValidOrderKey(prevKey) + || nextKey != null && !FractionalIndex.isValidOrderKey(nextKey) + || prevKey != null && prevKey.equals(nextKey)) { + reindexUserOrders(userId); + prevKey = resolveUserNeighborKey(userId, request.prevSiblingId(), documentId); + nextKey = resolveUserNeighborKey(userId, request.nextSiblingId(), documentId); + + if (prevKey != null && nextKey != null && prevKey.compareTo(nextKey) > 0) { + String lower = prevKey; + prevKey = nextKey; + nextKey = lower; + } + } + + if (prevKey == null && nextKey == null) { + prevKey = userDocumentOrderRepository + .findMaxOrderKeyByUserId(userId, documentId) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + } + + // The requested prev/next siblings are only adjacent in the frontend's + // section (Private or Shared); the shared user_document_orders key space + // is interleaved across both sections. So the new key must be computed + // against the actual adjacent keys in that shared space, otherwise it can + // collide with a document that lives between the two requested siblings. + String newUserOrderKey; + try { + if (prevKey != null) { + String actualSucc = userDocumentOrderRepository + .findMinOrderKeyGreaterThan(userId, prevKey, documentId) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + newUserOrderKey = FractionalIndex.keyBetween(prevKey, actualSucc); + } else if (nextKey != null) { + String actualPred = userDocumentOrderRepository + .findMaxOrderKeyLessThan(userId, nextKey, documentId) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + newUserOrderKey = FractionalIndex.keyBetween(actualPred, nextKey); + } else { + String maxKey = userDocumentOrderRepository + .findMaxOrderKeyByUserId(userId, documentId) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + newUserOrderKey = FractionalIndex.keyBetween(maxKey, null); + } + } catch (IllegalArgumentException ex) { + log.warn("Invalid user order key interval: prevKey={} nextKey={}", prevKey, nextKey, ex); + throw new ApiException(ErrorCode.CONFLICT, "The position changed concurrently. Please retry."); + } + + if (doc.getParent() != null) { + if (doc.getUser().getId().equals(userId)) { + // Owner: this is a private-tree move to the root level, so un-parent. + doc.setParent(null); + doc.setSiblingOrderKey(null); + documentRepository.saveAndFlush(doc); + } + // Collaborator: a nested shared document that appears at the root of + // the Shared section is only being reordered in the caller's personal + // navigation. The document stays under its real parent. + } + + User user = userRepository.findById(userId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + UserDocumentOrder udo = userDocumentOrderRepository + .findByUser_IdAndDocument_Id(userId, documentId) + .orElseGet(() -> + UserDocumentOrder.builder().user(user).document(doc).build()); + udo.setOrderKey(newUserOrderKey); + userDocumentOrderRepository.saveAndFlush(udo); + + for (DocumentCollaborator collaborator : collaboratorRepository.findAllByDocument_Id(documentId)) { + ensureCollaboratorRootOrder(doc, collaborator); + } + + boolean hasChildren = documentRepository.existsNonTrashedChildrenByParentId(documentId); + boolean hasCollaborators = collaboratorRepository.existsByDocument_Id(documentId); + DocumentAccessLevel access = doc.getUser().getId().equals(userId) + ? DocumentAccessLevel.OWNER + : permissionService.resolveAccess(userId, documentId); + + UUID resultParentId = doc.getParent() != null ? doc.getParent().getId() : null; + + return new DocumentResponse( + doc.getId(), + doc.getTitle(), + null, + resultParentId, + newUserOrderKey, + hasChildren, + hasCollaborators, + access, + doc.getCreatedBy(), + doc.getCreatedAt(), + doc.getUpdatedAt(), + null, + null); + } + } + + /** + * Transfers ownership of a moved document and all of its descendants to the owner of + * the destination tree, keeping the invariant child.user_id == parent.user_id so + * ancestor-based access resolution stays authoritative. + */ + private void adoptHostTreeOwnership(Document doc, User hostOwner) { + doc.setUser(hostOwner); + List frontier = List.of(doc.getId()); + int depth = 0; + while (!frontier.isEmpty()) { + if (depth >= MAX_TREE_DEPTH) { + // Mirrors the depth cap used elsewhere; cycles are prevented by move validation. + throw new ApiException(ErrorCode.VALIDATION_FAILED, "Document tree is too deep or contains a cycle."); + } + List children = documentRepository.findAllByParent_IdIn(frontier); + if (children.isEmpty()) { + break; + } + for (Document child : children) { + child.setUser(hostOwner); + } + documentRepository.saveAll(children); + frontier = children.stream().map(Document::getId).toList(); + depth++; + } + } + + private void reindexSiblings(UUID parentId) { + List siblings = documentRepository.findAllSiblingsForReindex(parentId); + if (siblings.isEmpty()) return; + String[] newKeys = FractionalIndex.nKeysBetweenSpaced(null, null, siblings.size(), REINDEX_GAP); + for (int i = 0; i < siblings.size(); i++) { + siblings.get(i).setSiblingOrderKey(newKeys[i]); + } + documentRepository.saveAll(siblings); + } + + private void reindexUserOrders(UUID userId) { + List orders = userDocumentOrderRepository.findAllForReindex(userId); + if (orders.isEmpty()) return; + String[] newKeys = FractionalIndex.nKeysBetweenSpaced(null, null, orders.size(), REINDEX_GAP); + for (int i = 0; i < orders.size(); i++) { + orders.get(i).setOrderKey(newKeys[i]); + } + userDocumentOrderRepository.saveAll(orders); + } + + private String nextFreeOrderKey(UUID userId, String minKey) { + String candidate = FractionalIndex.keyBetween(null, minKey); + while (userDocumentOrderRepository.existsByUser_IdAndOrderKey(userId, candidate)) { + candidate = FractionalIndex.keyBetween(null, candidate); + } + return candidate; + } + + private void ensureCollaboratorRootOrder(Document doc, DocumentCollaborator collaborator) { + UUID collaboratorId = collaborator.getUser().getId(); + if (userDocumentOrderRepository.existsByUser_IdAndDocument_Id(collaboratorId, doc.getId())) { + return; + } + String minKey = userDocumentOrderRepository + .findMinOrderKeyByUserId(collaboratorId, doc.getId()) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + UserDocumentOrder cudo = UserDocumentOrder.builder() + .user(collaborator.getUser()) + .document(doc) + .orderKey(nextFreeOrderKey(collaboratorId, minKey)) + .build(); + userDocumentOrderRepository.saveAndFlush(cudo); + } + + private String resolveSiblingNeighborKey(UUID siblingId, UUID excludedDocId, UUID expectedParentId) { + if (siblingId == null) return null; + if (siblingId.equals(excludedDocId)) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "A document cannot be its own sibling reference."); + } + Document sibling = documentRepository + .findByIdAndDeletedAtIsNull(siblingId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Sibling document not found: " + siblingId)); + if (expectedParentId != null) { + if (sibling.getParent() == null + || !expectedParentId.equals(sibling.getParent().getId())) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, + "Sibling document does not belong to the specified parent: " + siblingId); + } + } else if (sibling.getParent() != null) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, + "Sibling document does not belong to the specified parent: " + siblingId); + } + return sibling.getSiblingOrderKey(); + } + + private String resolveUserNeighborKey(UUID userId, UUID siblingId, UUID excludedDocId) { + if (siblingId == null) return null; + if (siblingId.equals(excludedDocId)) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "A document cannot be its own sibling reference."); + } + Document sibling = documentRepository + .findByIdAndDeletedAtIsNull(siblingId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Sibling document not found: " + siblingId)); + if (sibling.getUser().getId().equals(userId)) { + if (sibling.getParent() != null) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "sibling does not belong to root navigation"); + } + } else { + DocumentAccessLevel access = permissionService.resolveAccess(userId, siblingId); + if (access == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "Sibling document not found: " + siblingId); + } + } + return userDocumentOrderRepository + .findOrderKeyByUserIdAndDocumentId(userId, siblingId) + .orElseGet(() -> ensureUserOrderKey(userId, sibling)); + } + + private String ensureUserOrderKey(UUID userId, Document sibling) { + User user = userRepository.findById(userId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + String minKey = userDocumentOrderRepository + .findMinOrderKeyByUserId(userId, sibling.getId()) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + UserDocumentOrder udo = UserDocumentOrder.builder() + .user(user) + .document(sibling) + .orderKey(nextFreeOrderKey(userId, minKey)) + .build(); + userDocumentOrderRepository.saveAndFlush(udo); + return udo.getOrderKey(); + } + + private void validateNoCycle(UUID documentId, UUID newParentId) { + UUID cursor = newParentId; + int depth = 0; + while (cursor != null) { + if (depth >= MAX_TREE_DEPTH) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, + "Ancestor chain is too deep or contains a cycle exceeding maximum depth."); + } + if (cursor.equals(documentId)) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, "Cannot move a document under one of its own descendants."); + } + Document ancestor = + documentRepository.findByIdAndDeletedAtIsNull(cursor).orElse(null); + if (ancestor == null) break; + cursor = ancestor.getParent() != null ? ancestor.getParent().getId() : null; + depth++; + } + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/service/PermissionService.java b/api/src/main/java/com/nextdocs/api/document/service/PermissionService.java new file mode 100644 index 0000000..c99e4aa --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/service/PermissionService.java @@ -0,0 +1,163 @@ +package com.nextdocs.api.document.service; + +import com.nextdocs.api.common.exception.ApiException; +import com.nextdocs.api.common.exception.ErrorCode; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.repository.DocumentRepository; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Authoritative service to resolve effective permissions and enforce access control. + * It replaces direct ownership/collaborator lookups with ancestor-walk resolution. + */ +@Service +@RequiredArgsConstructor +public class PermissionService { + + private final DocumentRepository documentRepository; + + /** + * Resolves the effective access level of a user for a document. + * Walks up the ancestor chain (closest-ancestor-wins). + * + * @return the resolved access level, or null if no access is granted + */ + @Transactional(readOnly = true) + public DocumentAccessLevel resolveAccess(UUID userId, UUID documentId) { + String raw = documentRepository.resolveEffectiveAccess(userId, documentId); + if (raw == null) { + return null; + } + return DocumentAccessLevel.valueOf(raw); + } + + /** + * Enforces that the user has at least VIEW (read) access to the document. + * Masking forbidden as not found to protect document existence privacy. + * + * @return the Document if accessible + */ + @Transactional(readOnly = true) + public Document requireReadAccess(UUID userId, UUID documentId) { + Document doc = documentRepository + .findByIdAndDeletedAtIsNull(documentId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + DocumentAccessLevel access = resolveAccess(userId, documentId); + if (access == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return doc; + } + + /** + * Enforces that the user has at least EDIT access to the document. + * + * @return the Document if editable + */ + @Transactional(readOnly = true) + public Document requireEditAccess(UUID userId, UUID documentId) { + Document doc = documentRepository + .findByIdAndDeletedAtIsNull(documentId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + DocumentAccessLevel access = resolveAccess(userId, documentId); + if (access == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (!access.allowsEdit()) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + return doc; + } + + /** + * Enforces that the user is the direct owner of the document (no ancestor walk). + * Administrative settings (e.g. sharing settings, collaborator edits) must be restricted + * to the direct owner of the specific page. + * + * @return the Document if owned + */ + @Transactional(readOnly = true) + public Document requireOwnerAccess(UUID userId, UUID documentId) { + return documentRepository + .findByIdAndUser_IdAndDeletedAtIsNull(documentId, userId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + } + + /** + * Enforces direct ownership regardless of trash state. Sharing administration stays + * available while a document is in trash so owners can still manage collaborator access. + * + * @return the Document if owned, whether trashed or not + */ + @Transactional(readOnly = true) + public Document requireOwnerAccessIncludingTrash(UUID userId, UUID documentId) { + return documentRepository + .findByIdAndUser_Id(documentId, userId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + } + + /** + * Read access for active documents via the normal access chain; ownership-only fallback + * for trashed documents (e.g. viewing the collaborator list of a trashed document). + * + * @return the Document if readable under either rule + */ + @Transactional(readOnly = true) + public Document requireReadAccessOrTrashOwner(UUID userId, UUID documentId) { + Document active = + documentRepository.findByIdAndDeletedAtIsNull(documentId).orElse(null); + if (active != null) { + DocumentAccessLevel access = resolveAccess(userId, documentId); + if (access == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return active; + } + return documentRepository + .findByIdAndUser_Id(documentId, userId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + } + + /** + * Resolves the effective access level of a user for a document in the trash scope, + * i.e. including soft-deleted documents. Mirrors {@link #resolveAccess(UUID, UUID)} + * so permissions held before a document was trashed remain valid for + * trash management (viewing trash state, restoring, permanently deleting). + * + * @return the resolved access level, or null if no access is granted + */ + @Transactional(readOnly = true) + public DocumentAccessLevel resolveTrashAccess(UUID userId, UUID documentId) { + String raw = documentRepository.resolveTrashAccess(userId, documentId); + if (raw == null) { + return null; + } + return DocumentAccessLevel.valueOf(raw); + } + + /** + * Enforces that the user has at least EDIT-level access to a document in the trash scope. + * Owners and EDIT collaborators may manage trashed documents; VIEW/COMMENT grants are rejected. + * + * @return the Document regardless of trash state + */ + @Transactional(readOnly = true) + public Document requireTrashEditAccess(UUID userId, UUID documentId) { + Document doc = documentRepository.findById(documentId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + DocumentAccessLevel access = resolveTrashAccess(userId, documentId); + if (access == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (!access.allowsEdit()) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + return doc; + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/util/FractionalIndex.java b/api/src/main/java/com/nextdocs/api/document/util/FractionalIndex.java new file mode 100644 index 0000000..df0bd16 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/util/FractionalIndex.java @@ -0,0 +1,277 @@ +package com.nextdocs.api.document.util; + +public final class FractionalIndex { + + private FractionalIndex() {} + + private static final String DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + private static final String INT_DIGITS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + public static String keyBetween(String a, String b) { + return generateKeyBetween(a, b, DIGITS, INT_DIGITS); + } + + public static boolean isValidOrderKey(String key) { + if (key == null || key.isEmpty()) { + return false; + } + try { + validateOrderKey(key, DIGITS, INT_DIGITS); + return true; + } catch (IllegalArgumentException ex) { + return false; + } + } + + public static String[] nKeysBetween(String a, String b, int n) { + return generateNKeysBetween(a, b, n, DIGITS, INT_DIGITS); + } + + public static String[] nKeysBetweenSpaced(String a, String b, int n, int gap) { + if (n == 0) { + return new String[0]; + } + if (gap < 1) { + throw new IllegalArgumentException("gap must be >= 1"); + } + String[] keys = new String[n]; + String current = generateKeyBetween(a, b, DIGITS, INT_DIGITS); + keys[0] = current; + for (int i = 1; i < n; i++) { + for (int g = 0; g < gap; g++) { + current = generateKeyBetween(current, b, DIGITS, INT_DIGITS); + } + keys[i] = current; + } + return keys; + } + + private static String generateKeyBetween(String a, String b, String digits, String intDigits) { + if (a != null) { + validateOrderKey(a, digits, intDigits); + } + if (b != null) { + validateOrderKey(b, digits, intDigits); + } + if (a != null && b != null) { + if (a.compareTo(b) >= 0) { + throw new IllegalArgumentException("a must be less than b: " + a + " >= " + b); + } + } + + if (a == null) { + if (b == null) { + String head = String.valueOf(intDigits.charAt(intDigits.length() / 2)); + return head + digits.charAt(0); + } + String ib = getIntegerPart(b, intDigits); + String fb = b.substring(ib.length()); + if (isSmallestInteger(ib, digits, intDigits)) { + return ib + midpoint("", fb, digits); + } + if (ib.compareTo(b) < 0) { + return ib; + } + String res = decrementInteger(ib, digits, intDigits); + if (res == null) { + throw new IllegalArgumentException("cannot decrement any more"); + } + return res; + } + + if (b == null) { + String ia = getIntegerPart(a, intDigits); + String fa = a.substring(ia.length()); + String i = incrementInteger(ia, digits, intDigits); + if (i == null) { + return ia + midpoint(fa, null, digits); + } + return i; + } + + String ia = getIntegerPart(a, intDigits); + String fa = a.substring(ia.length()); + String ib = getIntegerPart(b, intDigits); + String fb = b.substring(ib.length()); + if (ia.equals(ib)) { + return ia + midpoint(fa, fb, digits); + } + String i = incrementInteger(ia, digits, intDigits); + if (i == null) { + throw new IllegalArgumentException("cannot increment any more"); + } + if (i.compareTo(b) < 0) { + return i; + } + return ia + midpoint(fa, null, digits); + } + + private static String[] generateNKeysBetween(String a, String b, int n, String digits, String intDigits) { + if (n == 0) { + return new String[0]; + } + if (n == 1) { + return new String[] {generateKeyBetween(a, b, digits, intDigits)}; + } + if (b == null) { + String c = generateKeyBetween(a, b, digits, intDigits); + String[] result = new String[n]; + result[0] = c; + for (int i = 1; i < n; i++) { + c = generateKeyBetween(c, b, digits, intDigits); + result[i] = c; + } + return result; + } + if (a == null) { + String c = generateKeyBetween(a, b, digits, intDigits); + String[] result = new String[n]; + result[n - 1] = c; + for (int i = n - 2; i >= 0; i--) { + c = generateKeyBetween(a, c, digits, intDigits); + result[i] = c; + } + return result; + } + int mid = n / 2; + String c = generateKeyBetween(a, b, digits, intDigits); + String[] left = generateNKeysBetween(a, c, mid, digits, intDigits); + String[] right = generateNKeysBetween(c, b, n - mid - 1, digits, intDigits); + String[] result = new String[n]; + System.arraycopy(left, 0, result, 0, left.length); + result[left.length] = c; + System.arraycopy(right, 0, result, left.length + 1, right.length); + return result; + } + + private static String midpoint(String a, String b, String digits) { + char zero = digits.charAt(0); + if (b != null && a.compareTo(b) >= 0) { + throw new IllegalArgumentException(a + " >= " + b); + } + if ((a.length() > 0 && a.charAt(a.length() - 1) == zero) + || (b != null && b.length() > 0 && b.charAt(b.length() - 1) == zero)) { + throw new IllegalArgumentException("trailing zero"); + } + if (b != null) { + int n = 0; + while (charOrZero(a, n, zero) == charAtOrNull(b, n)) { + n++; + } + if (n > 0) { + return b.substring(0, n) + midpoint(a.substring(n), b.substring(n), digits); + } + } + int digitA = a.length() > 0 ? digits.indexOf(a.charAt(0)) : 0; + int digitB = b != null ? digits.indexOf(b.charAt(0)) : digits.length(); + if (digitB - digitA > 1) { + int midDigit = (int) Math.round(0.5 * (digitA + digitB)); + return String.valueOf(digits.charAt(midDigit)); + } else { + if (b != null && b.length() > 1) { + return b.substring(0, 1); + } else { + return digits.charAt(digitA) + midpoint(a.length() > 1 ? a.substring(1) : "", null, digits); + } + } + } + + private static char charOrZero(String s, int index, char zero) { + return index < s.length() ? s.charAt(index) : zero; + } + + private static Character charAtOrNull(String s, int index) { + return index < s.length() ? s.charAt(index) : null; + } + + private static int getIntegerLength(String head, String intDigits) { + int i = intDigits.indexOf(head.charAt(0)); + if (i == -1 || intDigits.charAt(i) != head.charAt(0)) { + throw new IllegalArgumentException("invalid order key head: " + head); + } + int half = intDigits.length() / 2; + return i < half ? half - i + 1 : i - half + 2; + } + + private static String getIntegerPart(String key, String intDigits) { + int integerPartLength = getIntegerLength(key.substring(0, 1), intDigits); + if (integerPartLength > key.length()) { + throw new IllegalArgumentException("invalid order key: " + key); + } + return key.substring(0, integerPartLength); + } + + private static boolean isSmallestInteger(String key, String digits, String intDigits) { + String smallest = intDigits.charAt(0) + String.valueOf(digits.charAt(0)).repeat(intDigits.length() / 2); + return key.equals(smallest); + } + + private static void validateOrderKey(String key, String digits, String intDigits) { + if (isSmallestInteger(key, digits, intDigits)) { + throw new IllegalArgumentException("invalid order key: " + key); + } + String i = getIntegerPart(key, intDigits); + String f = key.substring(i.length()); + if (f.length() > 0 && f.charAt(f.length() - 1) == digits.charAt(0)) { + throw new IllegalArgumentException("invalid order key: " + key); + } + } + + private static void validateInteger(String x, String intDigits) { + int expectedLength = getIntegerLength(x.substring(0, 1), intDigits); + if (x.length() != expectedLength) { + throw new IllegalArgumentException("invalid integer part of order key: " + x); + } + } + + private static String incrementInteger(String x, String digits, String intDigits) { + validateInteger(x, intDigits); + String head = x.substring(0, 1); + char zero = digits.charAt(0); + StringBuilder trailing = new StringBuilder(); + for (int i = x.length() - 1; i >= 1; i--) { + int d = digits.indexOf(x.charAt(i)) + 1; + if (d == digits.length()) { + trailing.append(zero); + } else { + return head + x.substring(1, i) + digits.charAt(d) + trailing; + } + } + int headIndex = intDigits.indexOf(head.charAt(0)); + if (headIndex == intDigits.length() - 1) { + return null; + } + String h = String.valueOf(intDigits.charAt(headIndex + 1)); + int lengthDelta = getIntegerLength(h, intDigits) - getIntegerLength(head, intDigits); + return h + + (lengthDelta > 0 + ? trailing + String.valueOf(zero) + : lengthDelta < 0 ? trailing.substring(1) : trailing.toString()); + } + + private static String decrementInteger(String x, String digits, String intDigits) { + validateInteger(x, intDigits); + String head = x.substring(0, 1); + char last = digits.charAt(digits.length() - 1); + StringBuilder trailing = new StringBuilder(); + for (int i = x.length() - 1; i >= 1; i--) { + int d = digits.indexOf(x.charAt(i)) - 1; + if (d == -1) { + trailing.append(last); + } else { + return head + x.substring(1, i) + digits.charAt(d) + trailing; + } + } + int headIndex = intDigits.indexOf(head.charAt(0)); + if (headIndex == 0) { + return null; + } + String h = String.valueOf(intDigits.charAt(headIndex - 1)); + int lengthDelta = getIntegerLength(h, intDigits) - getIntegerLength(head, intDigits); + return h + + (lengthDelta > 0 + ? trailing + String.valueOf(last) + : lengthDelta < 0 ? trailing.substring(1) : trailing.toString()); + } +} diff --git a/api/src/main/resources/db/migration/V10__trash_permission_resolution.sql b/api/src/main/resources/db/migration/V10__trash_permission_resolution.sql new file mode 100644 index 0000000..262d448 --- /dev/null +++ b/api/src/main/resources/db/migration/V10__trash_permission_resolution.sql @@ -0,0 +1,85 @@ +-- Migration V10: Trash-scope permission resolution. +-- resolve_effective_access filters deleted_at IS NULL, so it returns no grants for anything +-- inside a trashed subtree. This function resolves access for trash management instead: +-- +-- 1. A trashed document belongs to its "trash bundle" - the contiguous run of trashed +-- ancestors above it. Whoever manages the topmost trashed node manages everything +-- inside it, mirroring how a subtree is restored as one unit. Documents grafted into +-- another user's tree therefore follow the host tree's fate instead of surfacing as +-- ghost entries their creator cannot restore or purge. +-- 2. Access is then resolved with the standard ancestor walk starting at the bundle root, +-- ignoring deleted_at so pre-trash grants stay valid. Closest grant wins; priority per +-- node: explicit collaborator row > ANYONE_WITH_LINK > none. +-- +-- Works for active documents too (bundle root falls back to the document itself). +-- Returns: TEXT ('VIEW' | 'COMMENT' | 'EDIT' | 'OWNER' | NULL) +CREATE OR REPLACE FUNCTION resolve_trash_access(p_user_id UUID, p_document_id UUID) +RETURNS TEXT +LANGUAGE sql +STABLE +AS $$ + WITH RECURSIVE + -- Step 1: climb the contiguous run of trashed nodes containing the document. + climb AS ( + SELECT d.id AS node_id, + d.parent_id, + (d.deleted_at IS NOT NULL) AS trashed, + 0 AS depth + FROM documents d + WHERE d.id = p_document_id + + UNION ALL + + -- Stop at the first non-trashed ancestor: it bounds the bundle. + SELECT p.id, p.parent_id, + (p.deleted_at IS NOT NULL), + c.depth + 1 + FROM documents p + JOIN climb c ON p.id = c.parent_id + WHERE c.trashed + ), + bundle_root AS ( + -- Topmost trashed node of the run; an active document falls back to itself. + SELECT node_id + FROM climb + ORDER BY trashed DESC, depth DESC + LIMIT 1 + ), + -- Step 2: ancestor walk from the bundle root, trashed rows included. + chain AS ( + SELECT d.id, d.user_id, d.parent_id, + d.general_access_mode, d.link_access_level, + 0 AS depth + FROM documents d + JOIN bundle_root br ON d.id = br.node_id + + UNION ALL + + SELECT p.id, p.user_id, p.parent_id, + p.general_access_mode, p.link_access_level, + c.depth + 1 + FROM documents p + JOIN chain c ON p.id = c.parent_id + WHERE c.depth < 100 -- hard cap; real trees are never this deep + ), + grants AS ( + SELECT ch.depth, + CASE + WHEN ch.user_id = p_user_id THEN 'OWNER' + WHEN col.access_level IS NOT NULL THEN col.access_level + WHEN ch.general_access_mode = 'ANYONE_WITH_LINK' THEN ch.link_access_level + ELSE NULL + END AS resolved_level + FROM chain ch + LEFT JOIN document_collaborators col + ON col.document_id = ch.id + AND col.user_id = p_user_id + ) + SELECT resolved_level + FROM grants + WHERE resolved_level IS NOT NULL + ORDER BY depth ASC + LIMIT 1; +$$; + +DROP FUNCTION IF EXISTS resolve_trash_bundle_access(UUID, UUID); diff --git a/api/src/main/resources/db/migration/V11__normalize_document_ownership.sql b/api/src/main/resources/db/migration/V11__normalize_document_ownership.sql new file mode 100644 index 0000000..d30269a --- /dev/null +++ b/api/src/main/resources/db/migration/V11__normalize_document_ownership.sql @@ -0,0 +1,25 @@ +-- Migration V11: Normalize document ownership to location authority. +-- Invariant: for any nested document, child.user_id == parent.user_id. Ownership of a +-- subtree belongs to the tree's root owner; creators are recorded in created_by and their +-- access flows through ancestor resolution. This backfills existing rows so that documents +-- created under (or moved into) another user's tree stop carrying creator-based ownership, +-- which previously let them bypass the host tree's access changes. + +-- Propagate each root's owner down to every descendant. +WITH RECURSIVE tree AS ( + -- Roots keep their owner. + SELECT d.id, d.user_id AS root_owner + FROM documents d + WHERE d.parent_id IS NULL + + UNION ALL + + SELECT d.id, t.root_owner + FROM documents d + JOIN tree t ON d.parent_id = t.id +) +UPDATE documents doc + SET user_id = tree.root_owner + FROM tree + WHERE doc.id = tree.id + AND doc.user_id <> tree.root_owner; diff --git a/api/src/main/resources/db/migration/V6__document_nesting.sql b/api/src/main/resources/db/migration/V6__document_nesting.sql new file mode 100644 index 0000000..65aca8d --- /dev/null +++ b/api/src/main/resources/db/migration/V6__document_nesting.sql @@ -0,0 +1,61 @@ +-- Phase 1: Add parent_id (self-referencing FK) and sibling_order_key (fractional index for nested docs). +-- parent_id NULL → root-level page (no parent; navigation order is in user_document_orders). +-- sibling_order_key → non-null when parent_id IS NOT NULL; null when parent_id IS NULL. + +ALTER TABLE documents + ADD COLUMN parent_id UUID NULL + REFERENCES documents(id) ON DELETE SET NULL, + ADD COLUMN sibling_order_key TEXT NULL; + +-- Enforce that nested documents must have a sibling_order_key +ALTER TABLE documents + ADD CONSTRAINT chk_documents_sibling_order + CHECK (parent_id IS NULL OR sibling_order_key IS NOT NULL); + +-- Backfill sibling_order_key for any existing nested rows +UPDATE documents +SET sibling_order_key = to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') +WHERE parent_id IS NOT NULL AND (sibling_order_key IS NULL OR sibling_order_key = ''); + +-- User navigation placement table for root-level documents (Private and Shared sections) +CREATE TABLE user_document_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + order_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uq_user_document_orders_user_doc UNIQUE (user_id, document_id) +); + +-- Backfill user_document_orders for root-level documents (owner entries) +INSERT INTO user_document_orders (user_id, document_id, order_key, created_at, updated_at) +SELECT d.user_id, d.id, + to_char(d.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + d.created_at, d.updated_at +FROM documents d +WHERE d.parent_id IS NULL +ON CONFLICT (user_id, document_id) DO NOTHING; + +-- Backfill user_document_orders for collaborators on root-level documents +INSERT INTO user_document_orders (user_id, document_id, order_key, created_at, updated_at) +SELECT c.user_id, c.document_id, + to_char(c.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + c.created_at, c.updated_at +FROM document_collaborators c +JOIN documents d ON d.id = c.document_id +WHERE d.parent_id IS NULL +ON CONFLICT (user_id, document_id) DO NOTHING; + +-- Partial index for children of parent X sorted by sibling_order_key +CREATE INDEX idx_documents_parent_sibling_order + ON documents(parent_id, sibling_order_key) + WHERE parent_id IS NOT NULL; + +-- Index for per-user navigation ordering +CREATE INDEX idx_user_document_orders_user_order + ON user_document_orders(user_id, order_key); + +CREATE INDEX idx_user_document_orders_doc + ON user_document_orders(document_id); diff --git a/api/src/main/resources/db/migration/V7__ancestor_permission_resolution.sql b/api/src/main/resources/db/migration/V7__ancestor_permission_resolution.sql new file mode 100644 index 0000000..2423df4 --- /dev/null +++ b/api/src/main/resources/db/migration/V7__ancestor_permission_resolution.sql @@ -0,0 +1,54 @@ +-- Computes the effective access_level a given user has on a given document +-- by walking the parent_id chain (closest-ancestor-wins). +-- Returns NULL if no explicit grant is found anywhere in the chain. +-- Accepts: p_user_id UUID, p_document_id UUID +-- Returns: TEXT ('VIEW' | 'COMMENT' | 'EDIT' | 'OWNER' | NULL) +CREATE OR REPLACE FUNCTION resolve_effective_access(p_user_id UUID, p_document_id UUID) +RETURNS TEXT +LANGUAGE sql +STABLE +AS $$ + WITH RECURSIVE chain AS ( + -- Seed: the document itself + SELECT d.id, d.user_id, d.parent_id, + d.general_access_mode, d.link_access_level, + 0 AS depth + FROM documents d + WHERE d.id = p_document_id + AND d.deleted_at IS NULL + + UNION ALL + + -- Walk up to parent + SELECT p.id, p.user_id, p.parent_id, + p.general_access_mode, p.link_access_level, + c.depth + 1 + FROM documents p + JOIN chain c ON p.id = c.parent_id + WHERE c.depth < 100 -- hard cap; real trees are never this deep + AND p.deleted_at IS NULL + ), + -- For each node in the chain, find the best explicit grant for this user. + -- Priority within a single node: explicit collaborator row > ANYONE_WITH_LINK. + grants AS ( + SELECT + ch.id AS doc_id, + ch.depth, + CASE + WHEN ch.user_id = p_user_id THEN 'OWNER' + WHEN col.access_level IS NOT NULL THEN col.access_level + WHEN ch.general_access_mode = 'ANYONE_WITH_LINK' THEN ch.link_access_level + ELSE NULL + END AS resolved_level + FROM chain ch + LEFT JOIN document_collaborators col + ON col.document_id = ch.id + AND col.user_id = p_user_id + ) + -- Pick the shallowest (closest) ancestor that actually has a grant. + SELECT resolved_level + FROM grants + WHERE resolved_level IS NOT NULL + ORDER BY depth ASC + LIMIT 1; +$$; diff --git a/api/src/main/resources/db/migration/V8__fix_order_key_backfill.sql b/api/src/main/resources/db/migration/V8__fix_order_key_backfill.sql new file mode 100644 index 0000000..e934fa2 --- /dev/null +++ b/api/src/main/resources/db/migration/V8__fix_order_key_backfill.sql @@ -0,0 +1,57 @@ +-- Migration V8: Fix order_key backfill for legacy timestamp keys starting with digits or containing non-alphanumeric chars. +-- FractionalIndex keys must start with an ASCII letter ('A'-'Z', 'a'-'z') and contain only [0-9A-Za-z]. + +-- 1. Fix nested documents sibling_order_key +WITH base62 AS ( + SELECT '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' AS chars +), +invalid_docs AS ( + SELECT id, + (ROW_NUMBER() OVER (PARTITION BY parent_id ORDER BY created_at ASC, id ASC) - 1) AS idx + FROM documents + WHERE parent_id IS NOT NULL + AND (sibling_order_key IS NULL + OR sibling_order_key !~ '^[A-Za-z][0-9A-Za-z]+$' + OR sibling_order_key ~ '[-:.]') +) +UPDATE documents d +SET sibling_order_key = CASE + WHEN invalid_docs.idx < 62 THEN + 'a' || SUBSTRING(b.chars FROM (invalid_docs.idx + 1)::integer FOR 1) + WHEN invalid_docs.idx < 3908 THEN + 'b' || SUBSTRING(b.chars FROM ((invalid_docs.idx - 62) / 62 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM ((invalid_docs.idx - 62) % 62 + 1)::integer FOR 1) + ELSE + 'c' || SUBSTRING(b.chars FROM ((invalid_docs.idx - 3908) / 3844 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM (((invalid_docs.idx - 3908) / 62) % 62 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM ((invalid_docs.idx - 3908) % 62 + 1)::integer FOR 1) +END +FROM invalid_docs, base62 b +WHERE d.id = invalid_docs.id; + +-- 2. Fix user_document_orders order_key +WITH base62 AS ( + SELECT '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' AS chars +), +invalid_orders AS ( + SELECT id, + (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) - 1) AS idx + FROM user_document_orders + WHERE order_key IS NULL + OR order_key !~ '^[A-Za-z][0-9A-Za-z]+$' + OR order_key ~ '[-:.]' +) +UPDATE user_document_orders udo +SET order_key = CASE + WHEN invalid_orders.idx < 62 THEN + 'a' || SUBSTRING(b.chars FROM (invalid_orders.idx + 1)::integer FOR 1) + WHEN invalid_orders.idx < 3908 THEN + 'b' || SUBSTRING(b.chars FROM ((invalid_orders.idx - 62) / 62 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM ((invalid_orders.idx - 62) % 62 + 1)::integer FOR 1) + ELSE + 'c' || SUBSTRING(b.chars FROM ((invalid_orders.idx - 3908) / 3844 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM (((invalid_orders.idx - 3908) / 62) % 62 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM ((invalid_orders.idx - 3908) % 62 + 1)::integer FOR 1) +END +FROM invalid_orders, base62 b +WHERE udo.id = invalid_orders.id; diff --git a/api/src/main/resources/db/migration/V9__reindex_all_order_keys.sql b/api/src/main/resources/db/migration/V9__reindex_all_order_keys.sql new file mode 100644 index 0000000..2a2e0bd --- /dev/null +++ b/api/src/main/resources/db/migration/V9__reindex_all_order_keys.sql @@ -0,0 +1,69 @@ +-- Migration V9: Re-index sibling_order_key for nested documents and order_key for user_document_orders +-- to guarantee unique, valid Base62 fractional index keys, then enforce uniqueness constraints. + +WITH base62 AS ( + SELECT '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' AS chars +), +ranked_nested_docs AS ( + SELECT + d.id, + (ROW_NUMBER() OVER ( + PARTITION BY d.parent_id + ORDER BY d.created_at ASC, d.id ASC + ) - 1) AS idx + FROM documents d + WHERE d.parent_id IS NOT NULL AND d.deleted_at IS NULL +) +UPDATE documents d +SET sibling_order_key = CASE + WHEN r.idx < 62 THEN + 'a' || SUBSTRING(b.chars FROM (r.idx + 1)::integer FOR 1) + WHEN r.idx < 3908 THEN + 'b' || SUBSTRING(b.chars FROM ((r.idx - 62) / 62 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM ((r.idx - 62) % 62 + 1)::integer FOR 1) + ELSE + 'c' || SUBSTRING(b.chars FROM ((r.idx - 3908) / 3844 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM (((r.idx - 3908) / 62) % 62 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM ((r.idx - 3908) % 62 + 1)::integer FOR 1) +END +FROM ranked_nested_docs r, base62 b +WHERE d.id = r.id; + +-- Re-index user_document_orders for all users +WITH base62 AS ( + SELECT '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' AS chars +), +ranked_orders AS ( + SELECT + udo.id, + (ROW_NUMBER() OVER ( + PARTITION BY udo.user_id + ORDER BY udo.created_at ASC, udo.id ASC + ) - 1) AS idx + FROM user_document_orders udo +) +UPDATE user_document_orders udo +SET order_key = CASE + WHEN r.idx < 62 THEN + 'a' || SUBSTRING(b.chars FROM (r.idx + 1)::integer FOR 1) + WHEN r.idx < 3908 THEN + 'b' || SUBSTRING(b.chars FROM ((r.idx - 62) / 62 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM ((r.idx - 62) % 62 + 1)::integer FOR 1) + ELSE + 'c' || SUBSTRING(b.chars FROM ((r.idx - 3908) / 3844 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM (((r.idx - 3908) / 62) % 62 + 1)::integer FOR 1) + || SUBSTRING(b.chars FROM ((r.idx - 3908) % 62 + 1)::integer FOR 1) +END +FROM ranked_orders r, base62 b +WHERE udo.id = r.id; + +-- Enforce uniqueness of (parent_id, sibling_order_key) within each parent's child group. +-- Trashed rows are excluded so trashing a document never blocks creating a new +-- sibling with the same key; restore() regenerates a fresh key when needed. +CREATE UNIQUE INDEX idx_documents_parent_sibling_order_key_unique + ON documents(parent_id, sibling_order_key) + WHERE parent_id IS NOT NULL AND deleted_at IS NULL; + +-- Enforce uniqueness of order_key per user in user_document_orders +CREATE UNIQUE INDEX idx_user_document_orders_user_order_key + ON user_document_orders(user_id, order_key); diff --git a/api/src/test/java/com/nextdocs/api/auth/entity/converter/OAuthTokenAttributeConverterTest.java b/api/src/test/java/com/nextdocs/api/auth/entity/converter/OAuthTokenAttributeConverterTest.java index 4f77050..0764f3c 100644 --- a/api/src/test/java/com/nextdocs/api/auth/entity/converter/OAuthTokenAttributeConverterTest.java +++ b/api/src/test/java/com/nextdocs/api/auth/entity/converter/OAuthTokenAttributeConverterTest.java @@ -6,15 +6,27 @@ import jakarta.persistence.PersistenceException; import java.util.Base64; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class OAuthTokenAttributeConverterTest { private static final String KEY_PROPERTY = "oauth.token.encryption.key-base64"; + private String originalKeyProperty; + + @BeforeEach + void captureKeyProperty() { + originalKeyProperty = System.getProperty(KEY_PROPERTY); + } + @AfterEach void tearDown() { - System.clearProperty(KEY_PROPERTY); + if (originalKeyProperty == null) { + System.clearProperty(KEY_PROPERTY); + } else { + System.setProperty(KEY_PROPERTY, originalKeyProperty); + } } @Test @@ -44,6 +56,8 @@ void convertMethods_whenNullValue_returnNull() { @Test void constructor_whenKeyMissing_throwsPersistenceException() { // Key is resolved at construction time — no key means immediate failure (fail-fast). + System.clearProperty(KEY_PROPERTY); + assertThatThrownBy(OAuthTokenAttributeConverter::new) .isInstanceOf(PersistenceException.class) .hasMessageContaining("not configured"); diff --git a/api/src/test/java/com/nextdocs/api/document/controller/DocumentControllerTest.java b/api/src/test/java/com/nextdocs/api/document/controller/DocumentControllerTest.java index 1b20745..858cff3 100644 --- a/api/src/test/java/com/nextdocs/api/document/controller/DocumentControllerTest.java +++ b/api/src/test/java/com/nextdocs/api/document/controller/DocumentControllerTest.java @@ -14,8 +14,12 @@ import com.nextdocs.api.auth.security.UserPrincipal; import com.nextdocs.api.common.exception.ApiException; import com.nextdocs.api.common.exception.ErrorCode; +import com.nextdocs.api.document.dto.request.DocumentMoveRequest; +import com.nextdocs.api.document.dto.response.DocumentBreadcrumbResponse; import com.nextdocs.api.document.dto.response.DocumentResponse; +import com.nextdocs.api.document.entity.DocumentAccessLevel; import com.nextdocs.api.document.service.DocumentService; +import com.nextdocs.api.document.service.DocumentTreeService; import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; @@ -45,6 +49,9 @@ class DocumentControllerTest { @MockitoBean private DocumentService documentService; + @MockitoBean + private DocumentTreeService documentTreeService; + @MockitoBean private JwtTokenProvider jwtTokenProvider; @@ -71,7 +78,19 @@ void setUp() { @Test void create_success_returns201() throws Exception { DocumentResponse response = new DocumentResponse( - documentId, "My Doc", "AQID", "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + documentId, + "My Doc", + "AQID", + null, + null, + false, + false, + DocumentAccessLevel.OWNER, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); when(documentService.create(eq(userId), any())) .thenReturn(new DocumentService.CreateDocumentResult(response, true)); @@ -94,7 +113,19 @@ void create_success_returns201() throws Exception { @Test void create_existingClientDocument_returns200() throws Exception { DocumentResponse response = new DocumentResponse( - documentId, "My Doc", "AQID", "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + documentId, + "My Doc", + "AQID", + null, + null, + false, + false, + DocumentAccessLevel.OWNER, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); when(documentService.create(eq(userId), any())) .thenReturn(new DocumentService.CreateDocumentResult(response, false)); @@ -116,17 +147,154 @@ void create_existingClientDocument_returns200() throws Exception { } @Test - void list_success_returns200() throws Exception { + void list_default_success_returns200() throws Exception { DocumentResponse response = new DocumentResponse( - documentId, "My Doc", null, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + documentId, + "My Doc", + null, + null, + "a0", + false, + false, + DocumentAccessLevel.OWNER, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); Page page = new PageImpl<>(List.of(response), PageRequest.of(0, 20), 1); - when(documentService.list(eq(userId), any(), eq(false))).thenReturn(page); + when(documentService.list(eq(userId), eq(null), eq("all"), eq(null), any())) + .thenReturn(page); mockMvc.perform(get("/api/v1/documents").with(user(principal))) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.content[0].id").value(documentId.toString())); + .andExpect(jsonPath("$.data.content[0].id").value(documentId.toString())) + .andExpect(jsonPath("$.data.content[0].hasChildren").value(false)) + .andExpect(jsonPath("$.data.content[0].accessLevel").value("OWNER")); + } + + @Test + void list_rootPrivate_success_returns200() throws Exception { + DocumentResponse response = new DocumentResponse( + documentId, + "Root Private", + null, + null, + "a0", + true, + false, + DocumentAccessLevel.OWNER, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); + + Page page = new PageImpl<>(List.of(response), PageRequest.of(0, 50), 1); + when(documentService.list(eq(userId), eq("root"), eq("private"), eq(null), any())) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/documents") + .param("parentId", "root") + .param("scope", "private") + .with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].id").value(documentId.toString())) + .andExpect(jsonPath("$.data.content[0].hasChildren").value(true)) + .andExpect(jsonPath("$.data.content[0].hasCollaborators").value(false)); + } + + @Test + void list_rootShared_success_returns200() throws Exception { + DocumentResponse response = new DocumentResponse( + documentId, + "Root Shared", + null, + null, + "a0", + false, + true, + DocumentAccessLevel.EDIT, + "Bob", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); + + Page page = new PageImpl<>(List.of(response), PageRequest.of(0, 50), 1); + when(documentService.list(eq(userId), eq("root"), eq("shared"), eq(null), any())) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/documents") + .param("parentId", "root") + .param("scope", "shared") + .with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].id").value(documentId.toString())) + .andExpect(jsonPath("$.data.content[0].hasCollaborators").value(true)) + .andExpect(jsonPath("$.data.content[0].accessLevel").value("EDIT")); + } + + @Test + void list_children_success_returns200() throws Exception { + UUID parentId = UUID.randomUUID(); + DocumentResponse response = new DocumentResponse( + documentId, + "Child Doc", + null, + parentId, + "a0", + false, + false, + DocumentAccessLevel.OWNER, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); + + Page page = new PageImpl<>(List.of(response), PageRequest.of(0, 50), 1); + when(documentService.list(eq(userId), eq(parentId.toString()), eq("all"), eq(null), any())) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/documents") + .param("parentId", parentId.toString()) + .with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].parentId").value(parentId.toString())); + } + + @Test + void list_trashed_success_returns200() throws Exception { + OffsetDateTime deleted = OffsetDateTime.parse("2025-01-01T00:00:00Z"); + DocumentResponse response = new DocumentResponse( + documentId, + "Trashed", + null, + null, + null, + false, + false, + DocumentAccessLevel.OWNER, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + deleted, + deleted.plusDays(30)); + + Page page = new PageImpl<>(List.of(response), PageRequest.of(0, 20), 1); + when(documentService.list(eq(userId), eq(null), eq("all"), eq(true), any())) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/documents").param("trashed", "true").with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].deletedAt").exists()); } @Test @@ -142,7 +310,19 @@ void get_notFound_returns404() throws Exception { @Test void update_success_returns200() throws Exception { DocumentResponse response = new DocumentResponse( - documentId, "Updated", "AQID", "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + documentId, + "Updated", + "AQID", + null, + null, + false, + false, + DocumentAccessLevel.OWNER, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); when(documentService.update(eq(userId), eq(documentId), any())).thenReturn(response); @@ -168,38 +348,105 @@ void delete_success_returns204() throws Exception { } @Test - void list_trashed_success_returns200() throws Exception { - OffsetDateTime deleted = OffsetDateTime.parse("2025-01-01T00:00:00Z"); + void restore_success_returns200() throws Exception { DocumentResponse response = new DocumentResponse( documentId, - "Trashed", + "Restored", + null, + null, null, + false, + false, + DocumentAccessLevel.OWNER, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), - deleted, - deleted.plusDays(30)); + null, + null); - Page page = new PageImpl<>(List.of(response), PageRequest.of(0, 20), 1); - when(documentService.list(eq(userId), any(), eq(true))).thenReturn(page); + when(documentService.restore(eq(userId), eq(documentId))).thenReturn(response); - mockMvc.perform(get("/api/v1/documents").param("trashed", "true").with(user(principal))) + mockMvc.perform(post("/api/v1/documents/{id}/restore", documentId).with(user(principal))) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.content[0].deletedAt").exists()); + .andExpect(jsonPath("$.data.title").value("Restored")); } @Test - void restore_success_returns200() throws Exception { - DocumentResponse response = new DocumentResponse( - documentId, "Restored", null, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + void move_success_returns200() throws Exception { + DocumentResponse moved = new DocumentResponse( + documentId, + "Moved", + null, + null, + "a1", + false, + false, + DocumentAccessLevel.OWNER, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); - when(documentService.restore(eq(userId), eq(documentId))).thenReturn(response); + when(documentTreeService.move(eq(userId), eq(documentId), any(DocumentMoveRequest.class))) + .thenReturn(moved); - mockMvc.perform(post("/api/v1/documents/{id}/restore", documentId).with(user(principal))) + mockMvc.perform(post("/api/v1/documents/{id}/move", documentId) + .with(user(principal)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "newParentId": null, + "prevSiblingId": null, + "nextSiblingId": null + } + """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.title").value("Restored")); + .andExpect(jsonPath("$.data.id").value(documentId.toString())) + .andExpect(jsonPath("$.data.orderKey").value("a1")); + } + + @Test + void getBreadcrumbs_success_returns200() throws Exception { + UUID rootId = UUID.randomUUID(); + DocumentBreadcrumbResponse root = new DocumentBreadcrumbResponse(rootId, "Root Page", null, null); + DocumentBreadcrumbResponse child = new DocumentBreadcrumbResponse(documentId, "Child Page", null, rootId); + + when(documentService.getBreadcrumbs(eq(userId), eq(documentId))).thenReturn(List.of(root, child)); + + mockMvc.perform(get("/api/v1/documents/{id}/path", documentId).with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].id").value(rootId.toString())) + .andExpect(jsonPath("$.data[0].title").value("Root Page")) + .andExpect(jsonPath("$.data[1].id").value(documentId.toString())) + .andExpect(jsonPath("$.data[1].title").value("Child Page")); + } + + @Test + void getPublicBreadcrumbs_success_returns200() throws Exception { + DocumentBreadcrumbResponse item = new DocumentBreadcrumbResponse(documentId, "Public Doc", null, null); + + when(documentService.getPublicBreadcrumbs(eq(documentId))).thenReturn(List.of(item)); + + mockMvc.perform(get("/api/v1/documents/{id}/public/path", documentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].id").value(documentId.toString())) + .andExpect(jsonPath("$.data[0].title").value("Public Doc")); + } + + @Test + void retiredTreeEndpoints_return404() throws Exception { + mockMvc.perform(get("/api/v1/documents/tree/root").with(user(principal))) + .andExpect(status().isNotFound()); + mockMvc.perform(get("/api/v1/documents/tree/shared").with(user(principal))) + .andExpect(status().isNotFound()); + mockMvc.perform(get("/api/v1/documents/{id}/children", UUID.randomUUID()) + .with(user(principal))) + .andExpect(status().isNotFound()); } @Test diff --git a/api/src/test/java/com/nextdocs/api/document/controller/DocumentSharingControllerTest.java b/api/src/test/java/com/nextdocs/api/document/controller/DocumentSharingControllerTest.java index f1ab262..c2efaed 100644 --- a/api/src/test/java/com/nextdocs/api/document/controller/DocumentSharingControllerTest.java +++ b/api/src/test/java/com/nextdocs/api/document/controller/DocumentSharingControllerTest.java @@ -19,7 +19,6 @@ import com.nextdocs.api.auth.security.UserPrincipal; import com.nextdocs.api.document.dto.response.CollaboratorResponse; import com.nextdocs.api.document.dto.response.DocumentAccessResponse; -import com.nextdocs.api.document.dto.response.DocumentResponse; import com.nextdocs.api.document.dto.response.SharingSettingsResponse; import com.nextdocs.api.document.entity.DocumentAccessLevel; import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; @@ -32,9 +31,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -232,20 +228,6 @@ void updateSharingSettings_restricted_withLinkAccessLevel_returns400() throws Ex verifyNoInteractions(sharingService); } - @Test - void listSharedWithMe_success_returns200() throws Exception { - DocumentResponse doc = new DocumentResponse( - documentId, "Shared Doc", null, "Owner", OffsetDateTime.now(), OffsetDateTime.now(), null, null); - - Page page = new PageImpl<>(List.of(doc), PageRequest.of(0, 20), 1); - when(sharingService.listSharedWithMe(eq(userId), any())).thenReturn(page); - - mockMvc.perform(get("/api/v1/documents/shared-with-me").with(user(principal))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.content[0].id").value(documentId.toString())); - } - @Test void accessCheck_success_returns200() throws Exception { DocumentAccessResponse response = new DocumentAccessResponse(documentId, true, DocumentAccessLevel.EDIT, false); diff --git a/api/src/test/java/com/nextdocs/api/document/repository/UserDocumentOrderRepositoryTest.java b/api/src/test/java/com/nextdocs/api/document/repository/UserDocumentOrderRepositoryTest.java new file mode 100644 index 0000000..97b7ccc --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/repository/UserDocumentOrderRepositoryTest.java @@ -0,0 +1,108 @@ +package com.nextdocs.api.document.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.auth.repository.UserRepository; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.UserDocumentOrder; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class UserDocumentOrderRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private DocumentRepository documentRepository; + + @Autowired + private UserDocumentOrderRepository userDocumentOrderRepository; + + @Test + void findAllForReindex_includesRowsForTrashedDocuments() { + User user = userRepository.saveAndFlush( + User.builder().email("alice@example.com").displayName("Alice").build()); + + Document active = documentRepository.saveAndFlush(Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Active") + .yjsState("seed".getBytes(StandardCharsets.UTF_8)) + .build()); + + Document trashed = documentRepository.saveAndFlush(Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Trashed") + .yjsState("seed".getBytes(StandardCharsets.UTF_8)) + .deletedAt(OffsetDateTime.now()) + .build()); + + userDocumentOrderRepository.saveAndFlush(UserDocumentOrder.builder() + .user(user) + .document(active) + .orderKey("a0") + .build()); + userDocumentOrderRepository.saveAndFlush(UserDocumentOrder.builder() + .user(user) + .document(trashed) + .orderKey("a1") + .build()); + + List rows = userDocumentOrderRepository.findAllForReindex(user.getId()); + + assertThat(rows).hasSize(2); + assertThat(rows) + .extracting(o -> o.getDocument().getId()) + .containsExactlyInAnyOrder(active.getId(), trashed.getId()); + } + + @Test + void findAllForReindex_isScopedToSingleUser() { + User user = userRepository.saveAndFlush( + User.builder().email("alice@example.com").displayName("Alice").build()); + User other = userRepository.saveAndFlush( + User.builder().email("bob@example.com").displayName("Bob").build()); + + Document userDoc = documentRepository.saveAndFlush(Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Doc") + .yjsState("seed".getBytes(StandardCharsets.UTF_8)) + .build()); + Document otherDoc = documentRepository.saveAndFlush(Document.builder() + .id(UUID.randomUUID()) + .user(other) + .title("Other") + .yjsState("seed".getBytes(StandardCharsets.UTF_8)) + .build()); + + UUID orderId = userDocumentOrderRepository + .saveAndFlush(UserDocumentOrder.builder() + .user(user) + .document(userDoc) + .orderKey("a0") + .build()) + .getId(); + userDocumentOrderRepository.saveAndFlush(UserDocumentOrder.builder() + .user(other) + .document(otherDoc) + .orderKey("a0") + .build()); + + List rows = userDocumentOrderRepository.findAllForReindex(user.getId()); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getId()).isEqualTo(orderId); + } +} diff --git a/api/src/test/java/com/nextdocs/api/document/service/DocumentListQueryHelperTest.java b/api/src/test/java/com/nextdocs/api/document/service/DocumentListQueryHelperTest.java new file mode 100644 index 0000000..e3b87f5 --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentListQueryHelperTest.java @@ -0,0 +1,359 @@ +package com.nextdocs.api.document.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.common.exception.ApiException; +import com.nextdocs.api.document.config.DocumentProperties; +import com.nextdocs.api.document.dto.response.DocumentResponse; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; +import com.nextdocs.api.document.repository.DocumentRepository; +import com.nextdocs.api.document.repository.UserDocumentOrderRepository; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class DocumentListQueryHelperTest { + + @Mock + private DocumentRepository documentRepository; + + @Mock + private DocumentCollaboratorRepository collaboratorRepository; + + @Mock + private UserDocumentOrderRepository userDocumentOrderRepository; + + @Mock + private DocumentProperties documentProperties; + + @Mock + private PermissionService permissionService; + + @InjectMocks + private DocumentListQueryHelper queryHelper; + + private UUID userId; + private User user; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + user = User.builder() + .id(userId) + .email("alice@example.com") + .displayName("Alice") + .build(); + } + + @Test + void list_rootPrivate_returnsPrivateRootsWithComputedFields() { + Document root1 = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Private Root") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + PageRequest pageable = PageRequest.of(0, 50); + Page queryPage = new PageImpl<>(List.of(new Object[] {root1, "a0"})); + + when(documentRepository.findPrivateRootDocuments(userId, pageable)).thenReturn(queryPage); + when(documentRepository.countNonTrashedChildrenByParentIds(any())) + .thenReturn(List.of(new Object[] {root1.getId(), 2L})); + + Page result = queryHelper.list(userId, "root", "private", null, pageable); + + assertEquals(1, result.getContent().size()); + DocumentResponse item = result.getContent().get(0); + assertEquals("Private Root", item.title()); + assertEquals("a0", item.orderKey()); + assertTrue(item.hasChildren()); + assertFalse(item.hasCollaborators()); + assertEquals(DocumentAccessLevel.OWNER, item.accessLevel()); + } + + @Test + void list_rootShared_returnsSharedRootsWithBatchPermissions() { + User otherOwner = User.builder().id(UUID.randomUUID()).build(); + Document sharedWithMe = Document.builder() + .id(UUID.randomUUID()) + .user(otherOwner) + .title("Shared with me") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document ownerShared = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Shared by me") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + PageRequest pageable = PageRequest.of(0, 50); + Page queryPage = + new PageImpl<>(List.of(new Object[] {sharedWithMe, "a0"}, new Object[] {ownerShared, "a1"})); + + when(documentRepository.findSharedRootDocuments(userId, pageable)).thenReturn(queryPage); + when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); + when(collaboratorRepository.findDocumentIdsWithCollaborators(any())).thenReturn(List.of(ownerShared.getId())); + when(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) + .thenReturn(List.of(new Object[] {sharedWithMe.getId(), "EDIT"})); + + Page result = queryHelper.list(userId, "root", "shared", null, pageable); + + assertEquals(2, result.getContent().size()); + DocumentResponse doc1 = result.getContent().get(0); + assertEquals("Shared with me", doc1.title()); + assertEquals("a0", doc1.orderKey()); + assertFalse(doc1.hasCollaborators()); + assertEquals(DocumentAccessLevel.EDIT, doc1.accessLevel()); + + DocumentResponse doc2 = result.getContent().get(1); + assertEquals("Shared by me", doc2.title()); + assertEquals("a1", doc2.orderKey()); + assertTrue(doc2.hasCollaborators()); + assertEquals(DocumentAccessLevel.OWNER, doc2.accessLevel()); + } + + @Test + void list_flatShared_returnsUserDocumentOrderForNestedFloatedSharedDocuments() { + User otherOwner = User.builder().id(UUID.randomUUID()).build(); + UUID privateParentId = UUID.randomUUID(); + Document privateParent = Document.builder() + .id(privateParentId) + .user(otherOwner) + .title("Private Parent") + .build(); + + Document floatedChild = Document.builder() + .id(UUID.randomUUID()) + .user(otherOwner) + .title("Floated Child") + .parent(privateParent) + .siblingOrderKey("sibling-0") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + PageRequest pageable = PageRequest.of(0, 20); + when(documentRepository.findSharedWithUserId(eq(userId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(floatedChild))); + when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); + when(collaboratorRepository.findDocumentIdsWithCollaborators(any())).thenReturn(List.of()); + // Parent is inaccessible (not returned in resolveEffectiveAccessBatch for parentIds) + when(documentRepository.resolveEffectiveAccessBatch( + eq(userId), eq(floatedChild.getId().toString()))) + .thenReturn(List.of(new Object[] {floatedChild.getId(), "VIEW"})); + when(documentRepository.resolveEffectiveAccessBatch(eq(userId), eq(privateParentId.toString()))) + .thenReturn(List.of()); + when(userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(eq(userId), any())) + .thenReturn(List.of(new Object[] {floatedChild.getId(), "user-order-1"})); + + Page result = queryHelper.list(userId, null, "shared", null, pageable); + + assertEquals(1, result.getContent().size()); + DocumentResponse doc = result.getContent().get(0); + assertEquals("Floated Child", doc.title()); + assertEquals(privateParentId, doc.parentId()); + // Should return the user's personal UserDocumentOrder key, not the owner's siblingOrderKey + assertEquals("user-order-1", doc.orderKey()); + assertEquals(DocumentAccessLevel.VIEW, doc.accessLevel()); + } + + @Test + void list_flatShared_returnsNullOrderKeyForFloatedDocumentWithoutUserDocumentOrder() { + User otherOwner = User.builder().id(UUID.randomUUID()).build(); + UUID privateParentId = UUID.randomUUID(); + Document privateParent = Document.builder() + .id(privateParentId) + .user(otherOwner) + .title("Private Parent") + .build(); + + Document floatedChild = Document.builder() + .id(UUID.randomUUID()) + .user(otherOwner) + .title("Floated Child") + .parent(privateParent) + .siblingOrderKey("sibling-0") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + PageRequest pageable = PageRequest.of(0, 20); + when(documentRepository.findSharedWithUserId(eq(userId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(floatedChild))); + when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); + when(collaboratorRepository.findDocumentIdsWithCollaborators(any())).thenReturn(List.of()); + // Parent is inaccessible (not returned in resolveEffectiveAccessBatch for parentIds) + when(documentRepository.resolveEffectiveAccessBatch( + eq(userId), eq(floatedChild.getId().toString()))) + .thenReturn(List.of(new Object[] {floatedChild.getId(), "VIEW"})); + when(documentRepository.resolveEffectiveAccessBatch(eq(userId), eq(privateParentId.toString()))) + .thenReturn(List.of()); + when(userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(eq(userId), any())) + .thenReturn(List.of()); + + Page result = queryHelper.list(userId, null, "shared", null, pageable); + + assertEquals(1, result.getContent().size()); + DocumentResponse doc = result.getContent().get(0); + assertEquals("Floated Child", doc.title()); + assertEquals(privateParentId, doc.parentId()); + // Should return null (not the owner's siblingOrderKey) when no personal UserDocumentOrder exists + assertNull(doc.orderKey()); + assertEquals(DocumentAccessLevel.VIEW, doc.accessLevel()); + } + + @Test + void list_flatShared_returnsSiblingOrderKeyForChildOfAccessibleSharedParent() { + User otherOwner = User.builder().id(UUID.randomUUID()).build(); + UUID sharedParentId = UUID.randomUUID(); + Document sharedParent = Document.builder() + .id(sharedParentId) + .user(otherOwner) + .title("Shared Parent") + .build(); + + Document sharedChild = Document.builder() + .id(UUID.randomUUID()) + .user(otherOwner) + .title("Shared Child") + .parent(sharedParent) + .siblingOrderKey("sibling-0") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + PageRequest pageable = PageRequest.of(0, 20); + when(documentRepository.findSharedWithUserId(eq(userId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(sharedChild))); + when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); + when(collaboratorRepository.findDocumentIdsWithCollaborators(any())).thenReturn(List.of()); + // Parent IS accessible + when(documentRepository.resolveEffectiveAccessBatch( + eq(userId), eq(sharedChild.getId().toString()))) + .thenReturn(List.of(new Object[] {sharedChild.getId(), "VIEW"})); + when(documentRepository.resolveEffectiveAccessBatch(eq(userId), eq(sharedParentId.toString()))) + .thenReturn(List.of(new Object[] {sharedParentId, "VIEW"})); + + Page result = queryHelper.list(userId, null, "shared", null, pageable); + + assertEquals(1, result.getContent().size()); + DocumentResponse doc = result.getContent().get(0); + assertEquals("Shared Child", doc.title()); + assertEquals(sharedParentId, doc.parentId()); + // Should keep siblingOrderKey since its parent is accessible/shared + assertEquals("sibling-0", doc.orderKey()); + } + + @Test + void list_emptyPagePreservesTotalElements() { + PageRequest pageable = PageRequest.of(2, 10); + Page emptyPageWithTotals = new PageImpl<>(List.of(), pageable, 25); + + when(documentRepository.findPrivateRootDocuments(userId, pageable)).thenReturn(emptyPageWithTotals); + + Page result = queryHelper.list(userId, "root", "private", null, pageable); + + assertTrue(result.getContent().isEmpty()); + assertEquals(25, result.getTotalElements()); + assertEquals(3, result.getTotalPages()); + } + + @Test + void list_children_returnsOrderedChildDocuments() { + UUID parentId = UUID.randomUUID(); + Document child1 = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Child 1") + .siblingOrderKey("a0") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + PageRequest pageable = PageRequest.of(0, 50); + Page childPage = new PageImpl<>(List.of(child1)); + + when(documentRepository.findAllByParent_IdAndDeletedAtIsNull(eq(parentId), any(Pageable.class))) + .thenReturn(childPage); + when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); + when(collaboratorRepository.findDocumentIdsWithCollaborators(any())).thenReturn(List.of()); + + Page result = queryHelper.list(userId, parentId.toString(), "all", null, pageable); + + assertEquals(1, result.getContent().size()); + DocumentResponse item = result.getContent().get(0); + assertEquals("Child 1", item.title()); + assertEquals(parentId, item.parentId()); + assertEquals("a0", item.orderKey()); + assertFalse(item.hasChildren()); + assertFalse(item.hasCollaborators()); + assertEquals(DocumentAccessLevel.OWNER, item.accessLevel()); + } + + @Test + void list_childrenWithInvalidScope_throwsValidationFailed() { + UUID parentId = UUID.randomUUID(); + assertThrows( + ApiException.class, + () -> queryHelper.list(userId, parentId.toString(), "shared", null, PageRequest.of(0, 20))); + } + + @Test + void list_childrenWithInvalidParentIdFormat_throwsValidationFailed() { + assertThrows( + ApiException.class, () -> queryHelper.list(userId, "invalid-uuid", "all", null, PageRequest.of(0, 20))); + } + + @Test + void list_trashed_returnsAccessibleTrashedDocuments() { + Document trashedDoc = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Trashed Doc") + .deletedAt(OffsetDateTime.now().minusDays(2)) + .createdAt(OffsetDateTime.now().minusDays(10)) + .updatedAt(OffsetDateTime.now().minusDays(2)) + .build(); + + PageRequest pageable = PageRequest.of(0, 20); + when(documentProperties.getTrashRetentionDays()).thenReturn(30); + when(documentRepository.findAccessibleTrashedDocuments(eq(userId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(trashedDoc))); + when(collaboratorRepository.findDocumentIdsWithCollaborators(any())).thenReturn(List.of()); + when(documentRepository.resolveTrashAccessBatch(eq(userId), anyString())) + .thenReturn(List.of(new Object[] {trashedDoc.getId(), "OWNER"})); + + Page result = queryHelper.list(userId, null, null, true, pageable); + + assertEquals(1, result.getContent().size()); + DocumentResponse item = result.getContent().get(0); + assertEquals("Trashed Doc", item.title()); + assertNotNull(item.deletedAt()); + assertNotNull(item.purgeAt()); + assertEquals(DocumentAccessLevel.OWNER, item.accessLevel()); + } +} diff --git a/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java b/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java index dacc901..10415fe 100644 --- a/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java @@ -1,10 +1,17 @@ package com.nextdocs.api.document.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -15,15 +22,20 @@ import com.nextdocs.api.document.config.DocumentProperties; import com.nextdocs.api.document.dto.request.DocumentCreateRequest; import com.nextdocs.api.document.dto.request.DocumentUpdateRequest; +import com.nextdocs.api.document.dto.response.DocumentBreadcrumbResponse; +import com.nextdocs.api.document.dto.response.DocumentResponse; import com.nextdocs.api.document.entity.Document; import com.nextdocs.api.document.entity.DocumentAccessLevel; import com.nextdocs.api.document.entity.DocumentCollaborator; import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import com.nextdocs.api.document.entity.UserDocumentOrder; import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; import com.nextdocs.api.document.repository.DocumentRepository; +import com.nextdocs.api.document.repository.UserDocumentOrderRepository; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -32,6 +44,9 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; @ExtendWith(MockitoExtension.class) class DocumentServiceTest { @@ -45,6 +60,15 @@ class DocumentServiceTest { @Mock private DocumentCollaboratorRepository collaboratorRepository; + @Mock + private UserDocumentOrderRepository userDocumentOrderRepository; + + @Mock + private PermissionService permissionService; + + @Mock + private DocumentListQueryHelper queryHelper; + private DocumentProperties documentProperties; private DocumentService documentService; @@ -53,8 +77,14 @@ class DocumentServiceTest { void setUp() { documentProperties = new DocumentProperties(); documentProperties.setTrashRetentionDays(30); - documentService = - new DocumentService(documentRepository, collaboratorRepository, userRepository, documentProperties); + documentService = new DocumentService( + documentRepository, + collaboratorRepository, + userDocumentOrderRepository, + userRepository, + documentProperties, + permissionService, + queryHelper); } @Test @@ -79,18 +109,47 @@ void create_persistsClientProvidedId() { .email("alice@example.com") .displayName("Alice") .build(); - DocumentCreateRequest request = new DocumentCreateRequest(documentId, "My Doc", "AQID", "Alice"); + DocumentCreateRequest request = + new DocumentCreateRequest(documentId, "My Doc", "AQID", "Alice", null, null, null); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(documentRepository.findByIdAndUser_Id(documentId, userId)).thenReturn(Optional.empty()); + when(documentRepository.findById(documentId)).thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(userId, documentId)) + .thenReturn(Optional.empty()); when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); DocumentService.CreateDocumentResult result = documentService.create(userId, request); assertTrue(result.created()); assertEquals(documentId, result.document().id()); - verify(documentRepository).findByIdAndUser_Id(documentId, userId); + verify(documentRepository).findById(documentId); verify(documentRepository).saveAndFlush(any(Document.class)); + verify(userDocumentOrderRepository).saveAndFlush(any(UserDocumentOrder.class)); + } + + @Test + void create_placesNewRootDocumentFirstInUserDocumentOrder() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + User user = User.builder() + .id(userId) + .email("alice@example.com") + .displayName("Alice") + .build(); + DocumentCreateRequest request = + new DocumentCreateRequest(documentId, "My Doc", "AQID", "Alice", null, null, null); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(documentRepository.findById(documentId)).thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(userId, documentId)) + .thenReturn(Optional.of("a5")); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + documentService.create(userId, request); + + ArgumentCaptor savedOrderCaptor = ArgumentCaptor.forClass(UserDocumentOrder.class); + verify(userDocumentOrderRepository).saveAndFlush(savedOrderCaptor.capture()); + assertTrue(savedOrderCaptor.getValue().getOrderKey().compareTo("a5") < 0); } @Test @@ -111,10 +170,11 @@ void create_returnsExistingDocumentForMatchingClientProvidedId() { .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) .build(); - DocumentCreateRequest request = new DocumentCreateRequest(documentId, "My Doc", "AQID", "Alice"); + DocumentCreateRequest request = + new DocumentCreateRequest(documentId, "My Doc", "AQID", "Alice", null, null, null); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(documentRepository.findByIdAndUser_Id(documentId, userId)).thenReturn(Optional.of(existing)); + when(documentRepository.findById(documentId)).thenReturn(Optional.of(existing)); DocumentService.CreateDocumentResult result = documentService.create(userId, request); @@ -139,10 +199,11 @@ void create_rejectsTrashedDocumentIdReuse() { .yjsState(new byte[] {1, 2, 3}) .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) .build(); - DocumentCreateRequest request = new DocumentCreateRequest(documentId, "My Doc", "AQID", "Alice"); + DocumentCreateRequest request = + new DocumentCreateRequest(documentId, "My Doc", "AQID", "Alice", null, null, null); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(documentRepository.findByIdAndUser_Id(documentId, userId)).thenReturn(Optional.of(existing)); + when(documentRepository.findById(documentId)).thenReturn(Optional.of(existing)); ApiException exception = assertThrows(ApiException.class, () -> documentService.create(userId, request)); @@ -150,15 +211,75 @@ void create_rejectsTrashedDocumentIdReuse() { verify(documentRepository, never()).saveAndFlush(any(Document.class)); } + @Test + void create_retriesOnOrderKeyCollision() { + UUID userId = UUID.randomUUID(); + User user = User.builder() + .id(userId) + .email("alice@example.com") + .displayName("Alice") + .build(); + DocumentCreateRequest request = new DocumentCreateRequest(null, "My Doc", "AQID", "Alice", null, null, null); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(eq(userId), any())) + .thenReturn(Optional.of("a5"), Optional.of("a4")); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenThrow(new DataIntegrityViolationException("order_key unique violation")) + .thenAnswer(invocation -> invocation.getArgument(0)); + + DocumentService.CreateDocumentResult result = documentService.create(userId, request); + + assertTrue(result.created()); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDocumentOrder.class); + verify(userDocumentOrderRepository, times(2)).saveAndFlush(captor.capture()); + List saved = captor.getAllValues(); + assertEquals(2, saved.size()); + assertTrue(saved.get(1).getOrderKey().compareTo("a4") < 0); + } + + @Test + void restore_regeneratesInvalidUserDocumentOrder() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + User user = User.builder() + .id(userId) + .email("alice@example.com") + .displayName("Alice") + .build(); + Document trashed = Document.builder() + .id(documentId) + .user(user) + .title("Trashed") + .yjsState(new byte[] {1}) + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(permissionService.requireTrashEditAccess(userId, documentId)).thenReturn(trashed); + when(userDocumentOrderRepository.findMaxOrderKeyByUserId(userId, documentId)) + .thenReturn(Optional.of("a5")); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, documentId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, documentId)) + .thenReturn(Optional.of("a6")); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + DocumentResponse response = documentService.restore(userId, documentId); + + assertEquals("a6", response.orderKey()); + assertNull(response.deletedAt()); + verify(documentRepository).saveAndFlush(any(Document.class)); + verify(userDocumentOrderRepository).saveAndFlush(any(UserDocumentOrder.class)); + } + @Test void get_allowsGeneralAccessWhenActiveLinkExists() { UUID requesterId = UUID.randomUUID(); UUID documentId = UUID.randomUUID(); Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); - when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); - when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) - .thenReturn(Optional.empty()); + when(permissionService.requireReadAccess(requesterId, documentId)).thenReturn(document); var response = documentService.get(requesterId, documentId, false); @@ -166,21 +287,147 @@ void get_allowsGeneralAccessWhenActiveLinkExists() { assertEquals("Shared doc", response.title()); } + @Test + void get_floatedSharedDocument_returnsUserDocumentOrderKey() { + UUID requesterId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + UUID privateParentId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document privateParent = + Document.builder().id(privateParentId).user(owner).build(); + Document document = Document.builder() + .id(documentId) + .user(owner) + .title("Floated Doc") + .parent(privateParent) + .siblingOrderKey("owner-sibling-key") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + when(permissionService.requireReadAccess(requesterId, documentId)).thenReturn(document); + // Parent is inaccessible to requester + when(permissionService.resolveAccess(requesterId, privateParentId)).thenReturn(null); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(requesterId, documentId)) + .thenReturn(Optional.of("user-order-key-1")); + + var response = documentService.get(requesterId, documentId, false); + + assertEquals(documentId, response.id()); + assertEquals("user-order-key-1", response.orderKey()); + } + + @Test + void get_floatedSharedDocument_withoutUserDocumentOrder_returnsNullOrderKey() { + UUID requesterId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + UUID privateParentId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document privateParent = + Document.builder().id(privateParentId).user(owner).build(); + Document document = Document.builder() + .id(documentId) + .user(owner) + .title("Floated Doc") + .parent(privateParent) + .siblingOrderKey("owner-sibling-key") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + when(permissionService.requireReadAccess(requesterId, documentId)).thenReturn(document); + // Parent is inaccessible to requester + when(permissionService.resolveAccess(requesterId, privateParentId)).thenReturn(null); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(requesterId, documentId)) + .thenReturn(Optional.empty()); + + var response = documentService.get(requesterId, documentId, false); + + assertEquals(documentId, response.id()); + assertNull(response.orderKey()); + } + + @Test + void get_nestedSharedDocumentUnderAccessibleParent_returnsSiblingOrderKey() { + UUID requesterId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + UUID sharedParentId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document sharedParent = + Document.builder().id(sharedParentId).user(owner).build(); + Document document = Document.builder() + .id(documentId) + .user(owner) + .title("Nested Doc") + .parent(sharedParent) + .siblingOrderKey("sibling-key-1") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + when(permissionService.requireReadAccess(requesterId, documentId)).thenReturn(document); + // Parent IS accessible to requester + when(permissionService.resolveAccess(requesterId, sharedParentId)).thenReturn(DocumentAccessLevel.VIEW); + + var response = documentService.get(requesterId, documentId, false); + + assertEquals(documentId, response.id()); + assertEquals("sibling-key-1", response.orderKey()); + } + + @Test + void get_includeTrashed_childOfTrashedAccessibleParent_resolvesParentTrashAccessAndReturnsSiblingKey() { + UUID requesterId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + UUID sharedParentId = UUID.randomUUID(); + OffsetDateTime deletedAt = OffsetDateTime.now(ZoneOffset.UTC); + User owner = User.builder().id(ownerId).build(); + Document sharedParent = Document.builder() + .id(sharedParentId) + .user(owner) + .deletedAt(deletedAt) + .build(); + Document trashedChild = Document.builder() + .id(documentId) + .user(owner) + .title("Trashed Child") + .parent(sharedParent) + .siblingOrderKey("sibling-key-2") + .deletedAt(deletedAt) + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(trashedChild)); + when(permissionService.resolveTrashAccess(requesterId, documentId)).thenReturn(DocumentAccessLevel.VIEW); + when(permissionService.resolveTrashAccess(requesterId, sharedParentId)).thenReturn(DocumentAccessLevel.VIEW); + + var response = documentService.get(requesterId, documentId, true); + + assertEquals(documentId, response.id()); + assertEquals("sibling-key-2", response.orderKey()); + assertEquals(DocumentAccessLevel.VIEW, response.accessLevel()); + } + @Test void update_allowsEditWhenGeneralAccessIsEdit() { UUID requesterId = UUID.randomUUID(); UUID documentId = UUID.randomUUID(); Document document = createSharedDocument(documentId, DocumentAccessLevel.EDIT); - when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); - when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) - .thenReturn(Optional.empty()); + when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); + when(permissionService.resolveAccess(requesterId, documentId)).thenReturn(DocumentAccessLevel.EDIT); when(documentRepository.save(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); var response = documentService.update(requesterId, documentId, new DocumentUpdateRequest("Updated title", null, null)); assertEquals("Updated title", response.title()); + verify(permissionService, atLeastOnce()).resolveAccess(requesterId, documentId); } @Test @@ -189,9 +436,8 @@ void update_returnsForbiddenWhenGeneralAccessIsReadOnly() { UUID documentId = UUID.randomUUID(); Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); - when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); - when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) - .thenReturn(Optional.empty()); + when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); + when(permissionService.resolveAccess(requesterId, documentId)).thenReturn(DocumentAccessLevel.VIEW); ApiException exception = assertThrows( ApiException.class, @@ -203,80 +449,1231 @@ void update_returnsForbiddenWhenGeneralAccessIsReadOnly() { } @Test - void update_prefersCollaboratorReadOnlyOverGeneralEditAccess() { - UUID requesterId = UUID.randomUUID(); - UUID documentId = UUID.randomUUID(); - Document document = createSharedDocument(documentId, DocumentAccessLevel.EDIT); + void list_usesBatchOrderKeyLookupForRootDocuments() { + UUID userId = UUID.randomUUID(); + User user = User.builder() + .id(userId) + .email("alice@example.com") + .displayName("Alice") + .build(); - DocumentCollaborator collaborator = DocumentCollaborator.builder() - .document(document) - .user(User.builder() - .id(requesterId) - .email("viewer@example.com") - .displayName("Viewer") - .build()) - .accessLevel(DocumentAccessLevel.VIEW) + Document root1 = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Root 1") + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + Document root2 = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Root 2") + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + Document parent = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Parent") + .build(); + Document child = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Child") + .parent(parent) + .siblingOrderKey("b5") + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) .build(); - when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); - when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) - .thenReturn(Optional.of(collaborator)); + Page page = new PageImpl<>(List.of()); + when(queryHelper.list(eq(userId), eq(null), eq("all"), eq(false), any())) + .thenReturn(page); - ApiException exception = assertThrows( - ApiException.class, - () -> documentService.update( - requesterId, documentId, new DocumentUpdateRequest("Updated title", null, null))); + Page result = documentService.list(userId, null, "all", false, null); - assertEquals(ErrorCode.FORBIDDEN, exception.getErrorCode()); - verify(documentRepository, never()).save(any(Document.class)); + assertEquals(0, result.getContent().size()); + verify(queryHelper).list(eq(userId), eq(null), eq("all"), eq(false), any()); } @Test - void get_allowsCollaboratorAccessWhenIncludeTrashedIsTrue() { + void delete_softDelete_withEditAccess_setsDeletedAtAndSaves() { UUID requesterId = UUID.randomUUID(); UUID documentId = UUID.randomUUID(); - Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); + Document document = Document.builder() + .id(documentId) + .title("Child doc") + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); - when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); - when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) - .thenReturn(Optional.empty()); // link access allows VIEW + when(permissionService.requireEditAccess(requesterId, documentId)).thenReturn(document); + when(documentRepository.save(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); - var response = documentService.get(requesterId, documentId, true); + documentService.delete(requesterId, documentId, false); - assertEquals(documentId, response.id()); - assertEquals("Shared doc", response.title()); + verify(permissionService).requireEditAccess(requesterId, documentId); + verify(userDocumentOrderRepository).deleteByDocument_Id(documentId); + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + verify(documentRepository).save(captor.capture()); + assertTrue(captor.getValue().getDeletedAt() != null); } @Test - void get_rejectsCollaboratorAccessWhenDocumentIsTrashedAndIncludeTrashedIsTrue() { + void delete_softDelete_withViewOnlyAccess_throwsForbidden() { UUID requesterId = UUID.randomUUID(); UUID documentId = UUID.randomUUID(); - Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); - document.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC)); - when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); + doThrow(new ApiException(ErrorCode.FORBIDDEN)) + .when(permissionService) + .requireEditAccess(requesterId, documentId); ApiException exception = - assertThrows(ApiException.class, () -> documentService.get(requesterId, documentId, true)); + assertThrows(ApiException.class, () -> documentService.delete(requesterId, documentId, false)); - assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + assertEquals(ErrorCode.FORBIDDEN, exception.getErrorCode()); + verify(documentRepository, never()).save(any()); } @Test - void get_rejectsAccessWhenCollaboratorHasNoAccessAndIncludeTrashedIsTrue() { + void delete_softDelete_withNoAccess_throwsNotFound() { UUID requesterId = UUID.randomUUID(); UUID documentId = UUID.randomUUID(); - Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); - document.setGeneralAccessMode(DocumentGeneralAccessMode.RESTRICTED); - when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); - when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) - .thenReturn(Optional.empty()); + doThrow(new ApiException(ErrorCode.NOT_FOUND)) + .when(permissionService) + .requireEditAccess(requesterId, documentId); ApiException exception = - assertThrows(ApiException.class, () -> documentService.get(requesterId, documentId, true)); + assertThrows(ApiException.class, () -> documentService.delete(requesterId, documentId, false)); assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + verify(documentRepository, never()).save(any()); + } + + @Test + void delete_permanentDelete_inTrash_deletesDocument() { + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document trashedDoc = Document.builder() + .id(documentId) + .user(owner) + .title("Trashed doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(permissionService.requireTrashEditAccess(ownerId, documentId)).thenReturn(trashedDoc); + + documentService.delete(ownerId, documentId, true); + + verify(userDocumentOrderRepository).deleteByDocument_Id(documentId); + verify(documentRepository).delete(trashedDoc); + } + + @Test + void delete_permanentDelete_notInTrash_throwsValidationFailed() { + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document activeDoc = Document.builder() + .id(documentId) + .user(owner) + .title("Active doc") + .deletedAt(null) + .build(); + + when(permissionService.requireTrashEditAccess(ownerId, documentId)).thenReturn(activeDoc); + + ApiException exception = + assertThrows(ApiException.class, () -> documentService.delete(ownerId, documentId, true)); + + assertEquals(ErrorCode.VALIDATION_FAILED, exception.getErrorCode()); + verify(documentRepository, never()).delete(any()); + } + + @Test + void create_nestedDocUnderForeignParent_assignsParentOwner() { + UUID creatorId = UUID.randomUUID(); + UUID hostOwnerId = UUID.randomUUID(); + UUID parentId = UUID.randomUUID(); + User host = User.builder().id(hostOwnerId).build(); + Document parent = Document.builder().id(parentId).user(host).build(); + DocumentCreateRequest request = + new DocumentCreateRequest(null, "Nested", "AQID", "Jerry", parentId, null, null); + + when(userRepository.findById(creatorId)) + .thenReturn(Optional.of(User.builder().id(creatorId).build())); + when(permissionService.requireEditAccess(creatorId, parentId)).thenReturn(parent); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + DocumentService.CreateDocumentResult result = documentService.create(creatorId, request); + + assertTrue(result.created()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + verify(documentRepository).saveAndFlush(captor.capture()); + // Location authority: the nested doc belongs to the host tree's owner, not the creator. + assertEquals(hostOwnerId, captor.getValue().getUser().getId()); + // Creator attribution is preserved separately. + assertEquals("Jerry", captor.getValue().getCreatedBy()); + // Nested docs get no personal navigation row. + verify(userDocumentOrderRepository, never()).saveAndFlush(any(UserDocumentOrder.class)); + } + + @Test + void create_clientIdBelongsToForeignDocWithAccess_returnsExisting() { + UUID creatorId = UUID.randomUUID(); + UUID hostOwnerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document existing = Document.builder() + .id(documentId) + .user(User.builder().id(hostOwnerId).build()) + .title("Hosted") + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + DocumentCreateRequest request = + new DocumentCreateRequest(documentId, "Retry", "AQID", "Jerry", null, null, null); + + when(userRepository.findById(creatorId)) + .thenReturn(Optional.of(User.builder().id(creatorId).build())); + when(documentRepository.findById(documentId)).thenReturn(Optional.of(existing)); + when(permissionService.resolveAccess(creatorId, documentId)).thenReturn(DocumentAccessLevel.EDIT); + + DocumentService.CreateDocumentResult result = documentService.create(creatorId, request); + + assertFalse(result.created()); + assertEquals(documentId, result.document().id()); + verify(documentRepository, never()).saveAndFlush(any(Document.class)); + } + + @Test + void create_clientIdBelongsToInaccessibleDoc_throwsConflict() { + UUID strangerId = UUID.randomUUID(); + UUID hostOwnerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document existing = Document.builder() + .id(documentId) + .user(User.builder().id(hostOwnerId).build()) + .title("Hosted") + .build(); + DocumentCreateRequest request = + new DocumentCreateRequest(documentId, "Squat", "AQID", "Stranger", null, null, null); + + when(userRepository.findById(strangerId)) + .thenReturn(Optional.of(User.builder().id(strangerId).build())); + when(documentRepository.findById(documentId)).thenReturn(Optional.of(existing)); + when(permissionService.resolveAccess(strangerId, documentId)).thenReturn(null); + + ApiException exception = assertThrows(ApiException.class, () -> documentService.create(strangerId, request)); + + assertEquals(ErrorCode.CONFLICT, exception.getErrorCode()); + } + + @Test + void create_nestedDocWithIdenticalSiblingKeys_throwsConflict() { + UUID userId = UUID.randomUUID(); + UUID parentId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + UUID nextSiblingId = UUID.randomUUID(); + User user = User.builder().id(userId).build(); + Document parent = Document.builder().id(parentId).user(user).build(); + Document prevSibling = Document.builder() + .id(prevSiblingId) + .parent(parent) + .siblingOrderKey("a0") + .build(); + Document nextSibling = Document.builder() + .id(nextSiblingId) + .parent(parent) + .siblingOrderKey("a0") + .build(); + DocumentCreateRequest request = + new DocumentCreateRequest(null, "Child", "AQID", "Alice", parentId, prevSiblingId, nextSiblingId); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(permissionService.requireEditAccess(userId, parentId)).thenReturn(parent); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(prevSibling)); + when(documentRepository.findByIdAndDeletedAtIsNull(nextSiblingId)).thenReturn(Optional.of(nextSibling)); + + ApiException ex = assertThrows(ApiException.class, () -> documentService.create(userId, request)); + assertEquals(ErrorCode.CONFLICT, ex.getErrorCode()); + } + + @Test + void create_rootDocWithIdenticalSiblingKeys_throwsConflict() { + UUID userId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + UUID nextSiblingId = UUID.randomUUID(); + User user = User.builder().id(userId).build(); + DocumentCreateRequest request = + new DocumentCreateRequest(null, "Root Doc", "AQID", "Alice", null, prevSiblingId, nextSiblingId); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)) + .thenReturn(Optional.of(Document.builder().id(prevSiblingId).build())); + when(documentRepository.findByIdAndDeletedAtIsNull(nextSiblingId)) + .thenReturn(Optional.of(Document.builder().id(nextSiblingId).build())); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId)) + .thenReturn(Optional.of("a0")); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, nextSiblingId)) + .thenReturn(Optional.of("a0")); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + ApiException ex = assertThrows(ApiException.class, () -> documentService.create(userId, request)); + assertEquals(ErrorCode.CONFLICT, ex.getErrorCode()); + } + + @Test + void create_rootDocWithNestedPrevSibling_throwsValidationFailed() { + UUID userId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + User user = User.builder().id(userId).build(); + DocumentCreateRequest request = + new DocumentCreateRequest(null, "Root Doc", "AQID", "Alice", null, prevSiblingId, null); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)) + .thenReturn(Optional.of(Document.builder().id(prevSiblingId).build())); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId)) + .thenReturn(Optional.empty()); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + ApiException ex = assertThrows(ApiException.class, () -> documentService.create(userId, request)); + assertEquals(ErrorCode.VALIDATION_FAILED, ex.getErrorCode()); + assertEquals("sibling does not belong to root navigation", ex.getMessage()); + } + + @Test + void create_rootDocWithNestedNextSibling_throwsValidationFailed() { + UUID userId = UUID.randomUUID(); + UUID nextSiblingId = UUID.randomUUID(); + User user = User.builder().id(userId).build(); + DocumentCreateRequest request = + new DocumentCreateRequest(null, "Root Doc", "AQID", "Alice", null, null, nextSiblingId); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(documentRepository.findByIdAndDeletedAtIsNull(nextSiblingId)) + .thenReturn(Optional.of(Document.builder().id(nextSiblingId).build())); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, nextSiblingId)) + .thenReturn(Optional.empty()); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + ApiException ex = assertThrows(ApiException.class, () -> documentService.create(userId, request)); + assertEquals(ErrorCode.VALIDATION_FAILED, ex.getErrorCode()); + assertEquals("sibling does not belong to root navigation", ex.getMessage()); + } + + @Test + void delete_permanentDelete_inTrashRoot_permanentlyDeletesParentAndAllDescendants() { + UUID ownerId = UUID.randomUUID(); + UUID parentDocId = UUID.randomUUID(); + UUID childDocId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document parentDoc = Document.builder() + .id(parentDocId) + .user(owner) + .title("Parent Doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + Document childDoc = Document.builder() + .id(childDocId) + .user(owner) + .parent(parentDoc) + .siblingOrderKey("a5") + .title("Child Doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(permissionService.requireTrashEditAccess(ownerId, parentDocId)).thenReturn(parentDoc); + when(documentRepository.findAllByParent_IdIn(List.of(parentDocId))).thenReturn(List.of(childDoc)); + when(documentRepository.findAllByParent_IdIn(List.of(childDocId))).thenReturn(List.of()); + + documentService.delete(ownerId, parentDocId, true); + + verify(collaboratorRepository).deleteByDocument_Id(childDocId); + verify(userDocumentOrderRepository).deleteByDocument_Id(childDocId); + verify(documentRepository).delete(childDoc); + + verify(collaboratorRepository).deleteByDocument_Id(parentDocId); + verify(userDocumentOrderRepository).deleteByDocument_Id(parentDocId); + verify(documentRepository).delete(parentDoc); + } + + @Test + void delete_permanentDelete_childOfTrashedParent_throwsValidationFailed() { + UUID ownerId = UUID.randomUUID(); + UUID parentDocId = UUID.randomUUID(); + UUID childDocId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document parentDoc = Document.builder() + .id(parentDocId) + .user(owner) + .title("Parent Doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + Document childDoc = Document.builder() + .id(childDocId) + .user(owner) + .parent(parentDoc) + .title("Child Doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(permissionService.requireTrashEditAccess(ownerId, childDocId)).thenReturn(childDoc); + + ApiException ex = assertThrows(ApiException.class, () -> documentService.delete(ownerId, childDocId, true)); + assertEquals(ErrorCode.VALIDATION_FAILED, ex.getErrorCode()); + assertEquals( + "Cannot permanently delete a child of a trashed document directly. Delete the parent document instead.", + ex.getMessage()); + } + + @Test + void delete_softDelete_cascadesToAllDescendants() { + UUID ownerId = UUID.randomUUID(); + UUID parentDocId = UUID.randomUUID(); + UUID childDocId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document parentDoc = Document.builder() + .id(parentDocId) + .user(owner) + .title("Parent Doc") + .build(); + Document childDoc = Document.builder() + .id(childDocId) + .user(owner) + .parent(parentDoc) + .siblingOrderKey("a5") + .title("Child Doc") + .build(); + + when(permissionService.requireEditAccess(ownerId, parentDocId)).thenReturn(parentDoc); + when(documentRepository.findAllByParent_IdIn(List.of(parentDocId))).thenReturn(List.of(childDoc)); + when(documentRepository.findAllByParent_IdIn(List.of(childDocId))).thenReturn(List.of()); + + documentService.delete(ownerId, parentDocId, false); + + assertNotNull(parentDoc.getDeletedAt()); + assertNotNull(childDoc.getDeletedAt()); + verify(userDocumentOrderRepository).deleteByDocument_Id(parentDocId); + verify(userDocumentOrderRepository).deleteByDocument_Id(childDocId); + verify(documentRepository).save(parentDoc); + verify(documentRepository).save(childDoc); + } + + @Test + void delete_softDelete_preservesDeletedAtForAlreadyTrashedDescendants() { + UUID ownerId = UUID.randomUUID(); + UUID parentDocId = UUID.randomUUID(); + UUID alreadyTrashedChildId = UUID.randomUUID(); + UUID activeChildId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + OffsetDateTime originalDeletedAt = OffsetDateTime.of(2025, 1, 1, 10, 0, 0, 0, ZoneOffset.UTC); + + Document parentDoc = Document.builder() + .id(parentDocId) + .user(owner) + .title("Parent Doc") + .build(); + Document alreadyTrashedChild = Document.builder() + .id(alreadyTrashedChildId) + .user(owner) + .parent(parentDoc) + .title("Already Trashed Child") + .deletedAt(originalDeletedAt) + .build(); + Document activeChild = Document.builder() + .id(activeChildId) + .user(owner) + .parent(parentDoc) + .title("Active Child") + .build(); + + when(permissionService.requireEditAccess(ownerId, parentDocId)).thenReturn(parentDoc); + when(documentRepository.findAllByParent_IdIn(List.of(parentDocId))) + .thenReturn(List.of(alreadyTrashedChild, activeChild)); + when(documentRepository.findAllByParent_IdIn(List.of(alreadyTrashedChildId, activeChildId))) + .thenReturn(List.of()); + + documentService.delete(ownerId, parentDocId, false); + + assertNotNull(parentDoc.getDeletedAt()); + assertNotNull(activeChild.getDeletedAt()); + assertEquals(originalDeletedAt, alreadyTrashedChild.getDeletedAt()); + verify(userDocumentOrderRepository).deleteByDocument_Id(parentDocId); + verify(userDocumentOrderRepository).deleteByDocument_Id(activeChildId); + verify(userDocumentOrderRepository, never()).deleteByDocument_Id(alreadyTrashedChildId); + verify(documentRepository).save(parentDoc); + verify(documentRepository).save(activeChild); + verify(documentRepository, never()).save(alreadyTrashedChild); + } + + @Test + void restore_childOfTrashedParent_throwsValidationFailed() { + UUID ownerId = UUID.randomUUID(); + UUID parentDocId = UUID.randomUUID(); + UUID childDocId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document parentDoc = Document.builder() + .id(parentDocId) + .user(owner) + .title("Parent Doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + Document childDoc = Document.builder() + .id(childDocId) + .user(owner) + .parent(parentDoc) + .title("Child Doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(permissionService.requireTrashEditAccess(ownerId, childDocId)).thenReturn(childDoc); + + ApiException ex = + assertThrows(ApiException.class, () -> documentService.restoreAndPersist(ownerId, childDocId, false)); + assertEquals(ErrorCode.VALIDATION_FAILED, ex.getErrorCode()); + assertEquals( + "Cannot restore a child of a trashed document directly. Restore the parent document instead.", + ex.getMessage()); + } + + @Test + void restore_trashRoot_restoresParentAndAllDescendants() { + UUID ownerId = UUID.randomUUID(); + UUID parentDocId = UUID.randomUUID(); + UUID childDocId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document parentDoc = Document.builder() + .id(parentDocId) + .user(owner) + .title("Parent Doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + Document childDoc = Document.builder() + .id(childDocId) + .user(owner) + .parent(parentDoc) + .siblingOrderKey("a5") + .title("Child Doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(permissionService.requireTrashEditAccess(ownerId, parentDocId)).thenReturn(parentDoc); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(ownerId, parentDocId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findMaxOrderKeyByUserId(ownerId, parentDocId)) + .thenReturn(Optional.of("a0")); + when(collaboratorRepository.findAllByDocument_Id(parentDocId)).thenReturn(List.of()); + when(documentRepository.saveAndFlush(parentDoc)).thenReturn(parentDoc); + when(documentRepository.findAllByParent_IdIn(List.of(parentDocId))).thenReturn(List.of(childDoc)); + when(documentRepository.findAllByParent_IdIn(List.of(childDocId))).thenReturn(List.of()); + + DocumentResponse response = documentService.restoreAndPersist(ownerId, parentDocId, false); + + assertNull(parentDoc.getDeletedAt()); + assertNull(childDoc.getDeletedAt()); + verify(userDocumentOrderRepository).saveAndFlush(any(UserDocumentOrder.class)); + verify(documentRepository).saveAndFlush(parentDoc); + verify(documentRepository).saveAndFlush(childDoc); + assertEquals(parentDocId, response.id()); + } + + @Test + void create_orderKeyCollisionWithExplicitId_retriesAndSucceeds() { + UUID userId = UUID.randomUUID(); + User user = User.builder().id(userId).build(); + UUID explicitId = UUID.randomUUID(); + DocumentCreateRequest request = + new DocumentCreateRequest(explicitId, "Retried Title", "eWFz", "Anonymous", null, null, null); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(userId, explicitId)) + .thenReturn(Optional.of("a0")); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // First attempt throws DataIntegrityViolationException on saveAndFlush + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenThrow(new DataIntegrityViolationException("unique violation")) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // Because transaction rolled back, findById returns empty, allowing retry + when(documentRepository.findById(explicitId)).thenReturn(Optional.empty()); + + DocumentService.CreateDocumentResult result = documentService.create(userId, request); + + assertTrue(result.created()); + assertEquals("Retried Title", result.document().title()); + assertEquals(explicitId, result.document().id()); + verify(userDocumentOrderRepository, times(2)).saveAndFlush(any(UserDocumentOrder.class)); + } + + @Test + void restore_withEditAccess_restoresTrashedDocument() { + UUID ownerId = UUID.randomUUID(); + UUID editorId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document trashed = Document.builder() + .id(documentId) + .user(User.builder().id(ownerId).build()) + .title("Shared Trashed") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(permissionService.requireTrashEditAccess(editorId, documentId)).thenReturn(trashed); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(ownerId, documentId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findMaxOrderKeyByUserId(ownerId, documentId)) + .thenReturn(Optional.empty()); + when(collaboratorRepository.findAllByDocument_Id(documentId)).thenReturn(List.of()); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + DocumentResponse response = documentService.restoreAndPersist(editorId, documentId, false); + + assertNull(response.deletedAt()); + assertNull(trashed.getDeletedAt()); + ArgumentCaptor udoCaptor = ArgumentCaptor.forClass(UserDocumentOrder.class); + verify(userDocumentOrderRepository).saveAndFlush(udoCaptor.capture()); + assertEquals(ownerId, udoCaptor.getValue().getUser().getId()); + } + + @Test + void restore_asCollaborator_restoresOwnerOrderRowInOwnerKeySpaceAndCollaboratorOrderRow() { + UUID ownerId = UUID.randomUUID(); + UUID editorId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + User editor = User.builder().id(editorId).build(); + Document trashed = Document.builder() + .id(documentId) + .user(owner) + .title("Shared Trashed") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + DocumentCollaborator collaborator = DocumentCollaborator.builder() + .document(trashed) + .user(editor) + .accessLevel(DocumentAccessLevel.EDIT) + .build(); + + when(permissionService.requireTrashEditAccess(editorId, documentId)).thenReturn(trashed); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(ownerId, documentId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findMaxOrderKeyByUserId(ownerId, documentId)) + .thenReturn(Optional.of("a0")); + when(collaboratorRepository.findAllByDocument_Id(documentId)).thenReturn(List.of(collaborator)); + when(userDocumentOrderRepository.existsByUser_IdAndDocument_Id(editorId, documentId)) + .thenReturn(false); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(editorId, documentId)) + .thenReturn(Optional.of("z9")); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + DocumentResponse response = documentService.restoreAndPersist(editorId, documentId, false); + + assertNull(response.deletedAt()); + ArgumentCaptor udoCaptor = ArgumentCaptor.forClass(UserDocumentOrder.class); + verify(userDocumentOrderRepository, times(2)).saveAndFlush(udoCaptor.capture()); + List savedUdos = udoCaptor.getAllValues(); + assertEquals(ownerId, savedUdos.get(0).getUser().getId()); + assertTrue(savedUdos.get(0).getOrderKey().compareTo("a0") > 0); + assertEquals(editorId, savedUdos.get(1).getUser().getId()); + assertTrue(savedUdos.get(1).getOrderKey().compareTo("z9") < 0); + } + + @Test + void restore_withViewOnlyAccess_throwsForbidden() { + UUID viewerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + doThrow(new ApiException(ErrorCode.FORBIDDEN)) + .when(permissionService) + .requireTrashEditAccess(viewerId, documentId); + + ApiException exception = + assertThrows(ApiException.class, () -> documentService.restoreAndPersist(viewerId, documentId, false)); + + assertEquals(ErrorCode.FORBIDDEN, exception.getErrorCode()); + verify(documentRepository, never()).saveAndFlush(any(Document.class)); + } + + @Test + void restore_withNoAccess_throwsNotFound() { + UUID strangerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + doThrow(new ApiException(ErrorCode.NOT_FOUND)) + .when(permissionService) + .requireTrashEditAccess(strangerId, documentId); + + ApiException exception = assertThrows( + ApiException.class, () -> documentService.restoreAndPersist(strangerId, documentId, false)); + + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void restore_activeDocument_throwsNotFound() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document active = Document.builder() + .id(documentId) + .user(User.builder().id(userId).build()) + .title("Active") + .build(); + + when(permissionService.requireTrashEditAccess(userId, documentId)).thenReturn(active); + + ApiException exception = + assertThrows(ApiException.class, () -> documentService.restoreAndPersist(userId, documentId, false)); + + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void restore_recreatesCollaboratorOrderRowsForRestoredDescendant() { + UUID ownerId = UUID.randomUUID(); + UUID collaboratorId = UUID.randomUUID(); + UUID parentDocId = UUID.randomUUID(); + UUID childDocId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + Document parentDoc = Document.builder() + .id(parentDocId) + .user(owner) + .title("Parent Doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + Document childDoc = Document.builder() + .id(childDocId) + .user(owner) + .parent(parentDoc) + .siblingOrderKey("a5") + .title("Child Doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + DocumentCollaborator collaborator = DocumentCollaborator.builder() + .document(childDoc) + .user(User.builder().id(collaboratorId).build()) + .build(); + + when(permissionService.requireTrashEditAccess(ownerId, parentDocId)).thenReturn(parentDoc); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(ownerId, parentDocId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findMaxOrderKeyByUserId(ownerId, parentDocId)) + .thenReturn(Optional.of("a0")); + when(collaboratorRepository.findAllByDocument_Id(parentDocId)).thenReturn(List.of()); + when(documentRepository.saveAndFlush(parentDoc)).thenReturn(parentDoc); + when(documentRepository.findAllByParent_IdIn(List.of(parentDocId))).thenReturn(List.of(childDoc)); + when(documentRepository.findAllByParent_IdIn(List.of(childDocId))).thenReturn(List.of()); + when(collaboratorRepository.findAllByDocument_Id(childDocId)).thenReturn(List.of(collaborator)); + when(userDocumentOrderRepository.existsByUser_IdAndDocument_Id(collaboratorId, childDocId)) + .thenReturn(false); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(collaboratorId, childDocId)) + .thenReturn(Optional.empty()); + + documentService.restoreAndPersist(ownerId, parentDocId, false); + + assertNull(childDoc.getDeletedAt()); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDocumentOrder.class); + verify(userDocumentOrderRepository, times(2)).saveAndFlush(captor.capture()); + List saved = captor.getAllValues(); + assertEquals(ownerId, saved.get(0).getUser().getId()); + assertEquals(parentDocId, saved.get(0).getDocument().getId()); + assertEquals(collaboratorId, saved.get(1).getUser().getId()); + assertEquals(childDocId, saved.get(1).getDocument().getId()); + } + + @Test + void delete_permanentDelete_withEditAccess_deletesTrashedDocument() { + UUID ownerId = UUID.randomUUID(); + UUID editorId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document trashedDoc = Document.builder() + .id(documentId) + .user(User.builder().id(ownerId).build()) + .title("Trashed doc") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(permissionService.requireTrashEditAccess(editorId, documentId)).thenReturn(trashedDoc); + + documentService.delete(editorId, documentId, true); + + verify(userDocumentOrderRepository).deleteByDocument_Id(documentId); + verify(documentRepository).delete(trashedDoc); + } + + @Test + void delete_permanentDelete_noAccess_throwsNotFound() { + UUID strangerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + doThrow(new ApiException(ErrorCode.NOT_FOUND)) + .when(permissionService) + .requireTrashEditAccess(strangerId, documentId); + + ApiException exception = + assertThrows(ApiException.class, () -> documentService.delete(strangerId, documentId, true)); + + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + verify(documentRepository, never()).delete(any()); + } + + @Test + void get_includeTrashed_allowsEditorOfTrashedDocument() { + UUID ownerId = UUID.randomUUID(); + UUID editorId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document trashed = Document.builder() + .id(documentId) + .user(User.builder().id(ownerId).build()) + .title("Shared Trashed") + .yjsState(new byte[] {1}) + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(trashed)); + when(permissionService.resolveTrashAccess(editorId, documentId)).thenReturn(DocumentAccessLevel.EDIT); + + var response = documentService.get(editorId, documentId, true); + + assertEquals(documentId, response.id()); + assertNotNull(response.deletedAt()); + } + + @Test + void get_includeTrashed_allowsViewOnlyCollaboratorReadOnly() { + UUID ownerId = UUID.randomUUID(); + UUID viewerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + OffsetDateTime deletedAt = OffsetDateTime.now(ZoneOffset.UTC); + Document trashed = Document.builder() + .id(documentId) + .user(User.builder().id(ownerId).build()) + .title("Shared Trashed") + .deletedAt(deletedAt) + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(trashed)); + when(permissionService.resolveTrashAccess(viewerId, documentId)).thenReturn(DocumentAccessLevel.VIEW); + + var response = documentService.get(viewerId, documentId, true); + + assertEquals(documentId, response.id()); + assertNotNull(response.deletedAt()); + } + + @Test + void get_includeTrashed_strangerGetsNotFound() { + UUID ownerId = UUID.randomUUID(); + UUID strangerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document trashed = Document.builder() + .id(documentId) + .user(User.builder().id(ownerId).build()) + .title("Shared Trashed") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(trashed)); + when(permissionService.resolveTrashAccess(strangerId, documentId)).thenReturn(null); + + ApiException exception = + assertThrows(ApiException.class, () -> documentService.get(strangerId, documentId, true)); + + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void update_trashedDocumentWithAnyTrashAccess_throwsConflict() { + UUID ownerId = UUID.randomUUID(); + UUID viewerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document trashed = Document.builder() + .id(documentId) + .user(User.builder().id(ownerId).build()) + .title("Trashed") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(trashed)); + when(permissionService.resolveTrashAccess(viewerId, documentId)).thenReturn(DocumentAccessLevel.COMMENT); + + ApiException exception = assertThrows( + ApiException.class, + () -> documentService.update(viewerId, documentId, new DocumentUpdateRequest("New title", null, null))); + + assertEquals(ErrorCode.CONFLICT, exception.getErrorCode()); + } + + @Test + void delete_softDelete_cycleGuard_throwsValidationFailed() { + UUID ownerId = UUID.randomUUID(); + UUID rootId = UUID.randomUUID(); + Document root = Document.builder() + .id(rootId) + .user(User.builder().id(ownerId).build()) + .title("Root") + .build(); + + when(permissionService.requireEditAccess(ownerId, rootId)).thenReturn(root); + when(documentRepository.save(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.findAllByParent_IdIn(any())).thenAnswer(invocation -> { + java.util.Collection parentIds = (java.util.Collection) invocation.getArgument(0); + UUID parentId = parentIds.iterator().next(); + return List.of(Document.builder() + .id(UUID.randomUUID()) + .user(root.getUser()) + .parent(Document.builder().id(parentId).build()) + .title("Child") + .build()); + }); + + ApiException exception = assertThrows(ApiException.class, () -> documentService.delete(ownerId, rootId, false)); + + assertEquals(ErrorCode.VALIDATION_FAILED, exception.getErrorCode()); + } + + @Test + void list_trashedOnly_returnsAccessibleTrashedDocuments() { + UUID userId = UUID.randomUUID(); + User user = User.builder() + .id(userId) + .email("t@example.com") + .displayName("T") + .build(); + Document trashed = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Trashed") + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + DocumentResponse trashedResponse = new DocumentResponse( + trashed.getId(), + "Trashed", + null, + null, + null, + false, + false, + DocumentAccessLevel.OWNER, + "T", + trashed.getCreatedAt(), + trashed.getUpdatedAt(), + trashed.getDeletedAt(), + null); + when(queryHelper.list(eq(userId), eq(null), eq("all"), eq(true), any())) + .thenReturn(new PageImpl<>(List.of(trashedResponse))); + + Page result = documentService.list(userId, null, "all", true, null); + + assertEquals(1, result.getContent().size()); + assertNotNull(result.getContent().get(0).deletedAt()); + assertNull(result.getContent().get(0).orderKey()); + } + + @Test + void getBreadcrumbs_owner_returnsFullHierarchy() { + UUID ownerId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).email("alice@example.com").build(); + + Document root = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Root") + .parent(null) + .build(); + + Document child = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Child") + .parent(root) + .build(); + + Document subChild = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("SubChild") + .parent(child) + .build(); + + when(documentRepository.findById(subChild.getId())).thenReturn(Optional.of(subChild)); + when(permissionService.resolveAccess(ownerId, subChild.getId())).thenReturn(DocumentAccessLevel.OWNER); + when(permissionService.resolveAccess(ownerId, child.getId())).thenReturn(DocumentAccessLevel.OWNER); + when(permissionService.resolveAccess(ownerId, root.getId())).thenReturn(DocumentAccessLevel.OWNER); + + List crumbs = documentService.getBreadcrumbs(ownerId, subChild.getId()); + + assertEquals(3, crumbs.size()); + assertEquals("Root", crumbs.get(0).title()); + assertNull(crumbs.get(0).parentId()); + assertEquals("Child", crumbs.get(1).title()); + assertEquals(root.getId(), crumbs.get(1).parentId()); + assertEquals("SubChild", crumbs.get(2).title()); + assertEquals(child.getId(), crumbs.get(2).parentId()); + } + + @Test + void getBreadcrumbs_trashedDocument_returnsBreadcrumbsForUserWithTrashAccess() { + UUID ownerId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).email("alice@example.com").build(); + + Document root = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Project Alpha") + .parent(null) + .build(); + + Document trashedChild = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Deleted Spec") + .parent(root) + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(documentRepository.findById(trashedChild.getId())).thenReturn(Optional.of(trashedChild)); + when(permissionService.resolveTrashAccess(ownerId, trashedChild.getId())) + .thenReturn(DocumentAccessLevel.OWNER); + when(permissionService.resolveAccess(ownerId, root.getId())).thenReturn(DocumentAccessLevel.OWNER); + + List crumbs = documentService.getBreadcrumbs(ownerId, trashedChild.getId()); + + assertEquals(2, crumbs.size()); + assertEquals("Project Alpha", crumbs.get(0).title()); + assertNull(crumbs.get(0).parentId()); + assertEquals("Deleted Spec", crumbs.get(1).title()); + assertEquals(root.getId(), crumbs.get(1).parentId()); + } + + @Test + void getBreadcrumbs_collaboratorOnlyOnChild_stopsAtChildAndDoesNotExposePrivateParents() { + UUID collaboratorId = UUID.randomUUID(); + User owner = + User.builder().id(UUID.randomUUID()).email("owner@example.com").build(); + + Document root = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Secret Root") + .parent(null) + .build(); + + Document child = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Secret Parent") + .parent(root) + .build(); + + Document subChild = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Shared SubChild") + .parent(child) + .build(); + + when(documentRepository.findById(subChild.getId())).thenReturn(Optional.of(subChild)); + when(permissionService.resolveAccess(collaboratorId, subChild.getId())).thenReturn(DocumentAccessLevel.VIEW); + // Collaborator does NOT have access to the parent "Secret Parent" + when(permissionService.resolveAccess(collaboratorId, child.getId())).thenReturn(null); + + List crumbs = documentService.getBreadcrumbs(collaboratorId, subChild.getId()); + + assertEquals(1, crumbs.size()); + assertEquals("Shared SubChild", crumbs.get(0).title()); + assertEquals(subChild.getId(), crumbs.get(0).id()); + assertNull(crumbs.get(0).parentId()); + // Verify that resolveAccess on root was never even attempted + verify(permissionService, never()).resolveAccess(collaboratorId, root.getId()); + } + + @Test + void getBreadcrumbs_collaboratorOnParent_stopsAtParent() { + UUID collaboratorId = UUID.randomUUID(); + User owner = + User.builder().id(UUID.randomUUID()).email("owner@example.com").build(); + + Document root = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Secret Company Root") + .parent(null) + .build(); + + Document child = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Shared Project") + .parent(root) + .build(); + + Document subChild = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Tasks") + .parent(child) + .build(); + + when(documentRepository.findById(subChild.getId())).thenReturn(Optional.of(subChild)); + when(permissionService.resolveAccess(collaboratorId, subChild.getId())).thenReturn(DocumentAccessLevel.EDIT); + // Collaborator has access to "Shared Project" + when(permissionService.resolveAccess(collaboratorId, child.getId())).thenReturn(DocumentAccessLevel.EDIT); + // But NOT to "Secret Company Root" + when(permissionService.resolveAccess(collaboratorId, root.getId())).thenReturn(null); + + List crumbs = documentService.getBreadcrumbs(collaboratorId, subChild.getId()); + + assertEquals(2, crumbs.size()); + assertEquals("Shared Project", crumbs.get(0).title()); + assertNull(crumbs.get(0).parentId()); + assertEquals("Tasks", crumbs.get(1).title()); + assertEquals(child.getId(), crumbs.get(1).parentId()); + } + + @Test + void getPublicBreadcrumbs_publicDocWithPrivateParent_stopsAtPublicDoc() { + Document privateRoot = Document.builder() + .id(UUID.randomUUID()) + .title("Private Org") + .generalAccessMode(DocumentGeneralAccessMode.RESTRICTED) + .parent(null) + .build(); + + Document publicDoc = Document.builder() + .id(UUID.randomUUID()) + .title("Public Spec") + .generalAccessMode(DocumentGeneralAccessMode.ANYONE_WITH_LINK) + .parent(privateRoot) + .build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(publicDoc.getId())).thenReturn(Optional.of(publicDoc)); + + List crumbs = documentService.getPublicBreadcrumbs(publicDoc.getId()); + + assertEquals(1, crumbs.size()); + assertEquals("Public Spec", crumbs.get(0).title()); + assertNull(crumbs.get(0).parentId()); + } + + @Test + void getPublicBreadcrumbs_publicDocWithPublicParent_returnsPublicHierarchy() { + Document publicParent = Document.builder() + .id(UUID.randomUUID()) + .title("Public Project") + .generalAccessMode(DocumentGeneralAccessMode.ANYONE_WITH_LINK) + .parent(null) + .build(); + + Document publicChild = Document.builder() + .id(UUID.randomUUID()) + .title("Public Task") + .generalAccessMode(DocumentGeneralAccessMode.ANYONE_WITH_LINK) + .parent(publicParent) + .build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(publicChild.getId())).thenReturn(Optional.of(publicChild)); + + List crumbs = documentService.getPublicBreadcrumbs(publicChild.getId()); + + assertEquals(2, crumbs.size()); + assertEquals("Public Project", crumbs.get(0).title()); + assertNull(crumbs.get(0).parentId()); + assertEquals("Public Task", crumbs.get(1).title()); + assertEquals(publicParent.getId(), crumbs.get(1).parentId()); + } + + @Test + void getPublicBreadcrumbs_trashedPublicDoc_throwsNotFound() { + UUID docId = UUID.randomUUID(); + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, () -> documentService.getPublicBreadcrumbs(docId)); + assertEquals(ErrorCode.NOT_FOUND, ex.getErrorCode()); + } + + @Test + void getPublicBreadcrumbs_trashedParent_stopsAtPublicChild() { + Document trashedPublicParent = Document.builder() + .id(UUID.randomUUID()) + .title("Trashed Public Parent") + .generalAccessMode(DocumentGeneralAccessMode.ANYONE_WITH_LINK) + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .parent(null) + .build(); + + Document publicChild = Document.builder() + .id(UUID.randomUUID()) + .title("Public Child") + .generalAccessMode(DocumentGeneralAccessMode.ANYONE_WITH_LINK) + .parent(trashedPublicParent) + .build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(publicChild.getId())).thenReturn(Optional.of(publicChild)); + + List crumbs = documentService.getPublicBreadcrumbs(publicChild.getId()); + + assertEquals(1, crumbs.size()); + assertEquals("Public Child", crumbs.get(0).title()); + assertNull(crumbs.get(0).parentId()); + } + + @Test + void getBreadcrumbs_blankOrNullTitle_fallsBackToUntitled() { + UUID ownerId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).email("alice@example.com").build(); + + Document root = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title(" ") + .parent(null) + .build(); + + Document child = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title(null) + .parent(root) + .build(); + + when(documentRepository.findById(child.getId())).thenReturn(Optional.of(child)); + when(permissionService.resolveAccess(ownerId, child.getId())).thenReturn(DocumentAccessLevel.OWNER); + when(permissionService.resolveAccess(ownerId, root.getId())).thenReturn(DocumentAccessLevel.OWNER); + + List crumbs = documentService.getBreadcrumbs(ownerId, child.getId()); + + assertEquals(2, crumbs.size()); + assertEquals("Untitled", crumbs.get(0).title()); + assertEquals("Untitled", crumbs.get(1).title()); + } + + @Test + void getPublicBreadcrumbs_blankOrNullTitle_fallsBackToUntitled() { + Document publicDoc = Document.builder() + .id(UUID.randomUUID()) + .title(" ") + .generalAccessMode(DocumentGeneralAccessMode.ANYONE_WITH_LINK) + .parent(null) + .build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(publicDoc.getId())).thenReturn(Optional.of(publicDoc)); + + List crumbs = documentService.getPublicBreadcrumbs(publicDoc.getId()); + + assertEquals(1, crumbs.size()); + assertEquals("Untitled", crumbs.get(0).title()); } private static Document createSharedDocument(UUID documentId, DocumentAccessLevel linkAccessLevel) { diff --git a/api/src/test/java/com/nextdocs/api/document/service/DocumentSharingServiceTest.java b/api/src/test/java/com/nextdocs/api/document/service/DocumentSharingServiceTest.java index 96e0f1f..e71ea72 100644 --- a/api/src/test/java/com/nextdocs/api/document/service/DocumentSharingServiceTest.java +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentSharingServiceTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -17,11 +18,14 @@ import com.nextdocs.api.document.entity.DocumentAccessLevel; import com.nextdocs.api.document.entity.DocumentCollaborator; import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import com.nextdocs.api.document.entity.UserDocumentOrder; import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; import com.nextdocs.api.document.repository.DocumentRepository; +import com.nextdocs.api.document.repository.UserDocumentOrderRepository; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -30,6 +34,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; @ExtendWith(MockitoExtension.class) class DocumentSharingServiceTest { @@ -40,14 +45,25 @@ class DocumentSharingServiceTest { @Mock private DocumentCollaboratorRepository collaboratorRepository; + @Mock + private UserDocumentOrderRepository userDocumentOrderRepository; + @Mock private UserRepository userRepository; + @Mock + private PermissionService permissionService; + private DocumentSharingService sharingService; @BeforeEach void setUp() { - sharingService = new DocumentSharingService(documentRepository, collaboratorRepository, userRepository); + sharingService = new DocumentSharingService( + documentRepository, + collaboratorRepository, + userDocumentOrderRepository, + userRepository, + permissionService); } @Test @@ -57,8 +73,7 @@ void getMyAccess_allowsAnyoneWithLinkWhenGeneralAccessEnabled() { Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); - when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) - .thenReturn(Optional.empty()); + when(permissionService.resolveAccess(requesterId, documentId)).thenReturn(DocumentAccessLevel.VIEW); DocumentAccessResponse response = sharingService.getMyAccess(requesterId, documentId); @@ -73,19 +88,8 @@ void getMyAccess_prefersCollaboratorAccessOverGeneralAccess() { UUID documentId = UUID.randomUUID(); Document document = createSharedDocument(documentId, DocumentAccessLevel.EDIT); - DocumentCollaborator collaborator = DocumentCollaborator.builder() - .document(document) - .user(User.builder() - .id(requesterId) - .email("viewer@example.com") - .displayName("Viewer") - .build()) - .accessLevel(DocumentAccessLevel.VIEW) - .build(); - when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); - when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) - .thenReturn(Optional.of(collaborator)); + when(permissionService.resolveAccess(requesterId, documentId)).thenReturn(DocumentAccessLevel.VIEW); DocumentAccessResponse response = sharingService.getMyAccess(requesterId, documentId); @@ -95,7 +99,101 @@ void getMyAccess_prefersCollaboratorAccessOverGeneralAccess() { } @Test - void upsertCollaborator_usesPersistedCreatedAtWithoutManualOverride() { + void getMyAccess_onTrashedDocument_reportsPreTrashAccessAndTrashFlag() { + UUID viewerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.empty()); + when(permissionService.resolveTrashAccess(viewerId, documentId)).thenReturn(DocumentAccessLevel.COMMENT); + + DocumentAccessResponse response = sharingService.getMyAccess(viewerId, documentId); + + assertTrue(response.allowed()); + assertTrue(response.trashed()); + assertEquals(DocumentAccessLevel.COMMENT, response.accessLevel()); + assertFalse(response.owner()); + } + + @Test + void getMyAccess_onTrashedDocumentWithoutAnyAccess_denies() { + UUID strangerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.empty()); + when(permissionService.resolveTrashAccess(strangerId, documentId)).thenReturn(null); + + DocumentAccessResponse response = sharingService.getMyAccess(strangerId, documentId); + + assertFalse(response.allowed()); + assertTrue(response.trashed()); + assertNull(response.accessLevel()); + } + + @Test + void accessCheck_onTrashedDocument_deniedEvenForOwner() { + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.empty()); + + DocumentAccessResponse response = sharingService.accessCheck(ownerId, documentId); + + assertFalse(response.allowed()); + assertTrue(response.trashed()); + assertNull(response.accessLevel()); + } + + @Test + void removeCollaborator_onTrashedDocument_allowsOwner() { + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + UUID collaboratorUserId = UUID.randomUUID(); + Document trashed = Document.builder() + .id(documentId) + .user(User.builder().id(ownerId).build()) + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(permissionService.requireOwnerAccessIncludingTrash(ownerId, documentId)) + .thenReturn(trashed); + + when(permissionService.requireOwnerAccessIncludingTrash(ownerId, documentId)) + .thenReturn(trashed); + when(collaboratorRepository.existsByDocument_IdAndUser_Id(documentId, collaboratorUserId)) + .thenReturn(true); + + sharingService.removeCollaborator(ownerId, documentId, collaboratorUserId); + + verify(collaboratorRepository).deleteByDocument_IdAndUser_Id(documentId, collaboratorUserId); + verify(userDocumentOrderRepository).deleteByUser_IdAndDocument_Id(collaboratorUserId, documentId); + } + + @Test + void listCollaborators_onTrashedDocument_allowsOwner() { + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document trashed = Document.builder() + .id(documentId) + .user(User.builder() + .id(ownerId) + .email("owner@example.com") + .displayName("Owner") + .build()) + .deletedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + when(permissionService.requireReadAccessOrTrashOwner(ownerId, documentId)) + .thenReturn(trashed); + when(collaboratorRepository.findAllByDocument_Id(documentId)).thenReturn(List.of()); + + List result = sharingService.listCollaborators(ownerId, documentId); + + assertEquals(1, result.size()); + assertEquals(DocumentAccessLevel.OWNER, result.get(0).accessLevel()); + } + + @Test + void upsertCollaborator_createsUserDocumentOrderForRootDocument() { UUID ownerId = UUID.randomUUID(); UUID documentId = UUID.randomUUID(); @@ -122,11 +220,16 @@ void upsertCollaborator_usesPersistedCreatedAtWithoutManualOverride() { .displayName("Alice") .build(); - when(documentRepository.findByIdAndUser_IdAndDeletedAtIsNull(documentId, ownerId)) - .thenReturn(Optional.of(document)); + when(permissionService.requireOwnerAccessIncludingTrash(ownerId, documentId)) + .thenReturn(document); when(userRepository.findByEmail("alice@example.com")).thenReturn(Optional.of(targetUser)); when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, targetUser.getId())) .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.existsByUser_IdAndDocument_Id(targetUser.getId(), documentId)) + .thenReturn(false); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(targetUser.getId(), documentId)) + .thenReturn(Optional.of("a5")); + OffsetDateTime persistedCreatedAt = OffsetDateTime.of(2026, 3, 1, 10, 0, 0, 0, ZoneOffset.UTC); when(collaboratorRepository.save(any(DocumentCollaborator.class))).thenAnswer(invocation -> { DocumentCollaborator input = invocation.getArgument(0); @@ -147,11 +250,128 @@ void upsertCollaborator_usesPersistedCreatedAtWithoutManualOverride() { ArgumentCaptor collaboratorCaptor = ArgumentCaptor.forClass(DocumentCollaborator.class); verify(collaboratorRepository).save(collaboratorCaptor.capture()); + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(UserDocumentOrder.class); + verify(userDocumentOrderRepository).saveAndFlush(orderCaptor.capture()); + assertTrue(orderCaptor.getValue().getOrderKey().compareTo("a5") < 0); + assertEquals(targetUser.getId(), response.userId()); assertNull(collaboratorCaptor.getValue().getCreatedAt()); assertEquals(persistedCreatedAt, response.addedAt()); } + @Test + void upsertCollaborator_retriesWhenOrderRowInsertCollides() { + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + User owner = User.builder() + .id(ownerId) + .email("owner@example.com") + .displayName("Owner") + .build(); + + Document document = Document.builder() + .id(documentId) + .user(owner) + .title("Shared doc") + .yjsState("seed".getBytes(StandardCharsets.UTF_8)) + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + User targetUser = User.builder() + .id(UUID.randomUUID()) + .email("alice@example.com") + .displayName("Alice") + .build(); + + when(permissionService.requireOwnerAccessIncludingTrash(ownerId, documentId)) + .thenReturn(document); + when(userRepository.findByEmail("alice@example.com")).thenReturn(Optional.of(targetUser)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, targetUser.getId())) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.existsByUser_IdAndDocument_Id(targetUser.getId(), documentId)) + .thenReturn(false); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(targetUser.getId(), documentId)) + .thenReturn(Optional.of("a5")); + when(collaboratorRepository.save(any(DocumentCollaborator.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenThrow(new DataIntegrityViolationException("order_key unique violation")) + .thenAnswer(invocation -> invocation.getArgument(0)); + + sharingService.upsertCollaborator( + ownerId, documentId, new CollaboratorUpsertRequest("alice@example.com", DocumentAccessLevel.EDIT)); + + verify(userDocumentOrderRepository, times(2)).saveAndFlush(any(UserDocumentOrder.class)); + } + + @Test + void removeCollaborator_deletesCollaboratorAndUserDocumentOrder() { + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + UUID collaboratorId = UUID.randomUUID(); + + when(collaboratorRepository.existsByDocument_IdAndUser_Id(documentId, collaboratorId)) + .thenReturn(true); + + sharingService.removeCollaborator(ownerId, documentId, collaboratorId); + + verify(collaboratorRepository).deleteByDocument_IdAndUser_Id(documentId, collaboratorId); + verify(userDocumentOrderRepository).deleteByUser_IdAndDocument_Id(collaboratorId, documentId); + } + + @Test + void upsertCollaborator_createsUserDocumentOrderForNestedDocument() { + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + User owner = User.builder() + .id(ownerId) + .email("owner@example.com") + .displayName("Owner") + .build(); + + Document parent = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Parent") + .build(); + Document document = Document.builder() + .id(documentId) + .user(owner) + .title("Nested shared doc") + .parent(parent) + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + User targetUser = User.builder() + .id(UUID.randomUUID()) + .email("alice@example.com") + .displayName("Alice") + .build(); + + when(permissionService.requireOwnerAccessIncludingTrash(ownerId, documentId)) + .thenReturn(document); + when(userRepository.findByEmail("alice@example.com")).thenReturn(Optional.of(targetUser)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, targetUser.getId())) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.existsByUser_IdAndDocument_Id(targetUser.getId(), documentId)) + .thenReturn(false); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(targetUser.getId(), documentId)) + .thenReturn(Optional.of("a5")); + when(collaboratorRepository.save(any(DocumentCollaborator.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + sharingService.upsertCollaborator( + ownerId, documentId, new CollaboratorUpsertRequest("alice@example.com", DocumentAccessLevel.EDIT)); + + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(UserDocumentOrder.class); + verify(userDocumentOrderRepository).saveAndFlush(orderCaptor.capture()); + assertTrue(orderCaptor.getValue().getOrderKey().compareTo("a5") < 0); + } + private static Document createSharedDocument(UUID documentId, DocumentAccessLevel linkAccessLevel) { User owner = User.builder() .id(UUID.randomUUID()) diff --git a/api/src/test/java/com/nextdocs/api/document/service/DocumentTreeServiceTest.java b/api/src/test/java/com/nextdocs/api/document/service/DocumentTreeServiceTest.java new file mode 100644 index 0000000..32829fa --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentTreeServiceTest.java @@ -0,0 +1,924 @@ +package com.nextdocs.api.document.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.auth.repository.UserRepository; +import com.nextdocs.api.common.exception.ApiException; +import com.nextdocs.api.common.exception.ErrorCode; +import com.nextdocs.api.document.dto.request.DocumentMoveRequest; +import com.nextdocs.api.document.dto.response.DocumentResponse; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.entity.DocumentCollaborator; +import com.nextdocs.api.document.entity.UserDocumentOrder; +import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; +import com.nextdocs.api.document.repository.DocumentRepository; +import com.nextdocs.api.document.repository.UserDocumentOrderRepository; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +@ExtendWith(MockitoExtension.class) +class DocumentTreeServiceTest { + + @Mock + private DocumentRepository documentRepository; + + @Mock + private DocumentCollaboratorRepository collaboratorRepository; + + @Mock + private UserDocumentOrderRepository userDocumentOrderRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private PermissionService permissionService; + + private DocumentTreeService documentTreeService; + + private UUID userId; + private User user; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + user = User.builder() + .id(userId) + .email("alice@example.com") + .displayName("Alice") + .build(); + documentTreeService = new DocumentTreeService( + documentRepository, + collaboratorRepository, + userDocumentOrderRepository, + userRepository, + permissionService); + } + + @Test + void move_reparent_deletesOwnerUserDocumentOrder_andPreservesCollaboratorOrders() { + UUID docId = UUID.randomUUID(); + UUID parentId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + UUID nextSiblingId = UUID.randomUUID(); + + Document doc = Document.builder() + .id(docId) + .user(user) + .title("Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document parent = + Document.builder().id(parentId).user(user).title("Parent").build(); + + DocumentMoveRequest request = new DocumentMoveRequest(parentId, prevSiblingId, nextSiblingId); + + when(permissionService.requireEditAccess(userId, docId)).thenReturn(doc); + when(permissionService.requireEditAccess(userId, parentId)).thenReturn(parent); + when(documentRepository.findByIdAndDeletedAtIsNull(parentId)).thenReturn(Optional.of(parent)); + Document prevSibling = Document.builder() + .id(prevSiblingId) + .parent(parent) + .siblingOrderKey("a0") + .build(); + Document nextSibling = Document.builder() + .id(nextSiblingId) + .parent(parent) + .siblingOrderKey("a2") + .build(); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(prevSibling)); + when(documentRepository.findByIdAndDeletedAtIsNull(nextSiblingId)).thenReturn(Optional.of(nextSibling)); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNotNull(result.orderKey()); + assertTrue(result.orderKey().compareTo("a0") > 0); + assertTrue(result.orderKey().compareTo("a2") < 0); + assertEquals(parentId, result.parentId()); + + // Verify document was updated with new parent and sibling order key + verify(documentRepository).saveAndFlush(any(Document.class)); + + // Verify ONLY owner root order was deleted, preserving collaborator Shared orders + verify(userDocumentOrderRepository).deleteByUser_IdAndDocument_Id(user.getId(), docId); + verify(userDocumentOrderRepository, never()).deleteByDocument_Id(docId); + } + + @Test + void move_reorderSharedNavigationByCollaborator_updatesOnlyCallerUserDocumentOrder() { + UUID docId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder() + .id(docId) + .user(owner) // Document is owned by someone else + .title("Shared Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document prevSibling = + Document.builder().id(prevSiblingId).user(owner).title("Prev").build(); + DocumentMoveRequest request = new DocumentMoveRequest(null, prevSiblingId, null); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(prevSibling)); + when(permissionService.resolveAccess(userId, prevSiblingId)).thenReturn(DocumentAccessLevel.VIEW); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId)) + .thenReturn(Optional.of("a0")); + when(userDocumentOrderRepository.findMinOrderKeyGreaterThan(eq(userId), eq("a0"), eq(docId))) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNull(result.parentId()); + assertNotNull(result.orderKey()); + assertTrue(result.orderKey().compareTo("a0") > 0); + assertEquals(DocumentAccessLevel.VIEW, result.accessLevel()); + + // Verify ONLY userDocumentOrder was saved, and Document was NOT modified/saved! + verify(userDocumentOrderRepository).saveAndFlush(any(UserDocumentOrder.class)); + verify(documentRepository, times(0)).saveAndFlush(any(Document.class)); + } + + @Test + void move_collaboratorReordersFloatedNestedDocument_withoutReparenting() { + UUID docId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + UUID parentId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + Document parent = + Document.builder().id(parentId).user(owner).title("Parent").build(); + + // Real parent is not shared with the caller, so the frontend floats the + // document at the root of the Shared section. + Document doc = Document.builder() + .id(docId) + .user(owner) + .title("Nested Shared Doc") + .parent(parent) + .siblingOrderKey("a1") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document prevSibling = + Document.builder().id(prevSiblingId).user(owner).title("Prev").build(); + DocumentMoveRequest request = new DocumentMoveRequest(null, prevSiblingId, null); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(prevSibling)); + when(permissionService.resolveAccess(userId, prevSiblingId)).thenReturn(DocumentAccessLevel.VIEW); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId)) + .thenReturn(Optional.of("a0")); + when(userDocumentOrderRepository.findMinOrderKeyGreaterThan(eq(userId), eq("a0"), eq(docId))) + .thenReturn(Optional.empty()); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.EDIT); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertEquals(parentId, result.parentId()); + assertNotNull(result.orderKey()); + assertTrue(result.orderKey().compareTo("a0") > 0); + + // A collaborator reorder must NOT un-parent the document. + verify(documentRepository, times(0)).saveAndFlush(any(Document.class)); + verify(userDocumentOrderRepository).saveAndFlush(any(UserDocumentOrder.class)); + } + + @Test + void move_collaboratorReorderWithFloatedSibling_lazilyCreatesSiblingOrder() { + UUID docId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder() + .id(docId) + .user(owner) + .title("Shared Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + // Floated sibling has no UserDocumentOrder row yet. + Document floatedSibling = Document.builder() + .id(prevSiblingId) + .user(owner) + .title("Floated Sibling") + .build(); + + DocumentMoveRequest request = new DocumentMoveRequest(null, prevSiblingId, null); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(floatedSibling)); + when(permissionService.resolveAccess(userId, prevSiblingId)).thenReturn(DocumentAccessLevel.VIEW); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(userId, prevSiblingId)) + .thenReturn(Optional.of("b5")); + when(userDocumentOrderRepository.findMinOrderKeyGreaterThan(eq(userId), anyString(), eq(docId))) + .thenReturn(Optional.empty()); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNotNull(result.orderKey()); + // One row for the lazily-created sibling order, one for the moved doc. + verify(userDocumentOrderRepository, times(2)).saveAndFlush(any(UserDocumentOrder.class)); + } + + @Test + void move_collaboratorReordersSharedToMeDocBetweenOwnerSharedDocs_updatesOnlyCallerOrder() { + UUID docId = UUID.randomUUID(); // Shared to me doc + UUID prevSiblingId = UUID.randomUUID(); // Owned by caller & shared with others + UUID nextSiblingId = UUID.randomUUID(); // Owned by caller & shared with others + UUID ownerId = UUID.randomUUID(); + User owner = User.builder().id(ownerId).build(); + + Document doc = Document.builder() + .id(docId) + .user(owner) // Document is owned by owner (not caller) + .title("Shared To Me Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document prevSibling = + Document.builder().id(prevSiblingId).user(user).title("Prev").build(); + Document nextSibling = + Document.builder().id(nextSiblingId).user(user).title("Next").build(); + DocumentMoveRequest request = new DocumentMoveRequest(null, prevSiblingId, nextSiblingId); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(prevSibling)); + when(documentRepository.findByIdAndDeletedAtIsNull(nextSiblingId)).thenReturn(Optional.of(nextSibling)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + // Caller has order keys for both owned shared siblings: "a0" and "a2" + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId)) + .thenReturn(Optional.of("a0")); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, nextSiblingId)) + .thenReturn(Optional.of("a2")); + when(userDocumentOrderRepository.findMinOrderKeyGreaterThan(eq(userId), eq("a0"), eq(docId))) + .thenReturn(Optional.of("a2")); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNull(result.parentId()); + assertNotNull(result.orderKey()); + // Generated key should be strictly between a0 and a2 + assertTrue(result.orderKey().compareTo("a0") > 0); + assertTrue(result.orderKey().compareTo("a2") < 0); + assertEquals(DocumentAccessLevel.VIEW, result.accessLevel()); + + // Verify ONLY userDocumentOrder for userId was saved, Document and owner's orders were never touched + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDocumentOrder.class); + verify(userDocumentOrderRepository).saveAndFlush(captor.capture()); + assertEquals(userId, captor.getValue().getUser().getId()); + assertEquals(docId, captor.getValue().getDocument().getId()); + verify(documentRepository, times(0)).saveAndFlush(any(Document.class)); + } + + @Test + void move_crossTree_adoptsHostOwnerForMovedSubtree() { + UUID docId = UUID.randomUUID(); + UUID childDocId = UUID.randomUUID(); + UUID hostParentId = UUID.randomUUID(); + User host = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder() + .id(docId) + .user(user) + .title("Moving subtree") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + Document child = Document.builder() + .id(childDocId) + .user(user) + .parent(doc) + .title("Child") + .build(); + Document hostParent = Document.builder() + .id(hostParentId) + .user(host) + .title("Host parent") + .build(); + + when(permissionService.requireEditAccess(userId, docId)).thenReturn(doc); + when(permissionService.requireEditAccess(userId, hostParentId)).thenReturn(hostParent); + when(documentRepository.findByIdAndDeletedAtIsNull(hostParentId)).thenReturn(Optional.of(hostParent)); + when(documentRepository.findMaxSiblingOrderKey(hostParentId, docId)).thenReturn(Optional.empty()); + when(documentRepository.findAllByParent_IdIn(List.of(docId))).thenReturn(List.of(child)); + when(documentRepository.findAllByParent_IdIn(List.of(childDocId))).thenReturn(List.of()); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.EDIT); + + documentTreeService.move(userId, docId, new DocumentMoveRequest(hostParentId, null, null)); + + // Location authority: moved doc and its descendants now belong to the host tree owner. + assertEquals(host.getId(), doc.getUser().getId()); + assertEquals(host.getId(), child.getUser().getId()); + verify(documentRepository).saveAll(anyList()); + verify(userDocumentOrderRepository).deleteByUser_IdAndDocument_Id(host.getId(), docId); + verify(userDocumentOrderRepository).deleteByUser_IdAndDocument_Id(user.getId(), docId); + verify(userDocumentOrderRepository, never()).deleteByDocument_Id(docId); + } + + @Test + void move_sameTree_reparentKeepsOwnership() { + UUID docId = UUID.randomUUID(); + UUID parentId = UUID.randomUUID(); + + Document doc = Document.builder() + .id(docId) + .user(user) + .title("Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + Document parent = + Document.builder().id(parentId).user(user).title("Parent").build(); + + when(permissionService.requireEditAccess(userId, docId)).thenReturn(doc); + when(permissionService.requireEditAccess(userId, parentId)).thenReturn(parent); + when(documentRepository.findByIdAndDeletedAtIsNull(parentId)).thenReturn(Optional.of(parent)); + when(documentRepository.findMaxSiblingOrderKey(parentId, docId)).thenReturn(Optional.empty()); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + + documentTreeService.move(userId, docId, new DocumentMoveRequest(parentId, null, null)); + + // Intra-tree reparenting never touches ownership; no cascade walk happens. + assertEquals(user.getId(), doc.getUser().getId()); + verify(documentRepository, never()).saveAll(anyList()); + } + + @Test + void move_cycleDetected_throwsValidationFailed() { + UUID docId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + + Document doc = Document.builder().id(docId).user(user).title("Doc").build(); + + Document child = Document.builder() + .id(childId) + .user(user) + .title("Child") + .parent(doc) + .siblingOrderKey("a1") + .build(); + + DocumentMoveRequest request = new DocumentMoveRequest(childId, null, null); + + when(permissionService.requireEditAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(childId)).thenReturn(Optional.of(child)); + + ApiException ex = assertThrows(ApiException.class, () -> documentTreeService.move(userId, docId, request)); + assertEquals(ErrorCode.VALIDATION_FAILED, ex.getErrorCode()); + } + + @Test + void move_selfAsSibling_throwsValidationFailed() { + UUID docId = UUID.randomUUID(); + + Document doc = Document.builder().id(docId).user(user).title("Doc").build(); + + DocumentMoveRequest request = new DocumentMoveRequest(null, docId, null); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireEditAccess(userId, docId)).thenReturn(doc); + + ApiException ex = assertThrows(ApiException.class, () -> documentTreeService.move(userId, docId, request)); + assertEquals(ErrorCode.VALIDATION_FAILED, ex.getErrorCode()); + } + + @Test + void move_depthExceeds100_throwsValidationFailed() { + UUID docId = UUID.randomUUID(); + UUID targetParentId = UUID.randomUUID(); + + Document doc = Document.builder().id(docId).user(user).title("Doc").build(); + + UUID currentId = targetParentId; + for (int i = 0; i < 100; i++) { + UUID nextParentId = UUID.randomUUID(); + Document parentDoc = Document.builder() + .id(currentId) + .user(user) + .parent(Document.builder().id(nextParentId).build()) + .build(); + when(documentRepository.findByIdAndDeletedAtIsNull(currentId)).thenReturn(Optional.of(parentDoc)); + currentId = nextParentId; + } + + DocumentMoveRequest request = new DocumentMoveRequest(targetParentId, null, null); + + when(permissionService.requireEditAccess(userId, docId)).thenReturn(doc); + + ApiException ex = assertThrows(ApiException.class, () -> documentTreeService.move(userId, docId, request)); + assertEquals(ErrorCode.VALIDATION_FAILED, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("Ancestor chain is too deep")); + } + + @Test + void move_concurrentCollision_reindexesAndRetries() { + UUID docId = UUID.randomUUID(); + UUID parentId = UUID.randomUUID(); + + Document doc = Document.builder() + .id(docId) + .user(user) + .title("Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document parent = + Document.builder().id(parentId).user(user).title("Parent").build(); + + DocumentMoveRequest request = new DocumentMoveRequest(parentId, null, null); + + when(permissionService.requireEditAccess(userId, docId)).thenReturn(doc); + when(permissionService.requireEditAccess(userId, parentId)).thenReturn(parent); + when(documentRepository.findByIdAndDeletedAtIsNull(parentId)).thenReturn(Optional.of(parent)); + when(documentRepository.findMaxSiblingOrderKey(parentId, docId)).thenReturn(Optional.of("a5")); + when(documentRepository.findAllSiblingsForReindex(parentId)).thenReturn(List.of()); + when(documentRepository.saveAndFlush(any(Document.class))) + .thenThrow(new DataIntegrityViolationException("sibling_order_key unique violation")) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + verify(documentRepository, times(2)).saveAndFlush(any(Document.class)); + assertNotNull(result.orderKey()); + } + + @Test + void move_nestedToRoot_recreatesOrderForCollaborators() { + UUID docId = UUID.randomUUID(); + UUID parentId = UUID.randomUUID(); + UUID collaboratorId = UUID.randomUUID(); + + Document parent = + Document.builder().id(parentId).user(user).title("Parent").build(); + Document doc = Document.builder() + .id(docId) + .user(user) + .title("Doc") + .parent(parent) + .siblingOrderKey("a1") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + User collaboratorUser = User.builder().id(collaboratorId).build(); + DocumentCollaborator collaborator = DocumentCollaborator.builder() + .user(collaboratorUser) + .document(doc) + .accessLevel(DocumentAccessLevel.VIEW) + .build(); + + DocumentMoveRequest request = new DocumentMoveRequest(null, null, null); + + when(permissionService.requireEditAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findMaxOrderKeyByUserId(userId, docId)).thenReturn(Optional.of("a5")); + when(collaboratorRepository.findAllByDocument_Id(docId)).thenReturn(List.of(collaborator)); + when(userDocumentOrderRepository.existsByUser_IdAndDocument_Id(collaboratorId, docId)) + .thenReturn(false); + when(userDocumentOrderRepository.findMinOrderKeyByUserId(collaboratorId, docId)) + .thenReturn(Optional.of("a7")); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNull(result.parentId()); + assertNotNull(result.orderKey()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDocumentOrder.class); + verify(userDocumentOrderRepository, times(2)).saveAndFlush(captor.capture()); + List saved = captor.getAllValues(); + assertTrue(saved.stream().anyMatch(o -> o.getUser().getId().equals(collaboratorId))); + assertTrue(saved.stream() + .filter(o -> o.getUser().getId().equals(collaboratorId)) + .allMatch(o -> o.getOrderKey().compareTo("a7") < 0)); + } + + @Test + void move_rootReorderByLinkOnlyUser_throwsForbidden() { + UUID docId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder() + .id(docId) + .user(owner) + .title("Shared Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + DocumentMoveRequest request = new DocumentMoveRequest(null, null, null); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenThrow(new ApiException(ErrorCode.NOT_FOUND)); + + ApiException ex = assertThrows(ApiException.class, () -> documentTreeService.move(userId, docId, request)); + assertEquals(ErrorCode.NOT_FOUND, ex.getErrorCode()); + } + + @Test + void move_rootReorderCollision_reindexesUserOrdersAndRetries() { + UUID docId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder() + .id(docId) + .user(owner) + .title("Shared Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document prevSibling = + Document.builder().id(prevSiblingId).user(owner).title("Prev").build(); + DocumentMoveRequest request = new DocumentMoveRequest(null, prevSiblingId, null); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(prevSibling)); + when(permissionService.resolveAccess(userId, prevSiblingId)).thenReturn(DocumentAccessLevel.VIEW); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId)) + .thenReturn(Optional.of("a0")); + when(userDocumentOrderRepository.findMinOrderKeyGreaterThan(eq(userId), eq("a0"), eq(docId))) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findAllForReindex(userId)).thenReturn(List.of()); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenThrow(new DataIntegrityViolationException("order_key unique violation")) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNotNull(result.orderKey()); + verify(userDocumentOrderRepository, times(2)).saveAndFlush(any(UserDocumentOrder.class)); + verify(userDocumentOrderRepository).findAllForReindex(userId); + } + + @Test + void move_rootReorderWithInterleavedKeys_placesDocumentInFreeSlotWithoutCollision() { + UUID docId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + UUID nextSiblingId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder() + .id(docId) + .user(owner) + .title("Shared Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document prevSibling = + Document.builder().id(prevSiblingId).user(owner).title("Prev").build(); + Document nextSibling = + Document.builder().id(nextSiblingId).user(owner).title("Next").build(); + DocumentMoveRequest request = new DocumentMoveRequest(null, prevSiblingId, nextSiblingId); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(prevSibling)); + when(documentRepository.findByIdAndDeletedAtIsNull(nextSiblingId)).thenReturn(Optional.of(nextSibling)); + when(permissionService.resolveAccess(userId, prevSiblingId)).thenReturn(DocumentAccessLevel.VIEW); + when(permissionService.resolveAccess(userId, nextSiblingId)).thenReturn(DocumentAccessLevel.VIEW); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId)) + .thenReturn(Optional.of("a4")); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, nextSiblingId)) + .thenReturn(Optional.of("a8")); + // A private-root document lives between the two shared siblings in the single + // shared user_document_orders key space; the moved doc must slot between the + // actual adjacent keys instead of colliding with the interleaved document. + when(userDocumentOrderRepository.findMinOrderKeyGreaterThan(eq(userId), eq("a4"), eq(docId))) + .thenReturn(Optional.of("a5")); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNotNull(result.orderKey()); + assertTrue(result.orderKey().compareTo("a4") > 0); + assertTrue(result.orderKey().compareTo("a5") < 0); + verify(userDocumentOrderRepository).saveAndFlush(any(UserDocumentOrder.class)); + } + + @Test + void move_rootReorderWithInvertedNeighborKeys_placesDocumentBetweenThem() { + UUID docId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + UUID nextSiblingId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder() + .id(docId) + .user(owner) + .title("Shared Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document prevSibling = + Document.builder().id(prevSiblingId).user(owner).title("Prev").build(); + Document nextSibling = + Document.builder().id(nextSiblingId).user(owner).title("Next").build(); + DocumentMoveRequest request = new DocumentMoveRequest(null, prevSiblingId, nextSiblingId); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(prevSibling)); + when(documentRepository.findByIdAndDeletedAtIsNull(nextSiblingId)).thenReturn(Optional.of(nextSibling)); + when(permissionService.resolveAccess(userId, prevSiblingId)).thenReturn(DocumentAccessLevel.VIEW); + when(permissionService.resolveAccess(userId, nextSiblingId)).thenReturn(DocumentAccessLevel.VIEW); + // The frontend can report prev/next in display order, which is inverted + // relative to the ascending key space (Gothhaa a4zx above EBbbba a4zt). + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId)) + .thenReturn(Optional.of("a4zx")); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, nextSiblingId)) + .thenReturn(Optional.of("a4zt")); + when(userDocumentOrderRepository.findMinOrderKeyGreaterThan(eq(userId), eq("a4zt"), eq(docId))) + .thenReturn(Optional.of("a4zx")); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNotNull(result.orderKey()); + assertTrue(result.orderKey().compareTo("a4zt") > 0); + assertTrue(result.orderKey().compareTo("a4zx") < 0); + verify(userDocumentOrderRepository).saveAndFlush(any(UserDocumentOrder.class)); + } + + @Test + void move_rootReorderFrontOfInterleavedList_placesDocumentBeforeFirstSibling() { + UUID docId = UUID.randomUUID(); + UUID nextSiblingId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder() + .id(docId) + .user(owner) + .title("Shared Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document nextSibling = + Document.builder().id(nextSiblingId).user(owner).title("Next").build(); + DocumentMoveRequest request = new DocumentMoveRequest(null, null, nextSiblingId); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(nextSiblingId)).thenReturn(Optional.of(nextSibling)); + when(permissionService.resolveAccess(userId, nextSiblingId)).thenReturn(DocumentAccessLevel.VIEW); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, nextSiblingId)) + .thenReturn(Optional.of("a8")); + // A private-root document sits just before the first shared sibling. + when(userDocumentOrderRepository.findMaxOrderKeyLessThan(eq(userId), eq("a8"), eq(docId))) + .thenReturn(Optional.of("a4")); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNotNull(result.orderKey()); + assertTrue(result.orderKey().compareTo("a4") > 0); + assertTrue(result.orderKey().compareTo("a8") < 0); + verify(userDocumentOrderRepository).saveAndFlush(any(UserDocumentOrder.class)); + } + + @Test + void move_frontReorderCollision_retryLandsInReindexedGapWithoutConflict() { + UUID docId = UUID.randomUUID(); + UUID siblingId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder() + .id(docId) + .user(owner) + .title("Shared Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document siblingDoc = + Document.builder().id(siblingId).user(user).title("Sibling").build(); + + UserDocumentOrder siblingOrder = UserDocumentOrder.builder() + .user(user) + .document(siblingDoc) + .orderKey("a0") + .build(); + UserDocumentOrder otherOrder = UserDocumentOrder.builder() + .user(user) + .document(Document.builder().id(UUID.randomUUID()).build()) + .orderKey("Zz") + .build(); + + DocumentMoveRequest request = new DocumentMoveRequest(null, null, siblingId); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(siblingId)).thenReturn(Optional.of(siblingDoc)); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, siblingId)) + .thenAnswer(invocation -> Optional.of(siblingOrder.getOrderKey())); + when(userDocumentOrderRepository.findMaxOrderKeyLessThan(eq(userId), anyString(), eq(docId))) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findAllForReindex(userId)) + .thenReturn(new java.util.ArrayList<>(List.of(otherOrder, siblingOrder))); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenThrow(new DataIntegrityViolationException("order_key unique violation")) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNotNull(result.orderKey()); + assertEquals("a8", siblingOrder.getOrderKey()); + assertTrue(result.orderKey().compareTo("a0") > 0); + assertTrue(result.orderKey().compareTo("a8") < 0); + verify(userDocumentOrderRepository, times(2)).saveAndFlush(any(UserDocumentOrder.class)); + verify(userDocumentOrderRepository).findAllForReindex(userId); + } + + @Test + void move_collaboratorWithAncestorShareNoDirectRow_reordersSuccessfully() { + UUID docId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder() + .id(docId) + .user(owner) + .title("Shared Doc") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document prevSibling = + Document.builder().id(prevSiblingId).user(owner).title("Prev").build(); + DocumentMoveRequest request = new DocumentMoveRequest(null, prevSiblingId, null); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireReadAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(prevSibling)); + when(permissionService.resolveAccess(userId, prevSiblingId)).thenReturn(DocumentAccessLevel.VIEW); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userDocumentOrderRepository.findOrderKeyByUserIdAndDocumentId(userId, prevSiblingId)) + .thenReturn(Optional.of("a0")); + when(userDocumentOrderRepository.findMinOrderKeyGreaterThan(eq(userId), eq("a0"), eq(docId))) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.findByUser_IdAndDocument_Id(userId, docId)) + .thenReturn(Optional.empty()); + when(userDocumentOrderRepository.saveAndFlush(any(UserDocumentOrder.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); + + DocumentResponse result = documentTreeService.move(userId, docId, request); + + assertNotNull(result.orderKey()); + assertTrue(result.orderKey().compareTo("a0") > 0); + assertEquals(DocumentAccessLevel.VIEW, result.accessLevel()); + } + + @Test + void move_unauthorizedSiblingReference_throwsNotFound() { + UUID docId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + User otherOwner = User.builder().id(UUID.randomUUID()).build(); + + Document doc = Document.builder().id(docId).user(user).title("My Doc").build(); + Document secretSibling = Document.builder() + .id(prevSiblingId) + .user(otherOwner) + .title("Secret") + .build(); + + DocumentMoveRequest request = new DocumentMoveRequest(null, prevSiblingId, null); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireEditAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(secretSibling)); + when(permissionService.resolveAccess(userId, prevSiblingId)).thenReturn(null); + + ApiException ex = assertThrows(ApiException.class, () -> documentTreeService.move(userId, docId, request)); + assertEquals(ErrorCode.NOT_FOUND, ex.getErrorCode()); + } + + @Test + void move_nestedSiblingReferenceForRootMove_throwsValidationFailed() { + UUID docId = UUID.randomUUID(); + UUID prevSiblingId = UUID.randomUUID(); + Document parent = Document.builder().id(UUID.randomUUID()).user(user).build(); + + Document doc = Document.builder().id(docId).user(user).title("My Doc").build(); + Document nestedSibling = Document.builder() + .id(prevSiblingId) + .user(user) + .parent(parent) + .title("Nested Sibling") + .build(); + + DocumentMoveRequest request = new DocumentMoveRequest(null, prevSiblingId, null); + + when(documentRepository.findByIdAndDeletedAtIsNull(docId)).thenReturn(Optional.of(doc)); + when(permissionService.requireEditAccess(userId, docId)).thenReturn(doc); + when(documentRepository.findByIdAndDeletedAtIsNull(prevSiblingId)).thenReturn(Optional.of(nestedSibling)); + + ApiException ex = assertThrows(ApiException.class, () -> documentTreeService.move(userId, docId, request)); + assertEquals(ErrorCode.VALIDATION_FAILED, ex.getErrorCode()); + assertEquals("sibling does not belong to root navigation", ex.getMessage()); + } +} diff --git a/api/src/test/java/com/nextdocs/api/document/service/PermissionServiceTest.java b/api/src/test/java/com/nextdocs/api/document/service/PermissionServiceTest.java new file mode 100644 index 0000000..5c9e425 --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/service/PermissionServiceTest.java @@ -0,0 +1,325 @@ +package com.nextdocs.api.document.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.common.exception.ApiException; +import com.nextdocs.api.common.exception.ErrorCode; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.repository.DocumentRepository; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PermissionServiceTest { + + @Mock + private DocumentRepository documentRepository; + + private PermissionService permissionService; + + @BeforeEach + void setUp() { + permissionService = new PermissionService(documentRepository); + } + + @Test + void resolveAccess_ownerOfDocument_returnsOwner() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + when(documentRepository.resolveEffectiveAccess(userId, documentId)).thenReturn("OWNER"); + + DocumentAccessLevel level = permissionService.resolveAccess(userId, documentId); + + assertEquals(DocumentAccessLevel.OWNER, level); + } + + @Test + void resolveAccess_directCollaboratorWithEdit_returnsEdit() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + when(documentRepository.resolveEffectiveAccess(userId, documentId)).thenReturn("EDIT"); + + DocumentAccessLevel level = permissionService.resolveAccess(userId, documentId); + + assertEquals(DocumentAccessLevel.EDIT, level); + } + + @Test + void resolveAccess_noGrantAnywhere_returnsNull() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + when(documentRepository.resolveEffectiveAccess(userId, documentId)).thenReturn(null); + + DocumentAccessLevel level = permissionService.resolveAccess(userId, documentId); + + assertNull(level); + } + + @Test + void resolveAccess_parentTrashedAndNoDirectGrant_returnsNull() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + // resolve_effective_access returns null because trashed parent is excluded + when(documentRepository.resolveEffectiveAccess(userId, documentId)).thenReturn(null); + + DocumentAccessLevel level = permissionService.resolveAccess(userId, documentId); + + assertNull(level); + } + + @Test + void requireReadAccess_noAccess_throwsNotFound() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document doc = Document.builder().id(documentId).build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(doc)); + when(documentRepository.resolveEffectiveAccess(userId, documentId)).thenReturn(null); + + ApiException exception = + assertThrows(ApiException.class, () -> permissionService.requireReadAccess(userId, documentId)); + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void requireReadAccess_hasAccess_returnsDocument() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document doc = Document.builder().id(documentId).build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(doc)); + when(documentRepository.resolveEffectiveAccess(userId, documentId)).thenReturn("VIEW"); + + Document result = permissionService.requireReadAccess(userId, documentId); + + assertEquals(doc, result); + } + + @Test + void requireEditAccess_viewOnly_throwsForbidden() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document doc = Document.builder().id(documentId).build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(doc)); + when(documentRepository.resolveEffectiveAccess(userId, documentId)).thenReturn("VIEW"); + + ApiException exception = + assertThrows(ApiException.class, () -> permissionService.requireEditAccess(userId, documentId)); + assertEquals(ErrorCode.FORBIDDEN, exception.getErrorCode()); + } + + @Test + void requireEditAccess_hasEdit_returnsDocument() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document doc = Document.builder().id(documentId).build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(doc)); + when(documentRepository.resolveEffectiveAccess(userId, documentId)).thenReturn("EDIT"); + + Document result = permissionService.requireEditAccess(userId, documentId); + + assertEquals(doc, result); + } + + @Test + void requireOwnerAccess_notOwner_throwsNotFound() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + when(documentRepository.findByIdAndUser_IdAndDeletedAtIsNull(documentId, userId)) + .thenReturn(Optional.empty()); + + ApiException exception = + assertThrows(ApiException.class, () -> permissionService.requireOwnerAccess(userId, documentId)); + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void requireOwnerAccess_isOwner_returnsDocument() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + User owner = User.builder().id(userId).build(); + Document doc = Document.builder().id(documentId).user(owner).build(); + + when(documentRepository.findByIdAndUser_IdAndDeletedAtIsNull(documentId, userId)) + .thenReturn(Optional.of(doc)); + + Document result = permissionService.requireOwnerAccess(userId, documentId); + + assertEquals(doc, result); + } + + @Test + void resolveTrashAccess_collaboratorWithEdit_returnsEdit() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + when(documentRepository.resolveTrashAccess(userId, documentId)).thenReturn("EDIT"); + + DocumentAccessLevel level = permissionService.resolveTrashAccess(userId, documentId); + + assertEquals(DocumentAccessLevel.EDIT, level); + } + + @Test + void resolveTrashAccess_noGrantAnywhere_returnsNull() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + when(documentRepository.resolveTrashAccess(userId, documentId)).thenReturn(null); + + assertNull(permissionService.resolveTrashAccess(userId, documentId)); + } + + @Test + void requireTrashEditAccess_missingDocument_throwsNotFound() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + when(documentRepository.findById(documentId)).thenReturn(Optional.empty()); + + ApiException exception = + assertThrows(ApiException.class, () -> permissionService.requireTrashEditAccess(userId, documentId)); + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void requireTrashEditAccess_noAccess_throwsNotFound() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document doc = Document.builder().id(documentId).build(); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(doc)); + when(documentRepository.resolveTrashAccess(userId, documentId)).thenReturn(null); + + ApiException exception = + assertThrows(ApiException.class, () -> permissionService.requireTrashEditAccess(userId, documentId)); + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void requireTrashEditAccess_viewOnly_throwsForbidden() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document doc = Document.builder().id(documentId).build(); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(doc)); + when(documentRepository.resolveTrashAccess(userId, documentId)).thenReturn("VIEW"); + + ApiException exception = + assertThrows(ApiException.class, () -> permissionService.requireTrashEditAccess(userId, documentId)); + assertEquals(ErrorCode.FORBIDDEN, exception.getErrorCode()); + } + + @Test + void requireTrashEditAccess_hasEdit_returnsDocument() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document doc = Document.builder().id(documentId).build(); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(doc)); + when(documentRepository.resolveTrashAccess(userId, documentId)).thenReturn("EDIT"); + + Document result = permissionService.requireTrashEditAccess(userId, documentId); + + assertEquals(doc, result); + } + + @Test + void requireOwnerAccessIncludingTrash_trashedDocumentOwned_returnsDocument() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + User owner = User.builder().id(userId).build(); + Document trashed = Document.builder() + .id(documentId) + .user(owner) + .deletedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)) + .build(); + + when(documentRepository.findByIdAndUser_Id(documentId, userId)).thenReturn(Optional.of(trashed)); + + Document result = permissionService.requireOwnerAccessIncludingTrash(userId, documentId); + + assertEquals(trashed, result); + } + + @Test + void requireOwnerAccessIncludingTrash_notOwner_throwsNotFound() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + when(documentRepository.findByIdAndUser_Id(documentId, userId)).thenReturn(Optional.empty()); + + ApiException exception = assertThrows( + ApiException.class, () -> permissionService.requireOwnerAccessIncludingTrash(userId, documentId)); + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void requireReadAccessOrTrashOwner_activeWithAccess_returnsDocument() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document active = Document.builder().id(documentId).build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(active)); + when(documentRepository.resolveEffectiveAccess(userId, documentId)).thenReturn("VIEW"); + + Document result = permissionService.requireReadAccessOrTrashOwner(userId, documentId); + + assertEquals(active, result); + } + + @Test + void requireReadAccessOrTrashOwner_activeWithoutAccess_throwsNotFound() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document active = Document.builder().id(documentId).build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(active)); + when(documentRepository.resolveEffectiveAccess(userId, documentId)).thenReturn(null); + + ApiException exception = assertThrows( + ApiException.class, () -> permissionService.requireReadAccessOrTrashOwner(userId, documentId)); + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void requireReadAccessOrTrashOwner_trashedOwnedByCaller_returnsDocument() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + User owner = User.builder().id(userId).build(); + Document trashed = Document.builder() + .id(documentId) + .user(owner) + .deletedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)) + .build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.empty()); + when(documentRepository.findByIdAndUser_Id(documentId, userId)).thenReturn(Optional.of(trashed)); + + Document result = permissionService.requireReadAccessOrTrashOwner(userId, documentId); + + assertEquals(trashed, result); + } + + @Test + void requireReadAccessOrTrashOwner_trashedNotOwner_throwsNotFound() { + UUID userId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.empty()); + when(documentRepository.findByIdAndUser_Id(documentId, userId)).thenReturn(Optional.empty()); + + ApiException exception = assertThrows( + ApiException.class, () -> permissionService.requireReadAccessOrTrashOwner(userId, documentId)); + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } +} diff --git a/api/src/test/java/com/nextdocs/api/document/util/FractionalIndexTest.java b/api/src/test/java/com/nextdocs/api/document/util/FractionalIndexTest.java new file mode 100644 index 0000000..c639729 --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/util/FractionalIndexTest.java @@ -0,0 +1,117 @@ +package com.nextdocs.api.document.util; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class FractionalIndexTest { + + @Test + void initialKey_returnsA0() { + assertEquals("a0", FractionalIndex.keyBetween(null, null)); + } + + @Test + void keyAfterEnd() { + String key = FractionalIndex.keyBetween("a0", null); + assertTrue(key.compareTo("a0") > 0); + } + + @Test + void keyBeforeStart() { + String key = FractionalIndex.keyBetween(null, "a0"); + assertTrue(key.compareTo("a0") < 0); + } + + @Test + void keyBetweenTwoKeys() { + String key = FractionalIndex.keyBetween("a0", "a1"); + assertTrue(key.compareTo("a0") > 0); + assertTrue(key.compareTo("a1") < 0); + } + + @Test + void repeatedInsertAtSameGap_noCollision() { + String prev = "a0"; + String next = "a1"; + for (int i = 0; i < 1000; i++) { + String key = FractionalIndex.keyBetween(prev, next); + assertNotNull(key); + assertTrue(key.compareTo(prev) > 0); + assertTrue(key.compareTo(next) < 0); + prev = key; + } + } + + @Test + void repeatedInsertAtSameGap_keyLengthGrowsSlowly() { + String prev = "a0"; + String next = "a1"; + int maxLength = 0; + for (int i = 0; i < 1000; i++) { + String key = FractionalIndex.keyBetween(prev, next); + assertTrue(key.compareTo(prev) > 0, "Key must be greater than prev"); + assertTrue(key.compareTo(next) < 0, "Key must be less than next"); + maxLength = Math.max(maxLength, key.length()); + prev = key; + } + assertTrue(maxLength < 300, "Max key length after 1000 inserts: " + maxLength); + } + + @Test + void nKeysBetween_generatesCorrectCount() { + String[] keys = FractionalIndex.nKeysBetween("a0", "a5", 3); + assertEquals(3, keys.length); + for (String key : keys) { + assertTrue(key.compareTo("a0") > 0); + assertTrue(key.compareTo("a5") < 0); + } + assertTrue(keys[0].compareTo(keys[1]) < 0); + assertTrue(keys[1].compareTo(keys[2]) < 0); + } + + @Test + void nKeysBetweenSpaced_leavesGapsBetweenKeys() { + String[] keys = FractionalIndex.nKeysBetweenSpaced(null, null, 4, 8); + assertEquals(4, keys.length); + for (int i = 0; i < keys.length - 1; i++) { + assertTrue(keys[i].compareTo(keys[i + 1]) < 0); + } + String[] packed = FractionalIndex.nKeysBetween(null, null, 4); + assertTrue(packed[1].compareTo(keys[1]) < 0, "spaced keys must be farther apart than packed keys"); + } + + @Test + void nKeysBetweenSpaced_frontInsertAfterReindex_landsInGap() { + String[] keys = FractionalIndex.nKeysBetweenSpaced(null, null, 3, 8); + String insert = FractionalIndex.keyBetween(null, keys[1]); + assertTrue(insert.compareTo(keys[0]) > 0, "insert must land after the first reindexed key"); + assertTrue(insert.compareTo(keys[1]) < 0, "insert must land before the reindexed neighbor"); + } + + @Test + void nKeysBetweenSpaced_emptyRequest_returnsNoKeys() { + assertEquals(0, FractionalIndex.nKeysBetweenSpaced(null, null, 0, 8).length); + } + + @Test + void nKeysBetweenSpaced_invalidGap_throwsException() { + assertThrows(IllegalArgumentException.class, () -> FractionalIndex.nKeysBetweenSpaced(null, null, 3, 0)); + } + + @Test + void invalidInput_reversedOrder_throwsException() { + assertThrows(IllegalArgumentException.class, () -> FractionalIndex.keyBetween("a1", "a0")); + } + + @Test + void invalidInput_sameKeys_throwsException() { + assertThrows(IllegalArgumentException.class, () -> FractionalIndex.keyBetween("a0", "a0")); + } + + @Test + void invalidInput_corruptedKey_throwsException() { + assertThrows(IllegalArgumentException.class, () -> FractionalIndex.keyBetween("2026-07-27", "a0")); + assertThrows(IllegalArgumentException.class, () -> FractionalIndex.keyBetween("a0", "a00")); + } +} diff --git a/package-lock.json b/package-lock.json index 112275c..fb2fed9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -796,6 +796,60 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emoji-mart/data": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", @@ -16073,6 +16127,9 @@ "@blocknote/core": "^0.51.4", "@blocknote/react": "^0.51.4", "@blocknote/shadcn": "^0.51.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.12.0", "idb": "^8.0.3", diff --git a/realtime/jest.config.js b/realtime/jest.config.js index 0d25972..a543157 100644 --- a/realtime/jest.config.js +++ b/realtime/jest.config.js @@ -1,11 +1,13 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ export default { - preset: 'ts-jest', + preset: 'ts-jest/presets/default-esm', testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], transform: { '^.+\\.tsx?$': [ 'ts-jest', { + useESM: true, tsconfig: 'tsconfig.test.json', }, ], diff --git a/realtime/package.json b/realtime/package.json index 05a2b86..279431c 100644 --- a/realtime/package.json +++ b/realtime/package.json @@ -21,8 +21,8 @@ "build": "tsc", "start": "node dist/main.js", "dev": "tsx src/main.ts", - "test": "jest", - "test:coverage": "jest --coverage", + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --coverage", "lint": "eslint", "lint:fix": "eslint --fix", "format": "prettier --check \"**/*.{ts,js,json,md}\"", diff --git a/realtime/tests/integration/lifecycle.test.ts b/realtime/tests/integration/lifecycle.test.ts index 7c9314d..07b5360 100644 --- a/realtime/tests/integration/lifecycle.test.ts +++ b/realtime/tests/integration/lifecycle.test.ts @@ -1,6 +1,12 @@ import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import net, { AddressInfo } from 'net'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); const SERVER_PATH = path.join(__dirname, '../../src/main.ts'); const TSX_PATH = path.resolve(path.dirname(require.resolve('tsx/package.json')), 'dist/cli.mjs'); diff --git a/realtime/tests/unit/config.test.ts b/realtime/tests/unit/config.test.ts index 695087e..bfcf37f 100644 --- a/realtime/tests/unit/config.test.ts +++ b/realtime/tests/unit/config.test.ts @@ -21,7 +21,7 @@ describe('Config', () => { delete process.env.ROOM_INACTIVE_TIMEOUT; delete process.env.ACCESS_REVALIDATION_INTERVAL_MS; - const config = (await import('../../src/config')).default; + const config = (await import('../../src/config.js')).default; expect(config.port).toBe(1234); expect(config.host).toBe('0.0.0.0'); @@ -41,7 +41,7 @@ describe('Config', () => { process.env.ROOM_INACTIVE_TIMEOUT = '120000'; process.env.ACCESS_REVALIDATION_INTERVAL_MS = '15000'; - const config = (await import('../../src/config')).default; + const config = (await import('../../src/config.js')).default; expect(config.port).toBe(8080); expect(config.host).toBe('127.0.0.1'); @@ -54,80 +54,80 @@ describe('Config', () => { it('should trim CORS origins', async () => { process.env.CORS_ORIGINS = ' http://example.com , http://test.com '; - const config = (await import('../../src/config')).default; + const config = (await import('../../src/config.js')).default; expect(config.corsOrigins).toEqual(['http://example.com', 'http://test.com']); }); it('should filter out empty CORS origins', async () => { process.env.CORS_ORIGINS = 'http://example.com,, ,http://test.com'; - const config = (await import('../../src/config')).default; + const config = (await import('../../src/config.js')).default; expect(config.corsOrigins).toEqual(['http://example.com', 'http://test.com']); }); it('should fallback to default log level for invalid value', async () => { process.env.LOG_LEVEL = 'invalid_level'; - const config = (await import('../../src/config')).default; + const config = (await import('../../src/config.js')).default; expect(config.logLevel).toBe('info'); }); it('should throw error for invalid PORT', async () => { process.env.PORT = '-1'; - await expect(import('../../src/config')).rejects.toThrow('Invalid PORT'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid PORT'); resetEnv(); process.env.PORT = '70000'; - await expect(import('../../src/config')).rejects.toThrow('Invalid PORT'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid PORT'); resetEnv(); process.env.PORT = 'abc'; - await expect(import('../../src/config')).rejects.toThrow('Invalid PORT'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid PORT'); }); it('should throw error for invalid ROOM_CLEANUP_INTERVAL', async () => { process.env.ROOM_CLEANUP_INTERVAL = '-5'; - await expect(import('../../src/config')).rejects.toThrow('Invalid ROOM_CLEANUP_INTERVAL'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid ROOM_CLEANUP_INTERVAL'); }); it('should throw error for invalid ROOM_INACTIVE_TIMEOUT', async () => { process.env.ROOM_INACTIVE_TIMEOUT = '0'; - await expect(import('../../src/config')).rejects.toThrow('Invalid ROOM_INACTIVE_TIMEOUT'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid ROOM_INACTIVE_TIMEOUT'); }); it('should throw error for invalid ACCESS_REVALIDATION_INTERVAL_MS', async () => { process.env.ACCESS_REVALIDATION_INTERVAL_MS = '0'; - await expect(import('../../src/config')).rejects.toThrow( + await expect(import('../../src/config.js')).rejects.toThrow( 'Invalid ACCESS_REVALIDATION_INTERVAL_MS' ); }); it('should throw error for invalid MAX_PAYLOAD', async () => { process.env.MAX_PAYLOAD = '-1'; - await expect(import('../../src/config')).rejects.toThrow('Invalid MAX_PAYLOAD'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid MAX_PAYLOAD'); }); it('should throw error for invalid MAX_CONNS_PER_IP', async () => { process.env.MAX_CONNS_PER_IP = '0'; - await expect(import('../../src/config')).rejects.toThrow('Invalid MAX_CONNS_PER_IP'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid MAX_CONNS_PER_IP'); }); it('should throw error for invalid MAX_GLOBAL_CONNS', async () => { process.env.MAX_GLOBAL_CONNS = '-100'; - await expect(import('../../src/config')).rejects.toThrow('Invalid MAX_GLOBAL_CONNS'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid MAX_GLOBAL_CONNS'); }); it('should throw error for invalid MAX_CONN_RATE_PER_MIN', async () => { process.env.MAX_CONN_RATE_PER_MIN = '0'; - await expect(import('../../src/config')).rejects.toThrow('Invalid MAX_CONN_RATE_PER_MIN'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid MAX_CONN_RATE_PER_MIN'); }); it('should throw error for invalid MAX_MSG_RATE_PER_SEC', async () => { process.env.MAX_MSG_RATE_PER_SEC = '-10'; - await expect(import('../../src/config')).rejects.toThrow('Invalid MAX_MSG_RATE_PER_SEC'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid MAX_MSG_RATE_PER_SEC'); }); it('should throw error for invalid MEMORY_THRESHOLD', async () => { process.env.MEMORY_THRESHOLD = 'invalid'; - await expect(import('../../src/config')).rejects.toThrow('Invalid MEMORY_THRESHOLD'); + await expect(import('../../src/config.js')).rejects.toThrow('Invalid MEMORY_THRESHOLD'); }); function resetEnv() { diff --git a/realtime/tests/unit/logger.test.ts b/realtime/tests/unit/logger.test.ts index beb3bff..e8fab24 100644 --- a/realtime/tests/unit/logger.test.ts +++ b/realtime/tests/unit/logger.test.ts @@ -1,14 +1,14 @@ import { jest } from '@jest/globals'; -// Mock config module before importing logger -jest.mock('../../src/config', () => ({ +// Mock config module before importing logger (ESM) +jest.unstable_mockModule('../../src/config.js', () => ({ __esModule: true, default: { logLevel: 'debug', // Default to debug for testing all levels }, })); -import logger from '../../src/logger'; +const { default: logger } = await import('../../src/logger.js'); describe('Logger', () => { let consoleSpy: { @@ -68,12 +68,12 @@ describe('Logger', () => { describe('Log Levels', () => { it('should not log debug when level is info', async () => { jest.resetModules(); - jest.doMock('../../src/config', () => ({ + jest.unstable_mockModule('../../src/config.js', () => ({ __esModule: true, default: { logLevel: 'info' }, })); - const { default: loggerInfo } = await import('../../src/logger'); + const { default: loggerInfo } = await import('../../src/logger.js'); loggerInfo.debug('debug message'); expect(consoleSpy.log).not.toHaveBeenCalled(); @@ -84,11 +84,11 @@ describe('Logger', () => { it('should not log info when level is warn', async () => { jest.resetModules(); - jest.doMock('../../src/config', () => ({ + jest.unstable_mockModule('../../src/config.js', () => ({ __esModule: true, default: { logLevel: 'warn' }, })); - const { default: loggerWarn } = await import('../../src/logger'); + const { default: loggerWarn } = await import('../../src/logger.js'); loggerWarn.info('info message'); expect(consoleSpy.log).not.toHaveBeenCalled(); @@ -99,11 +99,11 @@ describe('Logger', () => { it('should not log warn when level is error', async () => { jest.resetModules(); - jest.doMock('../../src/config', () => ({ + jest.unstable_mockModule('../../src/config.js', () => ({ __esModule: true, default: { logLevel: 'error' }, })); - const { default: loggerError } = await import('../../src/logger'); + const { default: loggerError } = await import('../../src/logger.js'); loggerError.warn('warn message'); expect(consoleSpy.warn).not.toHaveBeenCalled(); diff --git a/realtime/tests/unit/server.test.ts b/realtime/tests/unit/server.test.ts index 49c4d10..f2d6bb5 100644 --- a/realtime/tests/unit/server.test.ts +++ b/realtime/tests/unit/server.test.ts @@ -2,66 +2,10 @@ import { jest } from '@jest/globals'; import request from 'supertest'; import { EventEmitter } from 'events'; -jest.mock('ws', () => { - class MockWebSocketServer extends EventEmitter { - clients = { size: 0 }; - close = jest.fn(); - constructor() { - super(); - } - } - return { - WebSocketServer: MockWebSocketServer, - WebSocket: { OPEN: 1 }, - }; -}); - -jest.mock('../../src/logger', () => ({ - __esModule: true, - default: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - }, -})); - -jest.mock('../../src/yjs-utils', () => ({ - __esModule: true, - setupWSConnection: jest.fn(), - updateConnectionAccessLevel: jest.fn(), -})); - -jest.mock('../../src/config', () => ({ - __esModule: true, - default: { - port: 1234, - host: '0.0.0.0', - apiBaseUrl: 'http://localhost:8080', - corsOrigins: ['*'], - logLevel: 'info', - roomCleanupInterval: 300000, - roomInactiveTimeout: 3600000, - accessRevalidationIntervalMs: 5000, - fetchTimeoutMs: 5000, - unauthorizedAccessCooldownMs: 15000, - unauthorizedAccessWarnIntervalMs: 10000, - enforceMemoryThreshold: false, - limits: { - maxPayload: 5 * 1024 * 1024, - maxConnsPerIp: 200, - maxGlobalConns: 10000, - maxConnRatePerMin: 100, - maxMsgRatePerSec: 100, - memoryThreshold: 0.95, - }, - }, -})); - -import { WebSocket } from 'ws'; - const VALID_ROOM_ID = '11111111-1111-1111-1111-111111111111'; +const WS_OPEN = 1; + const waitForConnectionProcessing = async () => { await Promise.resolve(); await Promise.resolve(); @@ -78,6 +22,62 @@ describe('Server', () => { beforeEach(async () => { jest.resetModules(); + await jest.unstable_mockModule('ws', () => { + class MockWebSocketServer extends EventEmitter { + clients: any = { size: 0 }; + close = jest.fn(); + constructor() { + super(); + } + } + return { + WebSocketServer: MockWebSocketServer, + WebSocket: { OPEN: 1 }, + }; + }); + + await jest.unstable_mockModule('../../src/logger.js', () => ({ + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + })); + + await jest.unstable_mockModule('../../src/yjs-utils.js', () => ({ + __esModule: true, + setupWSConnection: jest.fn(), + updateConnectionAccessLevel: jest.fn(), + })); + + await jest.unstable_mockModule('../../src/config.js', () => ({ + __esModule: true, + default: { + port: 1234, + host: '0.0.0.0', + apiBaseUrl: 'http://localhost:8080', + corsOrigins: ['*'], + logLevel: 'info', + roomCleanupInterval: 300000, + roomInactiveTimeout: 3600000, + accessRevalidationIntervalMs: 5000, + fetchTimeoutMs: 5000, + unauthorizedAccessCooldownMs: 15000, + unauthorizedAccessWarnIntervalMs: 10000, + enforceMemoryThreshold: false, + limits: { + maxPayload: 5 * 1024 * 1024, + maxConnsPerIp: 200, + maxGlobalConns: 10000, + maxConnRatePerMin: 100, + maxMsgRatePerSec: 100, + memoryThreshold: 0.95, + }, + }, + })); + fetchMock = jest.fn() as jest.MockedFunction; fetchMock.mockResolvedValue({ ok: true, @@ -101,12 +101,12 @@ describe('Server', () => { arrayBuffers: 0, } as NodeJS.MemoryUsage); - const serverModule = await import('../../src/server'); + const serverModule = await import('../../src/server.js'); server = serverModule.server; wss = serverModule.wss; cleanupInactiveRooms = serverModule.cleanupInactiveRooms; - const yjsUtilsModule = await import('../../src/yjs-utils'); + const yjsUtilsModule = await import('../../src/yjs-utils.js'); setupWSConnectionMock = yjsUtilsModule.setupWSConnection; }); @@ -150,7 +150,7 @@ describe('Server', () => { }; mockConn = new EventEmitter(); (mockConn as any).close = jest.fn(); - (mockConn as any).readyState = WebSocket.OPEN; + (mockConn as any).readyState = WS_OPEN; wss.clients.size = 0; }); @@ -235,7 +235,7 @@ describe('Server', () => { const firstConn: any = new EventEmitter(); firstConn.close = jest.fn(); - firstConn.readyState = WebSocket.OPEN; + firstConn.readyState = WS_OPEN; wss.emit('connection', firstConn, mockReq); await waitForConnectionProcessing(); @@ -244,7 +244,7 @@ describe('Server', () => { const secondConn: any = new EventEmitter(); secondConn.close = jest.fn(); - secondConn.readyState = WebSocket.OPEN; + secondConn.readyState = WS_OPEN; wss.emit('connection', secondConn, mockReq); await waitForConnectionProcessing(); @@ -255,7 +255,7 @@ describe('Server', () => { const thirdConn: any = new EventEmitter(); thirdConn.close = jest.fn(); - thirdConn.readyState = WebSocket.OPEN; + thirdConn.readyState = WS_OPEN; wss.emit('connection', thirdConn, mockReq); await waitForConnectionProcessing(); @@ -284,7 +284,7 @@ describe('Server', () => { const firstConn: any = new EventEmitter(); firstConn.close = jest.fn(); - firstConn.readyState = WebSocket.OPEN; + firstConn.readyState = WS_OPEN; wss.emit('connection', firstConn, mockReq); await waitForConnectionProcessing(); @@ -295,7 +295,7 @@ describe('Server', () => { const secondConn: any = new EventEmitter(); secondConn.close = jest.fn(); - secondConn.readyState = WebSocket.OPEN; + secondConn.readyState = WS_OPEN; wss.emit('connection', secondConn, mockReq); await waitForConnectionProcessing(); @@ -306,7 +306,7 @@ describe('Server', () => { const thirdConn: any = new EventEmitter(); thirdConn.close = jest.fn(); - thirdConn.readyState = WebSocket.OPEN; + thirdConn.readyState = WS_OPEN; wss.emit('connection', thirdConn, mockReq); await waitForConnectionProcessing(); @@ -357,7 +357,7 @@ describe('Server', () => { }; mockConn = new EventEmitter(); (mockConn as any).close = jest.fn(); - (mockConn as any).readyState = WebSocket.OPEN; + (mockConn as any).readyState = WS_OPEN; wss.clients.size = 0; }); @@ -475,7 +475,7 @@ describe('Server', () => { }; mockConn = new EventEmitter(); (mockConn as any).close = jest.fn(); - (mockConn as any).readyState = WebSocket.OPEN; + (mockConn as any).readyState = WS_OPEN; wss.clients.size = 0; }); diff --git a/realtime/tests/unit/yjs-utils.test.ts b/realtime/tests/unit/yjs-utils.test.ts index 9416a43..e8a3061 100644 --- a/realtime/tests/unit/yjs-utils.test.ts +++ b/realtime/tests/unit/yjs-utils.test.ts @@ -4,10 +4,9 @@ import * as encoding from 'lib0/encoding'; import * as decoding from 'lib0/decoding'; import * as Y from 'yjs'; import { WebSocket } from 'ws'; -import logger from '../../src/logger'; -// Mock logger to avoid console output during tests -jest.mock('../../src/logger', () => ({ +// Mock logger to avoid console output during tests (ESM) +jest.unstable_mockModule('../../src/logger.js', () => ({ __esModule: true, default: { debug: jest.fn(), @@ -17,12 +16,10 @@ jest.mock('../../src/logger', () => ({ }, })); -import { - setupWSConnection, - updateConnectionAccessLevel, - docs, - getDocsStats, -} from '../../src/yjs-utils'; +const { default: logger } = await import('../../src/logger.js'); + +const { setupWSConnection, updateConnectionAccessLevel, docs, getDocsStats } = + await import('../../src/yjs-utils.js'); describe('Yjs Utils', () => { let mockConn: any; diff --git a/realtime/tsconfig.test.json b/realtime/tsconfig.test.json index 9489fa6..dfd161b 100644 --- a/realtime/tsconfig.test.json +++ b/realtime/tsconfig.test.json @@ -1,7 +1,6 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "module": "CommonJS", "types": ["node", "jest"], "rootDir": "." }, diff --git a/web/components/DocToolbar.tsx b/web/components/DocToolbar.tsx index de86627..909c50e 100644 --- a/web/components/DocToolbar.tsx +++ b/web/components/DocToolbar.tsx @@ -1,8 +1,13 @@ 'use client'; -import { useRef, useState, useCallback } from 'react'; +import { useRef, useState, useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { SharePanel } from '@/components/SharePanel'; -import { Comments, Globe } from '@/icons/index'; +import { Comments, Globe, MoreHorizontal } from '@/icons/index'; +import { useAppSelector } from '@/stores/hooks'; +import { useDocumentBreadcrumbs } from '@/hooks/useDocumentBreadcrumbs.hook'; +import { OFFLINE_DOCUMENT_SELECT_EVENT } from '@/lib/offline-navigation.util'; +import type { DocumentBreadcrumbItem } from '@/services/document.service'; function formatLastEdited(dateStr: string | undefined | null): string { if (!dateStr) return ''; @@ -29,8 +34,14 @@ function formatLastEdited(dateStr: string | undefined | null): string { } interface DocToolbarProps { - /** documentId used for share panel */ + /** documentId used for share panel and hierarchy */ documentId: string; + /** Current document title for live breadcrumb updates */ + documentTitle?: string; + /** Current document icon (reserved for future icon/cover support) */ + documentIcon?: string | null; + /** Optional navigation callback */ + onNavigateDocument?: (id: string) => void; /** Whether the authenticated user can open the share panel */ isShareEnabled: boolean; /** ISO string of when the document was last edited */ @@ -53,12 +64,17 @@ interface DocToolbarProps { onGuestNoticeCtaClick?: () => void; /** Whether to show a trash notice in the top toolbar */ showTrashNotice?: boolean; + /** Whether the viewer may restore/purge the trashed document (EDIT access or owner) */ + canManageTrash?: boolean; /** Callback to restore the document from trash */ onRestore?: () => void; } export function DocToolbar({ documentId, + documentTitle, + documentIcon, + onNavigateDocument, isShareEnabled, updatedAt, isOffline, @@ -70,16 +86,74 @@ export function DocToolbar({ showGuestNotice = false, onGuestNoticeCtaClick, showTrashNotice = false, + canManageTrash = false, onRestore, }: DocToolbarProps) { + const router = useRouter(); const [isShareOpen, setIsShareOpen] = useState(false); const [showOfflineTooltip, setShowOfflineTooltip] = useState(false); + const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false); const shareButtonRef = useRef(null); + const overflowMenuRef = useRef(null); + + const isSidebarCollapsed = useAppSelector((state) => state?.sidebar?.isCollapsed ?? false); + + // Hook to get full document hierarchy (reactive to live title edits and tree updates) + const { breadcrumbs } = useDocumentBreadcrumbs(documentId, documentTitle, documentIcon); const handleShareToggle = useCallback(() => { setIsShareOpen((prev) => !prev); }, []); + const handleNavigate = useCallback( + (targetId: string) => { + if (targetId === documentId) return; + setIsOverflowMenuOpen(false); + + if (onNavigateDocument) { + onNavigateDocument(targetId); + return; + } + + if (isOffline || (typeof window !== 'undefined' && !navigator.onLine)) { + window.dispatchEvent( + new CustomEvent(OFFLINE_DOCUMENT_SELECT_EVENT, { + detail: { id: targetId }, + }) + ); + return; + } + + router.push(`/doc/${targetId}`); + }, + [documentId, onNavigateDocument, isOffline, router] + ); + + // Close overflow dropdown on outside click or escape + useEffect(() => { + if (!isOverflowMenuOpen) return; + + const handleOutsideClick = (e: MouseEvent) => { + if (overflowMenuRef.current && !overflowMenuRef.current.contains(e.target as Node)) { + setIsOverflowMenuOpen(false); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOverflowMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleOutsideClick); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousedown', handleOutsideClick); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOverflowMenuOpen]); + const lastEditedLabel = formatLastEdited(updatedAt); const offlineTooltipId = 'doc-offline-tooltip'; const offlineTooltipText = @@ -94,97 +168,333 @@ export function DocToolbar({ const shouldShowGuestNotice = showGuestNotice && !!onGuestNoticeCtaClick; const isOfflineTooltipOpen = isOffline && showOfflineTooltip; + // Breadcrumbs rendering: + // When hierarchy is > 3 levels: [First Root] / [...] / [Immediate Parent] / [Current Doc] + const shouldCollapseBreadcrumbs = breadcrumbs.length > 3; + const rootItem = shouldCollapseBreadcrumbs ? breadcrumbs[0] : null; + const intermediateItems = shouldCollapseBreadcrumbs ? breadcrumbs.slice(1, -2) : []; + const parentItem = shouldCollapseBreadcrumbs ? breadcrumbs[breadcrumbs.length - 2] : null; + const currentItem = + breadcrumbs.length > 0 + ? breadcrumbs[breadcrumbs.length - 1] + : { id: documentId, title: documentTitle || 'Untitled' }; + + // Left offset tracking: aligns pixel-perfectly with left sidebar edge + const leftPositionStyle = { + left: isSidebarCollapsed + ? 'calc(3.25rem + 12px)' + : 'calc(var(--nd-sidebar-width, 256px) + 12px)', + }; + return ( <> - {/* ── Offline badge (top-left, only when offline) ── */} - {isOffline && ( -

setShowOfflineTooltip(true)} - onMouseLeave={() => setShowOfflineTooltip(false)} - onFocus={() => setShowOfflineTooltip(true)} - onBlur={() => setShowOfflineTooltip(false)} - onClick={() => setShowOfflineTooltip((prev) => !prev)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - setShowOfflineTooltip(true); - } - if (event.key === 'Escape') { - setShowOfflineTooltip(false); - } - }} - > + {/* ── Left most toolbar: Document Hierarchy Breadcrumbs (Notion-style) ── */} +
+ {/* + Note: Document icon and cover image are not yet introduced into the entity model. + For now, we display document titles only in the hierarchy breadcrumbs. + When the icon/cover feature is introduced in the future, icons can be rendered here directly alongside titles. + */} + {shouldCollapseBreadcrumbs ? ( + <> + {/* Root Document */} + {rootItem && ( + + )} + + + + {/* Collapsed Ellipsis with Dropdown */} +
+ + + {isOverflowMenuOpen && ( +
+ {intermediateItems.map((item: DocumentBreadcrumbItem) => ( + + ))} +
+ )} +
+ + + + {/* Immediate Parent Document */} + {parentItem && ( + + )} + + + + {/* Current Active Document */} + + {currentItem.title || 'Untitled'} + + + ) : ( + breadcrumbs.map((item, index) => { + const isCurrent = index === breadcrumbs.length - 1; + return ( + + {isCurrent ? ( + + {item.title || 'Untitled'} + + ) : ( + <> + + + + )} + + ); + }) + )} + + {/* ── Offline badge ── */} + {isOffline && (
setShowOfflineTooltip(true)} + onMouseLeave={() => setShowOfflineTooltip(false)} + onFocus={() => setShowOfflineTooltip(true)} + onBlur={() => setShowOfflineTooltip(false)} + onClick={() => setShowOfflineTooltip((prev) => !prev)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setShowOfflineTooltip(true); + } + if (event.key === 'Escape') { + setShowOfflineTooltip(false); + } + }} > -
- - {isOfflineTooltipOpen && ( - )} -
- )} + + {isOfflineTooltipOpen && ( + + )} +
+ )} + {/* ── Top-right toolbar ── */} -
- {showTrashNotice && onRestore && ( +
+ {showTrashNotice && canManageTrash && (
This document is in the trash. - - it to make edits. + {onRestore && ( + <> + + it to make edits. + + )} +
+ )} + + {showTrashNotice && !canManageTrash && ( +
+ + This document is in the owner's trash. You have read-only access and can view it, + but only people with edit access can restore or delete it. +
)} @@ -193,7 +503,7 @@ export function DocToolbar({ className=" inline-flex items-center gap-1.5 rounded-lg border border-border/70 bg-background/85 backdrop-blur-sm - px-3 py-1.5 + px-3 py-1 text-[12px] text-muted-foreground shadow-sm whitespace-nowrap @@ -234,7 +544,7 @@ export function DocToolbar({ aria-pressed={isCommentsSidebarOpen} aria-label={`${isCommentsSidebarOpen ? 'Close' : 'Open'} comments sidebar. ${commentsSummary}.`} className={[ - 'inline-flex items-center justify-center rounded-sm py-[5px] px-2 transition-colors duration-150', + 'inline-flex items-center justify-center rounded-sm py-[5px] px-2 transition-colors duration-150 h-7', isCommentsButtonDisabled ? 'text-muted-foreground opacity-60 cursor-not-allowed' : isCommentsSidebarOpen @@ -262,7 +572,7 @@ export function DocToolbar({ text-[12.5px] font-medium leading-none text-foreground transition-colors duration-150 hover:bg-[var(--nd-toolbar-hover-bg)] - cursor-pointer + cursor-pointer h-7 " > diff --git a/web/components/editor/Editor.tsx b/web/components/editor/Editor.tsx index b440b8a..fde3c45 100644 --- a/web/components/editor/Editor.tsx +++ b/web/components/editor/Editor.tsx @@ -54,6 +54,10 @@ export default function Editor() { >({}); const isGuestSharedView = !isAuthenticated && accessLevel === 'VIEW'; const isOffline = !isOnline; + const isTrashedDocument = !!meta?.deletedAt; + // Only EDIT holders and owners may restore/purge; viewers and commenters get the + // read-only trash notice instead. + const canManageTrash = isTrashedDocument && (accessLevel === 'EDIT' || accessLevel === 'OWNER'); const { pendingEdits } = useYjsPersistence( documentId, ydoc, @@ -188,13 +192,16 @@ export default function Editor() { <> void; onClose: () => void; isLoadingInitial: boolean; + /** Flat list — used only by the trash panel. */ filteredDocuments: SidebarSectionDocument[]; + /** Full list of trashed documents (unfiltered) for accurate parent hierarchy checks */ + trashedDocuments?: SidebarSectionDocument[]; + /** Tree data — used by the private and shared panels. */ + treeApi: TreeApi | null; + /** Node ids (e.g. shared documents) that must not render inside this tree. */ + excludedNodeIds?: ReadonlySet; + visibleRootIds: string[]; + /** When searching, the set of node ids that should be rendered. */ + visibleIds: ReadonlySet | null; + isSearching: boolean; + onCreateChild: (parentId: string) => void; + rootHasMore: boolean; + isLoadingRootMore: boolean; + onLoadMoreRoots: () => void; activeDocId: string; isAuthenticated: boolean; accessToken: string | null; @@ -33,6 +51,7 @@ export type DocumentsPanelProps = { actionType: DocActionType ) => void; resolvePanelActionType: (doc: SidebarSectionDocument) => DocActionType; + resolvePanelTreeActionType: (documentId: string) => DocActionType; setDocActionsAnchor: (anchor: DocActionsAnchor | null) => void; hasMore: boolean; isLoadingMore: boolean; @@ -48,6 +67,16 @@ export function DocumentsPanel({ onClose, isLoadingInitial, filteredDocuments, + trashedDocuments: trashedDocumentsProp, + treeApi, + excludedNodeIds, + visibleRootIds, + visibleIds, + isSearching, + onCreateChild, + rootHasMore, + isLoadingRootMore, + onLoadMoreRoots, activeDocId, isAuthenticated, accessToken, @@ -59,16 +88,19 @@ export function DocumentsPanel({ docActionsAnchor, onToggleDocumentActions, resolvePanelActionType, + resolvePanelTreeActionType, setDocActionsAnchor, hasMore, isLoadingMore, onLoadMore, }: DocumentsPanelProps) { const documentsPanelScrollRef = useRef(null); - const documentsPanelSentinelRef = useRef(null); + const treeSentinelRef = useRef(null); + const trashSentinelRef = useRef(null); const isTrashPanel = mode === 'trash'; const isSharedPanel = mode === 'shared'; + const isTreePanel = !isTrashPanel; // Handle Escape key to close the panel useEffect(() => { @@ -84,9 +116,9 @@ export function DocumentsPanel({ }; }, [onClose]); - // Infinite scroll IntersectionObserver + // Infinite scroll IntersectionObserver (flat trash list) useEffect(() => { - if (!hasMore || isLoadingInitial || isLoadingMore) { + if (!isTrashPanel || !hasMore || isLoadingInitial || isLoadingMore) { return; } @@ -95,7 +127,7 @@ export function DocumentsPanel({ } const root = documentsPanelScrollRef.current; - const target = documentsPanelSentinelRef.current; + const target = trashSentinelRef.current; if (!root || !target) { return; } @@ -119,23 +151,112 @@ export function DocumentsPanel({ return () => { observer.disconnect(); }; - }, [hasMore, isLoadingInitial, isLoadingMore, onLoadMore, filteredDocuments.length]); + }, [ + isTrashPanel, + hasMore, + isLoadingInitial, + isLoadingMore, + onLoadMore, + filteredDocuments.length, + ]); - // Keep fetching pages while list does not fill the panel viewport yet + // Infinite scroll for the tree panels (next page of root documents) useEffect(() => { - if (!hasMore || isLoadingInitial || isLoadingMore) { + if (!isTreePanel || !rootHasMore || isLoadingRootMore) { + return; + } + + if (typeof IntersectionObserver === 'undefined') { + return; + } + + const root = documentsPanelScrollRef.current; + const target = treeSentinelRef.current; + if (!root || !target) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const isIntersecting = entries.some((entry) => entry.isIntersecting); + if (isIntersecting && !isLoadingRootMore) { + onLoadMoreRoots(); + } + }, + { + root, + rootMargin: '120px', + threshold: 0.1, + } + ); + + observer.observe(target); + + return () => { + observer.disconnect(); + }; + }, [isTreePanel, rootHasMore, isLoadingRootMore, onLoadMoreRoots, visibleRootIds.length]); + + // Keep fetching pages while the list does not fill the panel viewport yet + useEffect(() => { + if (!isTrashPanel || !hasMore || isLoadingInitial || isLoadingMore) { return; } const container = documentsPanelScrollRef.current; - if (!container) { + if (!container || container.clientHeight === 0) { return; } if (container.scrollHeight <= container.clientHeight + 24) { void onLoadMore(); } - }, [hasMore, isLoadingInitial, isLoadingMore, onLoadMore, filteredDocuments.length]); + }, [ + isTrashPanel, + hasMore, + isLoadingInitial, + isLoadingMore, + onLoadMore, + filteredDocuments.length, + ]); + + // Keep fetching root pages while the tree does not fill the panel viewport yet + useEffect(() => { + if (!isTreePanel || !rootHasMore || isLoadingRootMore) { + return; + } + + const container = documentsPanelScrollRef.current; + if (!container || container.clientHeight === 0) { + return; + } + + if (container.scrollHeight <= container.clientHeight + 24) { + onLoadMoreRoots(); + } + }, [isTreePanel, rootHasMore, isLoadingRootMore, onLoadMoreRoots, visibleRootIds.length]); + + const trashedDocIds = useMemo(() => { + if (!isTrashPanel) { + return new Set(); + } + const source = trashedDocumentsProp ?? filteredDocuments; + return new Set(source.map((d) => d.id)); + }, [isTrashPanel, trashedDocumentsProp, filteredDocuments]); + + const renderEmptyState = () => ( +
+

+ {searchQuery + ? 'No documents match your search.' + : isTrashPanel + ? 'No documents in trash.' + : isSharedPanel + ? 'No shared documents yet.' + : 'No documents yet.'} +

+
+ ); return (
{isLoadingInitial ? ( + ) : isTreePanel && treeApi ? ( + visibleRootIds.length === 0 ? ( + renderEmptyState() + ) : ( + +
    + {visibleRootIds.map((rootId) => ( + { + setDocActionsAnchor(null); + onSelectDocument(id); + }} + onCreateChild={onCreateChild} + isActionsEnabled={isAuthenticated && Boolean(accessToken)} + docActionsAnchor={docActionsAnchor} + onToggleDocumentActions={onToggleDocumentActions} + resolveActionType={resolvePanelTreeActionType} + excludedNodeIds={excludedNodeIds} + reorderEnabled={isSharedPanel} + /> + ))} + + {isLoadingRootMore && ( +
  • + +
  • + )} + + {rootHasMore && !isLoadingRootMore && ( +
  • + )} +
+
+ ) ) : filteredDocuments.length === 0 ? ( -
-

- {searchQuery - ? 'No documents match your search.' - : isTrashPanel - ? 'No documents in trash.' - : isSharedPanel - ? 'No shared documents yet.' - : 'No documents yet.'} -

-
+ renderEmptyState() ) : (
    {filteredDocuments.map((doc) => { const isActive = doc.id === activeDocId; + const isChildOfTrashedParent = + isTrashPanel && doc.parentId != null && trashedDocIds.has(doc.parentId); return (
  • {isTrashPanel ? ( @@ -218,7 +372,7 @@ export function DocumentsPanel({ }} className={`w-full flex items-center gap-2.5 px-2 pr-16 py-1.5 rounded-sm text-left transition-colors duration-100 cursor-pointer ${ isActive - ? 'bg-sidebar-accent/70 hover:bg-sidebar-accent group-hover/doc:bg-sidebar-accent text-sidebar-accent-foreground' + ? 'bg-sidebar-accent/70 hover:bg-sidebar-accent group-hover/doc:bg-sidebar-accent text-sidebar-foreground' : 'text-sidebar-foreground/90 hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover/doc:bg-sidebar-accent group-hover/doc:text-sidebar-foreground' }`} > @@ -226,7 +380,7 @@ export function DocumentsPanel({ {doc.meta.title || 'Untitled'} - {isAuthenticated && accessToken && ( + {isAuthenticated && accessToken && !isChildOfTrashedParent && (
    @@ -303,7 +457,7 @@ export function DocumentsPanel({
  • )} - {hasMore &&
  • } + {hasMore &&
  • }
)}
diff --git a/web/components/sidebar/SharedTree.tsx b/web/components/sidebar/SharedTree.tsx new file mode 100644 index 0000000..d4440ad --- /dev/null +++ b/web/components/sidebar/SharedTree.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useEffect, useMemo, useRef } from 'react'; +import { useAppDispatch, useAppSelector } from '@/stores/hooks'; +import { + syncSharedRoots, + fetchChildrenThunk, + toggleExpanded, + moveDocumentThunk, +} from '@/stores/sharedTree/sharedTree.slice'; +import type { SharedDocumentEntry } from '@/stores/documentList/documentList.types'; +import { SidebarSection } from './SidebarSection'; +import { SidebarTreeItem } from './SidebarTreeItem'; +import { SidebarTreeDndContext, useTreeDndOptional, type TreeApi } from './SidebarTreeDndContext'; +import type { DocActionsAnchor, DocActionType } from './types'; +import { SIDEBAR_VISIBLE_COUNT } from './types'; + +export interface SharedTreeProps { + isOpen: boolean; + onToggle: () => void; + documents: SharedDocumentEntry[]; + isLoading: boolean; + activeDocId: string; + onSelectDocument: (id: string) => void; + onCreateChild: (parentId: string) => void; + isActionsEnabled: boolean; + docActionsAnchor: DocActionsAnchor | null; + onToggleDocumentActions: ( + event: React.MouseEvent, + documentId: string, + actionType: DocActionType + ) => void; + resolveActionType: (documentId: string) => DocActionType; + onShowAll: () => void; + className?: string; +} + +export function SharedTree({ + isOpen, + onToggle, + documents, + isLoading, + activeDocId, + onSelectDocument, + onCreateChild, + isActionsEnabled, + docActionsAnchor, + onToggleDocumentActions, + resolveActionType, + onShowAll, + className, +}: SharedTreeProps) { + const dispatch = useAppDispatch(); + // Reuse the unified Private+Shared DnD provider when one is mounted above us. + const hasOuterDndContext = useTreeDndOptional() !== null; + const nodes = useAppSelector((state) => state.sharedTree?.nodes ?? {}); + const rootIds = useAppSelector((state) => state.sharedTree?.rootIds ?? []); + + // The section only renders the first SIDEBAR_VISIBLE_COUNT roots; the rest + // are reachable through the "Show More" row which opens the full panel. + const renderedRootIds = rootIds.slice(0, SIDEBAR_VISIBLE_COUNT); + + // Keep the shared tree roots in sync with the shared-documents list. + // Runs only when the list content (ids + updatedAt) actually changes. + const lastSyncRef = useRef(''); + useEffect(() => { + const signature = documents.map((doc) => `${doc.id}:${doc.meta.updatedAt}`).join('|'); + if (signature === lastSyncRef.current) { + return; + } + lastSyncRef.current = signature; + dispatch(syncSharedRoots(documents)); + }, [documents, dispatch]); + + const treeApi = useMemo( + () => ({ + nodes, + rootIds, + toggleExpanded: (id) => dispatch(toggleExpanded(id)), + fetchChildren: (parentId) => void dispatch(fetchChildrenThunk({ parentId })), + moveDocument: (args) => void dispatch(moveDocumentThunk(args)), + // A document can only live at the root level of the shared section if it + // is already a root; children are shared only through their root parent, + // so they must never be moved out of the shared tree's root level. + canPlaceAtRoot: (draggedId) => nodes[draggedId]?.parentId == null, + }), + [nodes, rootIds, dispatch] + ); + + const treeContent = ( +
    + {renderedRootIds.map((rootId) => ( + + ))} +
+ ); + + return ( + + {hasOuterDndContext ? ( + treeContent + ) : ( + {treeContent} + )} + + ); +} diff --git a/web/components/sidebar/Sidebar.tsx b/web/components/sidebar/Sidebar.tsx index f644d50..73d35c9 100644 --- a/web/components/sidebar/Sidebar.tsx +++ b/web/components/sidebar/Sidebar.tsx @@ -6,6 +6,10 @@ import { useRouter, useParams } from 'next/navigation'; import { useDocumentList } from '@/hooks/useDocumentList.hook'; import { documentService } from '@/services/document.service'; import { useAppDispatch, useAppSelector } from '@/stores/hooks'; +import { + selectRootLevelOwnerSharedDocumentIds, + selectSharedWithMeDocumentIds, +} from '@/stores/documentList/documentList.selectors'; import { setCollapsed, setPanelMode, @@ -39,15 +43,42 @@ import { useOfflineDocumentSelect } from '@/hooks/useOfflineDocumentSelect.hook' import { generateDocumentId } from '@/lib/document-id.util'; import { OFFLINE_DOCUMENT_SELECT_EVENT } from '@/lib/offline-navigation.util'; import { resolveRootDocumentId } from '@/lib/root-document.util'; +import { + isSidebarDropAllowed, + resolveSidebarMoveRoute, + type SidebarDropZone, +} from '@/lib/sidebar-drop-rules'; + +import { + fetchRootNodesThunk, + fetchChildrenThunk, + removeNode, + toggleExpanded as privateToggleExpanded, + moveDocumentThunk as privateMoveDocumentThunk, + resetTree as resetSidebarTree, +} from '@/stores/sidebarTree/sidebarTree.slice'; +import { + fetchChildrenThunk as fetchSharedChildrenThunk, + syncSharedRoots, + removeNode as sharedRemoveNode, + toggleExpanded as sharedToggleExpanded, + moveDocumentThunk as sharedMoveDocumentThunk, + resetTree as resetSharedTree, +} from '@/stores/sharedTree/sharedTree.slice'; // Import sub-components -import { SidebarDocumentSection } from './SidebarDocumentSection'; +import { SharedTree } from './SharedTree'; +import { SidebarTree } from './SidebarTree'; import { ProfileMenuPopup } from './ProfileMenuPopup'; import { DocumentActionsMenu } from './DocumentActionsMenu'; import { DocumentsPanel } from './DocumentsPanel'; import { useSidebarResize } from './useSidebarResize'; +import { + SidebarTreeDndContext, + type MoveDocumentArgs, + type TreeApi, +} from './SidebarTreeDndContext'; -import { SIDEBAR_VISIBLE_COUNT } from './types'; import type { DocActionType, SidebarSectionDocument } from './types'; const emptySubscribe = () => () => {}; @@ -61,7 +92,6 @@ function Sidebar() { documents, sharedDocuments = [], trashedDocuments, - isLoading, isSharedLoading = false, isSharedLoadingMore = false, sharedHasMore = false, @@ -100,6 +130,15 @@ function Sidebar() { const documentsPanelMode = useAppSelector((state) => state.sidebar.panelMode); const docActionsAnchor = useAppSelector((state) => state.sidebar.docActionsAnchor); const searchQuery = useAppSelector((state) => state.sidebar.searchQuery); + const privateTreeNodes = useAppSelector((state) => state.sidebarTree?.nodes ?? {}); + const privateTreeRootIds = useAppSelector((state) => state.sidebarTree?.rootIds ?? []); + const privateRootHasMore = useAppSelector((state) => state.sidebarTree?.rootHasMore ?? false); + const privateRootPage = useAppSelector((state) => state.sidebarTree?.rootPage ?? 0); + const privateRootLoading = useAppSelector((state) => state.sidebarTree?.isRootLoading ?? false); + const sharedTreeNodes = useAppSelector((state) => state.sharedTree?.nodes ?? {}); + const sharedTreeRootIds = useAppSelector((state) => state.sharedTree?.rootIds ?? []); + const sharedWithMeDocumentIds = useAppSelector(selectSharedWithMeDocumentIds); + const rootLevelOwnerSharedDocumentIds = useAppSelector(selectRootLevelOwnerSharedDocumentIds); const { sidebarWidth, isResizing, startResizing } = useSidebarResize(); @@ -131,11 +170,6 @@ function Sidebar() { : isSharedPanel ? isSharedLoadingMore : isLoadingMore; - const panelIsLoadingInitial = isTrashPanel - ? isTrashLoading - : isSharedPanel - ? isSharedLoading && panelDocuments.length === 0 - : isLoading && panelDocuments.length === 0; const filteredDocuments = useMemo(() => { const query = searchQuery.trim().toLowerCase(); @@ -148,6 +182,247 @@ function Sidebar() { ); }, [panelDocuments, searchQuery]); + const panelTreeApi = useMemo(() => { + if (isTrashPanel) { + return null; + } + + if (isSharedPanel) { + return { + nodes: sharedTreeNodes, + rootIds: sharedTreeRootIds, + toggleExpanded: (id) => dispatch(sharedToggleExpanded(id)), + fetchChildren: (parentId) => void dispatch(fetchSharedChildrenThunk({ parentId })), + moveDocument: (args) => void dispatch(sharedMoveDocumentThunk(args)), + canPlaceAtRoot: (draggedId) => sharedTreeNodes[draggedId]?.parentId == null, + }; + } + + return { + nodes: privateTreeNodes, + rootIds: privateTreeRootIds, + toggleExpanded: (id) => dispatch(privateToggleExpanded(id)), + fetchChildren: (parentId) => void dispatch(fetchChildrenThunk({ parentId })), + moveDocument: (args) => void dispatch(privateMoveDocumentThunk(args)), + // Mirror sidebarTreeApi: shared nodes may only be placed at root if they + // are already roots; private nodes are always allowed at root. + canPlaceAtRoot: (draggedId) => + Object.hasOwn(sharedTreeNodes, draggedId) + ? sharedTreeNodes[draggedId]?.parentId == null + : true, + }; + }, [ + isTrashPanel, + isSharedPanel, + dispatch, + sharedTreeNodes, + sharedTreeRootIds, + privateTreeNodes, + privateTreeRootIds, + ]); + + const isPanelSearching = searchQuery.trim().length > 0; + + const panelIsLoadingInitial = isTrashPanel + ? isTrashLoading + : isSharedPanel + ? isSharedLoading && panelTreeApi?.rootIds.length === 0 + : privateRootLoading && panelTreeApi?.rootIds.length === 0; + + // Documents that live in the Shared section (shared with me, or root-level + // documents shared by me) must not be listed in the Private section's tree or + // "show more" panel. Nested documents that were shared stay under their + // actual parent in the Private tree. + const excludedNodeIds = useMemo(() => { + const excluded = new Set(sharedWithMeDocumentIds); + for (const id of rootLevelOwnerSharedDocumentIds) { + excluded.add(id); + } + return excluded; + }, [sharedWithMeDocumentIds, rootLevelOwnerSharedDocumentIds]); + + const isPrivatePanel = !isTrashPanel && !isSharedPanel; + + /** + * Cross-tree move router for the unified sidebar DnD context. + * + * TODO(full-access): collaborators cannot re-share documents they do not own yet + * (sharing administration is owner-only; no FULL_ACCESS access level exists). Until + * that ships, moving a document between two shared documents is blocked in the UI - + * only sibling reordering inside the Shared section is offered. Dropping a private + * document into a shared document IS allowed: the backend transfers ownership of the + * moved subtree to the host tree's owner (location authority), and access then flows + * from the new parent chain. + */ + const handleSidebarTreeMove = useCallback( + (args: MoveDocumentArgs) => { + const route = resolveSidebarMoveRoute(args, { + draggedIsShared: Object.hasOwn(sharedTreeNodes, args.documentId), + targetParentIdIsShared: + args.newParentId != null && Object.hasOwn(sharedTreeNodes, args.newParentId), + }); + + if (route.kind === 'shared-reorder') { + void dispatch(sharedMoveDocumentThunk(args)) + .unwrap() + .catch((error) => { + console.error('Failed to reorder shared document:', error); + dispatch( + addToast({ message: 'Failed to move document. Please try again.', type: 'error' }) + ); + }); + return; + } + if (route.kind === 'private') { + void dispatch(privateMoveDocumentThunk(args)) + .unwrap() + .catch((error) => { + console.error('Failed to move document:', error); + dispatch( + addToast({ message: 'Failed to move document. Please try again.', type: 'error' }) + ); + }); + return; + } + if (route.kind === 'shared-nest-adopt') { + void (async () => { + try { + const result = await dispatch(sharedMoveDocumentThunk(args)); + if (!sharedMoveDocumentThunk.fulfilled.match(result)) { + throw new Error('Move request failed'); + } + // The document left the private tree: drop it from that store and + // refresh roots so section membership stays accurate. + dispatch(removeNode(args.documentId)); + await dispatch(fetchRootNodesThunk()); + await refresh(false); + dispatch( + addToast({ + message: 'Moved into the shared document. Its access now follows the new location.', + type: 'info', + }) + ); + } catch (error) { + console.error('Failed to move document into shared tree:', error); + dispatch( + addToast({ message: 'Failed to move document. Please try again.', type: 'error' }) + ); + } + })(); + return; + } + + // Blocked until FULL_ACCESS exists. + dispatch( + addToast({ + message: + 'Moving documents between shared documents requires re-sharing permissions, which are not available yet.', + type: 'info', + }) + ); + }, + [dispatch, sharedTreeNodes, refresh] + ); + + /** + * Single DnD tree api spanning both sections so drags can cross them. Node ids are + * UUIDs, so the merged map is collision-free; routing keys off which store holds a node. + */ + const sidebarTreeApi = useMemo(() => { + const mergedNodes = { ...privateTreeNodes, ...sharedTreeNodes }; + const isSharedNode = (id: string) => Object.hasOwn(sharedTreeNodes, id); + const visiblePrivateRootIds = privateTreeRootIds.filter((id) => !excludedNodeIds.has(id)); + return { + nodes: mergedNodes, + rootIds: [...visiblePrivateRootIds, ...sharedTreeRootIds], + getRootIds: (nodeId: string) => + isSharedNode(nodeId) ? sharedTreeRootIds : visiblePrivateRootIds, + toggleExpanded: (id) => + dispatch(isSharedNode(id) ? sharedToggleExpanded(id) : privateToggleExpanded(id)), + fetchChildren: (parentId) => + void dispatch( + isSharedNode(parentId) + ? fetchSharedChildrenThunk({ parentId }) + : fetchChildrenThunk({ parentId }) + ), + canPlaceAtRoot: (draggedId) => + isSharedNode(draggedId) ? mergedNodes[draggedId]?.parentId == null : true, + resolveDrop: (draggedId, { nodeId, zone }) => + isSidebarDropAllowed(zone as SidebarDropZone, { + draggedId, + draggedIsShared: isSharedNode(draggedId), + targetIsShared: isSharedNode(nodeId), + draggedParentId: mergedNodes[draggedId]?.parentId ?? null, + targetParentId: mergedNodes[nodeId]?.parentId ?? null, + }), + moveDocument: handleSidebarTreeMove, + }; + }, [ + privateTreeNodes, + sharedTreeNodes, + privateTreeRootIds, + sharedTreeRootIds, + excludedNodeIds, + dispatch, + handleSidebarTreeMove, + ]); + + // Search-filtered tree: a node is visible when its title matches or when any + // of its (loaded) descendants matches. Ancestors of matches stay visible. + const { visibleRootIds, visibleIds } = useMemo(() => { + if (!panelTreeApi) { + return { visibleRootIds: [], visibleIds: null }; + } + + const query = searchQuery.trim().toLowerCase(); + if (!query) { + return { + visibleRootIds: isPrivatePanel + ? panelTreeApi.rootIds.filter((rootId) => !excludedNodeIds.has(rootId)) + : panelTreeApi.rootIds, + visibleIds: null, + }; + } + + const { nodes, rootIds } = panelTreeApi; + const visible = new Set(); + const visit = (id: string): boolean => { + const node = nodes[id]; + if (!node) { + return false; + } + const selfMatch = (node.title || 'Untitled').toLowerCase().includes(query); + const hasVisibleChild = node.children.some(visit); + if (selfMatch || hasVisibleChild) { + visible.add(id); + return true; + } + return false; + }; + rootIds.forEach(visit); + + return { + visibleRootIds: rootIds.filter( + (id) => visible.has(id) && (!isPrivatePanel || !excludedNodeIds.has(id)) + ), + visibleIds: visible, + }; + }, [panelTreeApi, searchQuery, isPrivatePanel, excludedNodeIds]); + + const resolvePanelTreeActionType = useCallback( + (documentId: string): DocActionType => { + if (isSharedPanel) { + const node = sharedTreeNodes[documentId]; + if (node?.parentId != null) { + return 'move-to-trash'; + } + return node?.effectiveAccessLevel === 'OWNER' ? 'move-to-trash' : 'leave-shared'; + } + return 'move-to-trash'; + }, + [isSharedPanel, sharedTreeNodes] + ); + // Use useSyncExternalStore to safely detect if we are on the client // without triggering "cascading render" lint errors or hydration mismatches. const isClient = useSyncExternalStore( @@ -156,31 +431,43 @@ function Sidebar() { () => false ); - const handleCreateFile = useCallback(async () => { - try { - const newId = generateDocumentId(); - const created = await documentService.createDocument(); - await documentService.saveDocument(newId, created.ydoc, created.meta); - - if (isAuthenticated && accessToken) { - await documentService.createCloudDocument( - accessToken, - newId, - created.meta.title || 'Untitled', - created.ydoc, - created.meta.createdBy ?? null + const handleCreateFile = useCallback( + async (parentId?: string) => { + try { + const newId = generateDocumentId(); + const created = await documentService.createDocument(); + await documentService.saveDocument(newId, created.ydoc, created.meta); + + if (isAuthenticated && accessToken) { + await documentService.createCloudDocument( + accessToken, + newId, + created.meta.title || 'Untitled', + created.ydoc, + created.meta.createdBy ?? null, + parentId ?? null + ); + void dispatch(fetchRootNodesThunk()); + if (parentId) { + if (sharedTreeNodes[parentId]) { + void dispatch(fetchSharedChildrenThunk({ parentId })); + } else { + void dispatch(fetchChildrenThunk({ parentId })); + } + } + } + + await refresh(false); + router.push(`/doc/${newId}`); + } catch (error) { + console.error('Failed to create document:', error); + dispatch( + addToast({ message: 'Failed to create document. Please try again.', type: 'error' }) ); } - - await refresh(false); - router.push(`/doc/${newId}`); - } catch (error) { - console.error('Failed to create document:', error); - dispatch( - addToast({ message: 'Failed to create document. Please try again.', type: 'error' }) - ); - } - }, [router, refresh, isAuthenticated, accessToken, dispatch]); + }, + [router, refresh, isAuthenticated, accessToken, dispatch, sharedTreeNodes] + ); const handleSelectDocument = useCallback( (id: string) => { @@ -318,15 +605,42 @@ function Sidebar() { } dispatch(setSearchQuery('')); dispatch(setPanelMode('all')); - }, [isShowingAll, showAllDocuments, dispatch]); + // Ensure the private tree is loaded even when the sidebar is collapsed + // (the sidebar tree only fetches roots while it is rendered). + if (privateTreeRootIds.length === 0) { + void dispatch(fetchRootNodesThunk()); + } + }, [isShowingAll, showAllDocuments, dispatch, privateTreeRootIds.length]); const openSharedDocumentsPanel = useCallback(() => { if (!isShowingAllShared) { showAllSharedDocuments(); } + dispatch(syncSharedRoots(sharedDocuments)); dispatch(setSearchQuery('')); dispatch(setPanelMode('shared')); - }, [isShowingAllShared, showAllSharedDocuments, dispatch]); + }, [isShowingAllShared, showAllSharedDocuments, dispatch, sharedDocuments]); + + const handleLoadMoreRoots = useCallback(() => { + if (isSharedPanel) { + if (!isSharedLoadingMore && sharedHasMore) { + void loadMoreSharedDocuments(); + } + return; + } + if (!isTrashPanel && !privateRootLoading) { + void dispatch(fetchRootNodesThunk({ page: privateRootPage + 1, append: true })); + } + }, [ + isTrashPanel, + isSharedPanel, + dispatch, + privateRootLoading, + privateRootPage, + isSharedLoadingMore, + sharedHasMore, + loadMoreSharedDocuments, + ]); const openTrashDocumentsPanel = useCallback(() => { dispatch(setSearchQuery('')); @@ -351,6 +665,14 @@ function Sidebar() { return 'move-to-trash'; }, []); + const resolveSharedTreeActionType = useCallback( + (documentId: string): DocActionType => { + const doc = sharedDocuments.find((entry) => entry.id === documentId); + return doc ? resolveSharedActionType(doc) : 'move-to-trash'; + }, + [sharedDocuments, resolveSharedActionType] + ); + const resolvePanelActionType = useCallback( (doc: SidebarSectionDocument): DocActionType => isSharedPanel ? resolveSharedActionType(doc) : 'move-to-trash', @@ -386,6 +708,8 @@ function Sidebar() { try { await documentService.moveCloudDocumentToTrash(docId, accessToken); dispatch(setDocActionsAnchor(null)); + dispatch(removeNode(docId)); + dispatch(sharedRemoveNode(docId)); if (activeDocId === docId) { await navigateToResolvedRootDocument({ excludedDocumentIds: [docId] }); @@ -423,6 +747,7 @@ function Sidebar() { try { setTrashActionLoadingDocId(docId); await documentService.restoreCloudDocumentFromTrash(docId, accessToken); + void dispatch(fetchRootNodesThunk()); await refresh(false); await refreshTrash(false); @@ -577,7 +902,7 @@ function Sidebar() { {/* Action buttons */}
- - {isOpen && ( - - )} -
- ); -} diff --git a/web/components/sidebar/SidebarSection.tsx b/web/components/sidebar/SidebarSection.tsx new file mode 100644 index 0000000..83de9c3 --- /dev/null +++ b/web/components/sidebar/SidebarSection.tsx @@ -0,0 +1,128 @@ +import { ChevronRight, MoreHorizontal } from '@/icons'; +import { SIDEBAR_VISIBLE_COUNT } from './types'; + +export interface SidebarSectionProps { + title: string; + isOpen: boolean; + onToggle: () => void; + /** Optional trailing action rendered at the right edge of the header (e.g. "new document" button). */ + rightAction?: React.ReactNode; + /** Whether the section's root documents are still loading (shows skeleton rows). */ + isLoading: boolean; + /** Number of root documents currently rendered in the section. */ + rootCount: number; + /** Whether more root documents exist beyond the visible slice (filtered). */ + hasMore?: boolean; + /** Text shown when the section has no root documents. */ + emptyText: string; + /** Unique prefix for skeleton row keys. */ + skeletonKeyPrefix: string; + /** Accessible name for the "Show More" row. */ + showAllAriaLabel: string; + /** Called when "Show More" is clicked (opens the section's full documents panel). */ + onShowAll: () => void; + className?: string; + children: React.ReactNode; +} + +/** + * Shared shell for a sidebar section (Private / Shared). Owns all the section + * chrome — header, skeleton rows, empty state and the "Show More" row — so any + * modification (markup, threshold, labels) is reflected on every section at once. + * "Show More" is shown only when the section's root document count exceeds + * SIDEBAR_VISIBLE_COUNT and simply opens the section's full documents panel. + */ +export function SidebarSection({ + title, + isOpen, + onToggle, + rightAction, + isLoading, + rootCount, + hasMore, + emptyText, + skeletonKeyPrefix, + showAllAriaLabel, + onShowAll, + className, + children, +}: SidebarSectionProps) { + const isEmpty = rootCount === 0; + const showMore = !isLoading && (rootCount > SIDEBAR_VISIBLE_COUNT || Boolean(hasMore)); + + return ( +
+ {/* Full-width clickable header; the chevron is purely decorative */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggle(); + } + }} + className="group/header flex items-center justify-between mx-1.5 pl-2 pr-2 py-[5px] text-[13px] text-muted-foreground rounded-sm hover:bg-sidebar-accent transition-colors duration-100 cursor-pointer select-none" + > + + + {title} + + + + {rightAction} +
+ + {isOpen && ( + + )} +
+ ); +} diff --git a/web/components/sidebar/SidebarTree.tsx b/web/components/sidebar/SidebarTree.tsx new file mode 100644 index 0000000..00947b5 --- /dev/null +++ b/web/components/sidebar/SidebarTree.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { useAppDispatch, useAppSelector } from '@/stores/hooks'; +import { + fetchRootNodesThunk, + fetchChildrenThunk, + toggleExpanded, + moveDocumentThunk, + updateNodeMeta, +} from '@/stores/sidebarTree/sidebarTree.slice'; +import { + selectRootLevelOwnerSharedDocumentIds, + selectSharedWithMeDocumentIds, +} from '@/stores/documentList/documentList.selectors'; +import { useAuth } from '@/hooks/useAuth.hook'; +import { Plus } from '@/icons'; +import { SidebarSection } from './SidebarSection'; +import { SidebarTreeItem } from './SidebarTreeItem'; +import { SidebarTreeDndContext, useTreeDndOptional, type TreeApi } from './SidebarTreeDndContext'; +import type { DocActionsAnchor, DocActionType } from './types'; +import { SIDEBAR_VISIBLE_COUNT } from './types'; + +export interface SidebarTreeProps { + isOpen: boolean; + onToggle: () => void; + activeDocId: string; + onSelectDocument: (id: string) => void; + onCreateChild: (parentId?: string) => void; + isActionsEnabled: boolean; + docActionsAnchor: DocActionsAnchor | null; + onToggleDocumentActions: ( + event: React.MouseEvent, + documentId: string, + actionType: DocActionType + ) => void; + /** Called when the "Show More" row is clicked (opens the all-documents panel). */ + onShowAll: () => void; + excludedNodeIds?: Set; + className?: string; +} + +export function SidebarTree({ + isOpen, + onToggle, + activeDocId, + onSelectDocument, + onCreateChild, + isActionsEnabled, + docActionsAnchor, + onToggleDocumentActions, + onShowAll, + excludedNodeIds: propExcludedNodeIds, + className, +}: SidebarTreeProps) { + const dispatch = useAppDispatch(); + const { isAuthenticated, accessToken } = useAuth(); + // When a parent provider exists (unified Private+Shared DnD), reuse it instead of + // mounting a nested DndContext - dnd-kit drags cannot cross context boundaries. + const hasOuterDndContext = useTreeDndOptional() !== null; + const nodes = useAppSelector((state) => state.sidebarTree?.nodes ?? {}); + const rootIds = useAppSelector((state) => state.sidebarTree?.rootIds ?? []); + const isRootLoading = useAppSelector((state) => state.sidebarTree?.isRootLoading ?? false); + const sharedWithMeDocumentIds = useAppSelector(selectSharedWithMeDocumentIds); + const rootLevelOwnerSharedDocumentIds = useAppSelector(selectRootLevelOwnerSharedDocumentIds); + + // Documents that live in the Shared section must not render in the Private + // tree: everything shared with the user, plus root-level documents the user + // shared with others. Nested documents that were shared (real parentId set) + // stay in the Private tree under their actual parent. + const fallbackExcludedNodeIds = useMemo(() => { + const excluded = new Set(sharedWithMeDocumentIds); + for (const id of rootLevelOwnerSharedDocumentIds) { + excluded.add(id); + } + return excluded; + }, [sharedWithMeDocumentIds, rootLevelOwnerSharedDocumentIds]); + + const excludedNodeIds = propExcludedNodeIds ?? fallbackExcludedNodeIds; + + const visibleRootIds = useMemo( + () => rootIds.filter((rootId) => !excludedNodeIds.has(rootId)), + [rootIds, excludedNodeIds] + ); + + // The section only renders the first SIDEBAR_VISIBLE_COUNT roots; the rest + // are reachable through the "Show More" row which opens the full panel. + const renderedRootIds = visibleRootIds.slice(0, SIDEBAR_VISIBLE_COUNT); + + // Show More is derived after filtering excluded nodes; backend hasMore is not used for the + // collapsed button (which is purely about truncated visible count). For the panel's infinite + // scroll, hasMore is considered separately in Sidebar.tsx. + const hasMore = visibleRootIds.length > SIDEBAR_VISIBLE_COUNT; + + useEffect(() => { + void dispatch(fetchRootNodesThunk()); + }, [dispatch, isAuthenticated, accessToken]); + + useEffect(() => { + const handleMetaUpdate = (e: Event) => { + const customEvent = e as CustomEvent<{ id: string; meta: { title?: string } }>; + if (customEvent.detail?.id) { + dispatch( + updateNodeMeta({ + id: customEvent.detail.id, + title: customEvent.detail.meta?.title, + }) + ); + } + }; + + const handleDocsChanged = () => { + void dispatch(fetchRootNodesThunk()); + }; + + window.addEventListener('document-meta-updated', handleMetaUpdate); + window.addEventListener('cloud-documents-changed', handleDocsChanged); + window.addEventListener('local-documents-changed', handleDocsChanged); + + return () => { + window.removeEventListener('document-meta-updated', handleMetaUpdate); + window.removeEventListener('cloud-documents-changed', handleDocsChanged); + window.removeEventListener('local-documents-changed', handleDocsChanged); + }; + }, [dispatch]); + + const treeApi = useMemo( + () => ({ + nodes, + rootIds: visibleRootIds, + toggleExpanded: (id) => dispatch(toggleExpanded(id)), + fetchChildren: (parentId) => void dispatch(fetchChildrenThunk({ parentId })), + moveDocument: (args) => void dispatch(moveDocumentThunk(args)), + canPlaceAtRoot: () => true, + }), + [nodes, visibleRootIds, dispatch] + ); + + const treeContent = ( +
    + {renderedRootIds.map((rootId) => ( + + ))} +
+ ); + + return ( + { + e.stopPropagation(); + onCreateChild(undefined); + }} + title="New Document" + aria-label="New Document" + className="p-1 -my-1 rounded-sm opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 hover:bg-sidebar-foreground/15 text-muted-foreground transition-all duration-100 cursor-pointer" + > + + + } + isLoading={isRootLoading} + rootCount={visibleRootIds.length} + hasMore={hasMore} + emptyText="No documents yet" + skeletonKeyPrefix="tree-root-skeleton" + showAllAriaLabel="Show all documents" + onShowAll={onShowAll} + > + {hasOuterDndContext ? ( + treeContent + ) : ( + {treeContent} + )} + + ); +} diff --git a/web/components/sidebar/SidebarTreeDndContext.tsx b/web/components/sidebar/SidebarTreeDndContext.tsx new file mode 100644 index 0000000..200e590 --- /dev/null +++ b/web/components/sidebar/SidebarTreeDndContext.tsx @@ -0,0 +1,368 @@ +'use client'; + +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + pointerWithin, + type DragStartEvent, + type DragOverEvent, +} from '@dnd-kit/core'; +import { DocumentText } from '@/icons'; +import type { SidebarTreeNode } from '@/types/tree.types'; + +export type TreeDropPosition = 'top' | 'bottom'; + +export interface MoveDocumentArgs { + documentId: string; + newParentId: string | null; + prevSiblingId: string | null; + nextSiblingId: string | null; +} + +export interface TreeApi { + nodes: Record; + rootIds: string[]; + toggleExpanded: (id: string) => void; + fetchChildren: (parentId: string) => void; + moveDocument: (args: MoveDocumentArgs) => void; + /** Whether the given node may be dropped at the tree's root level. */ + canPlaceAtRoot: (draggedId: string) => boolean; + /** Optional resolver for root-level sibling IDs for a given node (e.g. per-tree roots in unified contexts). */ + getRootIds?: (targetNodeId: string) => string[]; + /** + * Optional cross-tree drop policy. When provided it has final say over whether a + * drop onto the given row/zone is offered; when absent the legacy single-tree + * behavior (root policy + cycle checks only) applies. + */ + resolveDrop?: (draggedId: string, target: { nodeId: string; zone: string }) => boolean; +} + +export interface TreeDropState { + activeId: string | null; + /** Where to show the reorder line: at the top or bottom edge of a row. */ + lineAt: { nodeId: string; position: TreeDropPosition } | null; + /** Row to highlight (blue, low opacity) as the reparent target. */ + highlightNodeId: string | null; + /** Expanded parent with no children whose empty area gets the line. */ + lineInEmptyOf: string | null; +} + +interface TreeDndContextValue { + treeApi: TreeApi; + dropState: TreeDropState; +} + +const TreeDndContext = createContext(null); + +export function useTreeDnd() { + const value = useContext(TreeDndContext); + if (!value) { + throw new Error('useTreeDnd must be used within a SidebarTreeDndContext'); + } + return value; +} + +/** Like useTreeDnd, but returns null when no provider is mounted (e.g. the panel tree). */ +export function useTreeDndOptional(): TreeDndContextValue | null { + return useContext(TreeDndContext); +} + +const ZONE_SUFFIXES = ['top', 'mid', 'bottom', 'empty']; + +function parseZoneId(zoneId: string): { nodeId: string; zone: string } | null { + for (const suffix of ZONE_SUFFIXES) { + const marker = `__${suffix}`; + if (zoneId.endsWith(marker)) { + const nodeId = zoneId.slice(0, -marker.length); + if (nodeId.length === 0) return null; + return { nodeId, zone: suffix }; + } + } + return null; +} + +export function SidebarTreeDndContext({ + treeApi, + children, +}: { + treeApi: TreeApi; + children: React.ReactNode; +}) { + const { nodes, rootIds } = treeApi; + + const [activeId, setActiveId] = useState(null); + const [lineAt, setLineAt] = useState(null); + const [highlightNodeId, setHighlightNodeId] = useState(null); + const [lineInEmptyOf, setLineInEmptyOf] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 5, + }, + }) + ); + + const isDescendant = useCallback( + (nodeId: string, ancestorId: string) => { + let cursor = nodes[nodeId]?.parentId ?? null; + while (cursor) { + if (cursor === ancestorId) { + return true; + } + cursor = nodes[cursor]?.parentId ?? null; + } + return false; + }, + [nodes] + ); + + const clearDropState = useCallback(() => { + setLineAt(null); + setHighlightNodeId(null); + setLineInEmptyOf(null); + }, []); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + setActiveId(String(event.active.id)); + clearDropState(); + }, + [clearDropState] + ); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + if (!activeId) { + return; + } + + const overId = event.over ? String(event.over.id) : null; + + let nextLineAt: TreeDropState['lineAt'] = null; + let nextHighlightNodeId: string | null = null; + let nextLineInEmptyOf: string | null = null; + + if (overId) { + const parsed = parseZoneId(overId); + if (parsed) { + const { nodeId, zone } = parsed; + const policyAllows = + !treeApi.resolveDrop || treeApi.resolveDrop(activeId, { nodeId, zone }); + if (nodeId !== activeId && !isDescendant(nodeId, activeId) && policyAllows) { + if (zone === 'mid' || zone === 'empty') { + if (zone === 'empty') { + nextLineInEmptyOf = nodeId; + } else { + nextHighlightNodeId = nodeId; + } + } else if (zone === 'top' || zone === 'bottom') { + // Line drops place the node as a sibling; dropping into the root + // level is only allowed when the tree's root policy permits it. + const targetParentId = nodes[nodeId]?.parentId ?? null; + if (targetParentId !== null || treeApi.canPlaceAtRoot(activeId)) { + nextLineAt = { nodeId, position: zone }; + } + } + } + } + } + + // Only update when the drop state actually changed. dnd-kit auto-scrolls + // the sidebar while dragging, so rows move under the pointer and the same + // zone can be reported repeatedly; updating state on every move makes the + // reorder line/highlight flash on and off. + if ( + nextLineAt?.nodeId !== lineAt?.nodeId || + nextLineAt?.position !== lineAt?.position || + nextHighlightNodeId !== highlightNodeId || + nextLineInEmptyOf !== lineInEmptyOf + ) { + setLineAt(nextLineAt); + setHighlightNodeId(nextHighlightNodeId); + setLineInEmptyOf(nextLineInEmptyOf); + } + }, + [activeId, nodes, treeApi, isDescendant, lineAt, highlightNodeId, lineInEmptyOf] + ); + + const handleDragEnd = useCallback(() => { + const draggedId = activeId; + setActiveId(null); + clearDropState(); + + if (!draggedId) { + return; + } + + const draggedNode = nodes[draggedId]; + if (!draggedNode) { + return; + } + + let targetParentId: string | null = null; + let prevSiblingId: string | null = null; + let nextSiblingId: string | null = null; + let guardNodeId: string | null = null; + let guardZone: string = 'mid'; + + if (lineAt) { + const overNode = nodes[lineAt.nodeId]; + if (!overNode) { + return; + } + targetParentId = overNode.parentId; + guardNodeId = lineAt.nodeId; + guardZone = lineAt.position; + const targetRootIds = treeApi.getRootIds ? treeApi.getRootIds(lineAt.nodeId) : rootIds; + const rawSiblings = + targetParentId && nodes[targetParentId] ? nodes[targetParentId].children : targetRootIds; + const siblings = rawSiblings.filter((id) => id !== draggedId); + const overIndex = siblings.indexOf(lineAt.nodeId); + if (overIndex !== -1) { + if (lineAt.position === 'top') { + prevSiblingId = overIndex > 0 ? siblings[overIndex - 1] : null; + nextSiblingId = lineAt.nodeId; + } else { + prevSiblingId = lineAt.nodeId; + nextSiblingId = overIndex < siblings.length - 1 ? siblings[overIndex + 1] : null; + } + } + } else if (highlightNodeId) { + targetParentId = highlightNodeId; + guardNodeId = highlightNodeId; + guardZone = 'mid'; + const parent = nodes[highlightNodeId]; + if (parent && parent.childrenLoaded && parent.children.length > 0) { + const parentChildren = parent.children.filter((id) => id !== draggedId); + if (parentChildren.length > 0) { + prevSiblingId = parentChildren[parentChildren.length - 1]; + } + } + } else if (lineInEmptyOf) { + targetParentId = lineInEmptyOf; + guardNodeId = lineInEmptyOf; + guardZone = 'empty'; + } else { + return; + } + + // Cross-tree drop policy has final say (mirrors the drag-over gate). + if (treeApi.resolveDrop) { + if ( + !guardNodeId || + !treeApi.resolveDrop(draggedId, { nodeId: guardNodeId, zone: guardZone }) + ) { + return; + } + } + + if (targetParentId === draggedId) { + return; + } + + // Root-level drops must respect the tree's root policy + if (targetParentId === null && !treeApi.canPlaceAtRoot(draggedId)) { + return; + } + + // Cycle check: cannot drop into one of the dragged node's own descendants + let ancestorCursor = targetParentId; + while (ancestorCursor) { + if (ancestorCursor === draggedId) { + return; + } + ancestorCursor = nodes[ancestorCursor]?.parentId ?? null; + } + + // Skip if the drop would not change the current position + const currentRootIds = treeApi.getRootIds ? treeApi.getRootIds(draggedId) : rootIds; + const currentSiblings = + draggedNode.parentId && nodes[draggedNode.parentId] + ? nodes[draggedNode.parentId].children + : currentRootIds; + const currentIndex = currentSiblings.indexOf(draggedId); + const currentPrev = currentIndex > 0 ? currentSiblings[currentIndex - 1] : null; + const currentNext = + currentIndex !== -1 && currentIndex < currentSiblings.length - 1 + ? currentSiblings[currentIndex + 1] + : null; + + if ( + targetParentId === draggedNode.parentId && + prevSiblingId === currentPrev && + nextSiblingId === currentNext + ) { + return; + } + + if (prevSiblingId === draggedId) { + prevSiblingId = null; + } + if (nextSiblingId === draggedId) { + nextSiblingId = null; + } + + treeApi.moveDocument({ + documentId: draggedId, + newParentId: targetParentId, + prevSiblingId, + nextSiblingId, + }); + }, [activeId, nodes, rootIds, lineAt, highlightNodeId, lineInEmptyOf, treeApi, clearDropState]); + + const handleDragCancel = useCallback(() => { + setActiveId(null); + clearDropState(); + }, [clearDropState]); + + const activeNode = activeId ? nodes[activeId] : null; + + // Show a grabbing hand cursor over the whole app while a drag is in progress + useEffect(() => { + document.body.classList.toggle('nd-is-dragging', Boolean(activeId)); + return () => { + document.body.classList.remove('nd-is-dragging'); + }; + }, [activeId]); + + const dropState = useMemo( + () => ({ activeId, lineAt, highlightNodeId, lineInEmptyOf }), + [activeId, lineAt, highlightNodeId, lineInEmptyOf] + ); + + const contextValue = useMemo( + () => ({ treeApi, dropState }), + [treeApi, dropState] + ); + + return ( + + + {children} + + + {activeNode ? ( +
+
+ ) : null} +
+
+
+ ); +} diff --git a/web/components/sidebar/SidebarTreeItem.tsx b/web/components/sidebar/SidebarTreeItem.tsx new file mode 100644 index 0000000..db4254f --- /dev/null +++ b/web/components/sidebar/SidebarTreeItem.tsx @@ -0,0 +1,283 @@ +import { useCallback, useMemo } from 'react'; +import { useDraggable, useDroppable } from '@dnd-kit/core'; +import { ChevronRight, DocumentText, MoreHorizontal, Plus } from '@/icons'; +import { useTreeDndOptional } from './SidebarTreeDndContext'; +import type { TreeApi } from './SidebarTreeDndContext'; +import type { DocActionsAnchor, DocActionType } from './types'; + +export interface SidebarTreeItemProps { + nodeId: string; + depth: number; + activeDocId: string; + onSelectDocument: (id: string) => void; + onCreateChild: (parentId: string) => void; + isActionsEnabled: boolean; + docActionsAnchor: DocActionsAnchor | null; + onToggleDocumentActions: ( + event: React.MouseEvent, + documentId: string, + actionType: DocActionType + ) => void; + resolveActionType?: (documentId: string) => DocActionType; + /** Node ids (e.g. shared documents) that must not render inside this tree. */ + excludedNodeIds?: ReadonlySet; + /** Tree data when rendered outside a SidebarTreeDndContext (e.g. the panel). */ + treeApi?: TreeApi; + /** Render without drag-and-drop affordances (used by the documents panel). */ + dndDisabled?: boolean; + /** When searching, only these node ids are rendered (matching nodes + ancestors). */ + visibleIds?: ReadonlySet | null; + /** When searching, children are shown regardless of the collapsed state. */ + forceShowChildren?: boolean; + /** Allow root-level reordering even when the node's access level would + * otherwise gate editing; used by the shared tree where reordering the + * user's own navigation is allowed for every collaborator. */ + reorderEnabled?: boolean; +} + +function zoneId(nodeId: string, zone: string) { + return `${nodeId}__${zone}`; +} + +const EMPTY_DROP_STATE = { + lineAt: null, + highlightNodeId: null, + lineInEmptyOf: null, +}; + +export function SidebarTreeItem({ + nodeId, + depth, + activeDocId, + onSelectDocument, + onCreateChild, + isActionsEnabled, + docActionsAnchor, + onToggleDocumentActions, + resolveActionType, + excludedNodeIds, + treeApi: treeApiProp, + dndDisabled = false, + visibleIds, + forceShowChildren = false, + reorderEnabled = false, +}: SidebarTreeItemProps) { + const dndContext = useTreeDndOptional(); + const treeApi = treeApiProp ?? dndContext?.treeApi ?? null; + const node = treeApi?.nodes[nodeId]; + const dropState = dndContext && !dndDisabled ? dndContext.dropState : EMPTY_DROP_STATE; + const { lineAt, highlightNodeId, lineInEmptyOf } = dropState; + const visibleChildren = useMemo(() => { + let children = node?.children.filter((childId) => !excludedNodeIds?.has(childId)) ?? []; + if (visibleIds) { + children = children.filter((childId) => visibleIds.has(childId)); + } + return children; + }, [node, excludedNodeIds, visibleIds]); + const canEdit = node?.effectiveAccessLevel === 'EDIT' || node?.effectiveAccessLevel === 'OWNER'; + + const canReorder = canEdit || (reorderEnabled && node?.parentId == null); + + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: nodeId, + disabled: dndDisabled || !canReorder, + }); + + const { setNodeRef: setTopZoneRef } = useDroppable({ id: zoneId(nodeId, 'top') }); + const { setNodeRef: setMidZoneRef } = useDroppable({ id: zoneId(nodeId, 'mid') }); + const { setNodeRef: setBottomZoneRef } = useDroppable({ id: zoneId(nodeId, 'bottom') }); + const { setNodeRef: setEmptyZoneRef } = useDroppable({ id: zoneId(nodeId, 'empty') }); + + const handleToggleExpand = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!node) return; + + if (!node.isExpanded && !node.childrenLoaded && !node.isLoading) { + treeApi?.fetchChildren(nodeId); + } + treeApi?.toggleExpanded(nodeId); + }, + [node, nodeId, treeApi] + ); + + const handleCreateChild = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!node) return; + if (!node.isExpanded && !node.childrenLoaded && !node.isLoading) { + treeApi?.fetchChildren(nodeId); + } + onCreateChild(nodeId); + }, + [node, nodeId, treeApi, onCreateChild] + ); + + if (!node || !treeApi) { + return null; + } + + const isActive = node.id === activeDocId; + const indentPx = 8 + depth * 12; + const isDropTarget = highlightNodeId === node.id; + const showLineTop = lineAt?.nodeId === node.id && lineAt.position === 'top'; + const showLineBottom = lineAt?.nodeId === node.id && lineAt.position === 'bottom'; + const showEmptyLine = lineInEmptyOf === node.id; + const actionType = resolveActionType ? resolveActionType(node.id) : 'move-to-trash'; + const showChildren = forceShowChildren || node.isExpanded; + + return ( +
  • +
    onSelectDocument(node.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelectDocument(node.id); + } + }} + > + {/* Icon - document icon by default, chevron on hover for expandable items */} +
    + + +
    + + {/* Title */} + {node.title || 'Untitled'} + + {/* Action buttons on hover */} +
    + {canEdit && ( + + )} + + {isActionsEnabled && ( + + )} +
    + + {/* Drop zones: top/bottom for reordering (line), middle for reparenting (highlight) */} + {!dndDisabled && ( +
  • + ); +} diff --git a/web/components/sidebar/useSidebarResize.ts b/web/components/sidebar/useSidebarResize.ts index 47509d7..03585be 100644 --- a/web/components/sidebar/useSidebarResize.ts +++ b/web/components/sidebar/useSidebarResize.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useState } from 'react'; +import { useAppDispatch } from '@/stores/hooks'; +import { setSidebarWidth as setSidebarWidthAction } from '@/stores/sidebar/sidebar.slice'; export function useSidebarResize(initialWidth = 256, minWidth = 256, maxWidth = 480) { + const dispatch = useAppDispatch(); const [sidebarWidth, setSidebarWidth] = useState(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('nextdocs-sidebar-width'); @@ -15,6 +18,13 @@ export function useSidebarResize(initialWidth = 256, minWidth = 256, maxWidth = }); const [isResizing, setIsResizing] = useState(false); + useEffect(() => { + dispatch(setSidebarWidthAction(sidebarWidth)); + if (typeof document !== 'undefined') { + document.documentElement.style.setProperty('--nd-sidebar-width', `${sidebarWidth}px`); + } + }, [sidebarWidth, dispatch]); + const startResizing = useCallback((e: React.MouseEvent) => { e.preventDefault(); setIsResizing(true); diff --git a/web/hooks/useDocument.hook.ts b/web/hooks/useDocument.hook.ts index 6f2060d..8d23363 100644 --- a/web/hooks/useDocument.hook.ts +++ b/web/hooks/useDocument.hook.ts @@ -173,25 +173,6 @@ async function resolveLocalFallbackDocument( }; } -/** - * Fetches a document whose active access may have just been revoked (e.g. it was moved to - * trash from another tab/device). Returns the trashed copy when the caller is the owner - - * trashed documents are served read-only to their owner over REST (includeTrashed=true) - - * or null when the document is truly inaccessible (active but revoked, permanently deleted, - * or not the owner). - */ -async function loadTrashedDocumentIfVisible( - documentId: string, - token: string -): Promise { - try { - const result = await documentService.getCloudDocument(documentId, token); - return result.meta.deletedAt ? result : null; - } catch { - return null; - } -} - export function useDocument(documentId: string, options?: UseDocumentOptions) { const id = documentId; const isSharedDocument = options?.isSharedDocument === true; @@ -199,10 +180,14 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { const { currentDocumentId, meta, isLoading, error } = useAppSelector((state) => state.document); const { isAuthenticated, accessToken, user, isInitializing, refresh } = useAuth(); const { isOnline } = useNetworkStatus(); + const initialFallbackAccessLevel = + readCachedDocumentAccessLevel(id) ?? (isSharedDocument ? 'VIEW' : null); const accessTokenRef = useRef(accessToken); - const accessLevelRef = useRef('EDIT'); + const accessLevelRef = useRef(initialFallbackAccessLevel); const [resolvedDocumentId, setResolvedDocumentId] = useState(id); - const [accessLevel, setAccessLevel] = useState('EDIT'); + const [accessLevel, setAccessLevel] = useState( + initialFallbackAccessLevel + ); const [isRealtimeConnected, setIsRealtimeConnected] = useState(false); const [realtimeProvider, setRealtimeProvider] = useState(null); const [errorState, setErrorState] = useState(null); @@ -223,12 +208,18 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { // when a new document is loaded (instead of reading from // the module-level singleton at render time, which may be stale) const [ydoc, setLocalYDoc] = useState(null); + const ydocRef = useRef(null); - // When the owner loses active access because their document was moved to trash - // (e.g. from another tab/device), surface the read-only trash view instead of a - // spurious "access restricted" error. + // When a document is moved to trash (e.g. from another tab/device), surface the + // read-only trash view instead of a spurious "access restricted" error. Anyone who + // held pre-trash access gets the view; only EDIT/OWNER holders may restore. const applyTrashedDocumentView = useCallback( - (documentId: string, result: DocumentLoadResult) => { + ( + documentId: string, + result: DocumentLoadResult, + trashAccessLevel?: DocumentAccessLevel | null + ) => { + ydocRef.current = result.ydoc; setLocalYDoc(result.ydoc); setYDoc(result.ydoc); dispatch( @@ -237,16 +228,48 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { meta: result.meta, }) ); - setAccessLevel('VIEW'); - // The trashed doc renders read-only; don't leave a stale cached level (e.g. EDIT) - // behind that could misrepresent permissions. - clearCachedDocumentAccessLevel(documentId); + const nextLevel = trashAccessLevel ?? 'VIEW'; + accessLevelRef.current = nextLevel; + setAccessLevel(nextLevel); + // Preserve the pre-trash access level in cache so a refresh shows the + // correct Restore affordance immediately (Editor.tsx canManageTrash). + // Reading is still gated by meta.deletedAt, so cached EDIT does not + // make a trashed doc editable. + if (trashAccessLevel) { + writeCachedDocumentAccessLevel(documentId, trashAccessLevel); + } else { + clearCachedDocumentAccessLevel(documentId); + } setErrorState(null); dispatch(setError(null)); }, [dispatch] ); + // Shared transition for "you can no longer see this document": drops the in-memory doc, + // clears any cached access level, and surfaces the restricted-error panel. `source` is + // either a status code (generic not-found) or the API error that triggered it. + const enterRestrictedState = useCallback( + (documentId: string, source: DocumentServiceApiError | number) => { + clearCachedDocumentAccessLevel(documentId); + const restrictedError = + typeof source === 'number' + ? buildDocumentErrorState( + new DocumentServiceApiError('The requested resource was not found.', source) + ) + : buildDocumentErrorState(source); + setErrorState(restrictedError); + ydocRef.current = null; + setLocalYDoc(null); + setYDoc(null); + dispatch(clearDocument()); + dispatch(setError(restrictedError.description)); + accessLevelRef.current = null; + setAccessLevel(null); + }, + [dispatch] + ); + useEffect(() => { accessTokenRef.current = accessToken; }, [accessToken]); @@ -255,6 +278,10 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { accessLevelRef.current = accessLevel; }, [accessLevel]); + useEffect(() => { + ydocRef.current = ydoc; + }, [ydoc]); + useEffect(() => { if (!isAuthenticated || isInitializing || errorState === null || ydoc !== null || isLoading) { return; @@ -280,8 +307,12 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { dispatch(setError(null)); setErrorState(null); setLocalYDoc(null); + ydocRef.current = null; setResolvedDocumentId(id); lastLoadContextKeyRef.current = null; + const initialLevel = readCachedDocumentAccessLevel(id) ?? (isSharedDocument ? 'VIEW' : null); + accessLevelRef.current = initialLevel; + setAccessLevel(initialLevel); return; } @@ -298,13 +329,16 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { isSharedDocument ? 'shared' : 'private', ].join(':'); const isSameLoadContext = lastLoadContextKeyRef.current === loadContextKey; - const hasLoadedDocumentForContext = isSameLoadContext && ydoc !== null; + const hasLoadedDocumentForContext = isSameLoadContext && ydocRef.current !== null; if (hasLoadedDocumentForContext) { return; } lastLoadContextKeyRef.current = loadContextKey; + const initialLevel = readCachedDocumentAccessLevel(id) ?? (isSharedDocument ? 'VIEW' : null); + accessLevelRef.current = initialLevel; + setAccessLevel(initialLevel); let cancelled = false; @@ -334,15 +368,22 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { result = fallback.result; } else { try { - result = await documentService.getCloudDocument(id, token); + result = await documentService.getCloudDocument(id, token, { + includeTrashed: true, + }); loadedFromCloud = true; - clearCloudReadBackoff(); } catch (cloudErr) { if (cloudErr instanceof DocumentServiceApiError && cloudErr.status === 401) { // Stale token: trigger silent re-auth and fall back to local IDB. // The new accessToken from refreshSessionThunk will re-trigger loadDoc. void refresh(); + } else if ( + cloudErr instanceof DocumentServiceApiError && + (cloudErr.status === 403 || cloudErr.status === 404) + ) { + enterRestrictedState(id, cloudErr); + return; } else if (!isConnectivityError(cloudErr)) { throw cloudErr; } else { @@ -400,72 +441,63 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { } } + const isTrashedDoc = !!result.meta.deletedAt; + + if (isTrashedDoc && !isAuthenticated) { + enterRestrictedState(effectiveId, 404); + return; + } + if (!cancelled) { - const isTrashedDoc = !!result.meta.deletedAt; if ( isAuthenticated && token && !isCloudReadInBackoff() && - !hasPendingSyncForRequestedDoc && - !isTrashedDoc + !hasPendingSyncForRequestedDoc ) { try { const myAccess = await documentService.getMyAccess(effectiveId, token); if (!myAccess.allowed || !myAccess.accessLevel) { - clearCachedDocumentAccessLevel(effectiveId); - const restrictedError = buildDocumentErrorState( - new DocumentServiceApiError('The requested resource was not found.', 404) - ); - setErrorState(restrictedError); - setLocalYDoc(null); - setYDoc(null); - dispatch(clearDocument()); - dispatch(setError(restrictedError.description)); - setAccessLevel(null); + enterRestrictedState(effectiveId, 404); return; } + // Cache the level even for trashed docs so refresh does not + // flicker to read-only before getMyAccess resolves. The doc + // stays read-only via meta.deletedAt regardless of cached level. writeCachedDocumentAccessLevel(effectiveId, myAccess.accessLevel); + accessLevelRef.current = myAccess.accessLevel; setAccessLevel(myAccess.accessLevel); } catch (accessErr) { if ( accessErr instanceof DocumentServiceApiError && (accessErr.status === 403 || accessErr.status === 404) ) { - clearCachedDocumentAccessLevel(effectiveId); - const restrictedError = buildDocumentErrorState(accessErr); - setErrorState(restrictedError); - setLocalYDoc(null); - setYDoc(null); - dispatch(clearDocument()); - dispatch(setError(restrictedError.description)); - setAccessLevel(null); + enterRestrictedState(effectiveId, accessErr); return; } // Access lookup is advisory for UI state; keep the most recently known access level // when the network drops so cached shared docs do not become editable offline. - console.warn( - 'Unable to fetch document access level, using cached/default access level:', - accessErr - ); - setAccessLevel( - resolveAuthenticatedFallbackAccessLevel(effectiveId, { - currentAccessLevel: accessLevelRef.current, - isSharedDocument, - }) - ); + const fallbackLevel = isTrashedDoc + ? (readCachedDocumentAccessLevel(effectiveId) ?? accessLevelRef.current ?? 'VIEW') + : resolveAuthenticatedFallbackAccessLevel(effectiveId, { + currentAccessLevel: accessLevelRef.current, + isSharedDocument, + }); + accessLevelRef.current = fallbackLevel; + setAccessLevel(fallbackLevel); } } else { - setAccessLevel( - isTrashedDoc - ? 'VIEW' - : isAuthenticated - ? resolveAuthenticatedFallbackAccessLevel(effectiveId, { - currentAccessLevel: accessLevelRef.current, - isSharedDocument, - }) - : guestAccessLevel - ); + const fallbackLevel = isTrashedDoc + ? (readCachedDocumentAccessLevel(effectiveId) ?? accessLevelRef.current ?? 'VIEW') + : isAuthenticated + ? resolveAuthenticatedFallbackAccessLevel(effectiveId, { + currentAccessLevel: accessLevelRef.current, + isSharedDocument, + }) + : guestAccessLevel; + accessLevelRef.current = fallbackLevel; + setAccessLevel(fallbackLevel); } if (isAuthenticated && token && loadedFromCloud) { @@ -479,6 +511,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { } } + ydocRef.current = result.ydoc; setResolvedDocumentId(effectiveId); setYDoc(result.ydoc); setLocalYDoc(result.ydoc); @@ -495,6 +528,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { if (!cancelled) { const nextError = buildDocumentErrorState(err); setErrorState(nextError); + ydocRef.current = null; setLocalYDoc(null); setYDoc(null); dispatch(clearDocument()); @@ -509,6 +543,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { // Clear stale ydoc immediately so the editor shows loading state setLocalYDoc(null); + ydocRef.current = null; setResolvedDocumentId(id); dispatch(clearDocument()); loadDoc(); @@ -523,7 +558,6 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { isAuthenticated, accessToken, isInitializing, - ydoc, isOnline, isSharedDocument, user?.id, @@ -589,20 +623,47 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { if (token) { const myAccess = await documentService.getMyAccess(resolvedDocumentId, token); if (closeHandlerCancelled) return; - if (!myAccess.allowed || !myAccess.accessLevel) { - // The realtime server rejected this connection (1008). Before treating it as - // a revocation, check whether the user can still view the document from trash - // (e.g. it was moved to trash from another tab/device). Trashed documents are - // served read-only to their owner over REST. - const trashedCopy = await loadTrashedDocumentIfVisible(resolvedDocumentId, token); - if (closeHandlerCancelled) return; - if (trashedCopy) { - applyTrashedDocumentView(resolvedDocumentId, trashedCopy); - provider.shouldConnect = false; - setIsRealtimeConnected(false); - setRealtimeProvider((current) => (current === provider ? null : current)); + if (myAccess.trashed && myAccess.allowed && myAccess.accessLevel) { + // The realtime server strictly rejects access checks for trashed documents, + // so a 1008 close here means the document moved to trash (from another + // tab/device), not that access was revoked. Anyone who held pre-trash access + // gets the read-only trash view. + try { + const cloudCopy = await documentService.getCloudDocument( + resolvedDocumentId, + token, + { includeTrashed: true } + ); + if (closeHandlerCancelled) return; + if (cloudCopy.meta.deletedAt) { + applyTrashedDocumentView(resolvedDocumentId, cloudCopy, myAccess.accessLevel); + provider.shouldConnect = false; + setIsRealtimeConnected(false); + setRealtimeProvider((current) => (current === provider ? null : current)); + return; + } + // The document was restored between getMyAccess and getCloudDocument: + // keep the document active and allow reconnection. + writeCachedDocumentAccessLevel(resolvedDocumentId, myAccess.accessLevel); + setAccessLevel(myAccess.accessLevel); + provider.shouldConnect = true; + return; + } catch (cloudErr) { + if (closeHandlerCancelled) return; + if ( + cloudErr instanceof DocumentServiceApiError && + (cloudErr.status === 403 || cloudErr.status === 404) + ) { + // Trash view unavailable (e.g. purged in the meantime) - treat as revoked. + handleAccessRevoked(cloudErr.status); + return; + } + console.warn('Failed to fetch trashed document status on close:', cloudErr); return; } + } + if (!myAccess.allowed || !myAccess.accessLevel) { + // Access has been officially revoked/restricted. handleAccessRevoked(404); return; } @@ -636,16 +697,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { const handleAccessRevoked = (statusCode: number = 404) => { if (closeHandlerCancelled) return; - clearCachedDocumentAccessLevel(resolvedDocumentId); - const restrictedError = buildDocumentErrorState( - new DocumentServiceApiError('The requested resource was not found.', statusCode) - ); - setErrorState(restrictedError); - setLocalYDoc(null); - setYDoc(null); - dispatch(clearDocument()); - dispatch(setError(restrictedError.description)); - setAccessLevel(null); + enterRestrictedState(resolvedDocumentId, statusCode); provider.shouldConnect = false; setIsRealtimeConnected(false); @@ -691,6 +743,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { refresh, dispatch, applyTrashedDocumentView, + enterRestrictedState, ]); // Listen for server-pushed access-level changes and apply them immediately. @@ -786,24 +839,37 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { const checkAccessLevel = async () => { try { const myAccess = await documentService.getMyAccess(resolvedDocumentId, accessToken); - if (!myAccess.allowed || !myAccess.accessLevel) { - // Before treating this as a revocation, check whether the user can still view - // the document from trash (e.g. it was moved to trash from another tab/device). - const trashedCopy = await loadTrashedDocumentIfVisible(resolvedDocumentId, accessToken); - if (trashedCopy) { - applyTrashedDocumentView(resolvedDocumentId, trashedCopy); + if (myAccess.trashed && myAccess.allowed && myAccess.accessLevel) { + // The document moved to trash between polls - swap to the read-only trash view + // for anyone who held pre-trash access. + try { + const cloudCopy = await documentService.getCloudDocument( + resolvedDocumentId, + accessToken, + { includeTrashed: true } + ); + if (cloudCopy.meta.deletedAt) { + applyTrashedDocumentView(resolvedDocumentId, cloudCopy, myAccess.accessLevel); + return; + } + // The document was restored between getMyAccess and getCloudDocument: + writeCachedDocumentAccessLevel(resolvedDocumentId, myAccess.accessLevel); + setAccessLevel(myAccess.accessLevel); + return; + } catch (cloudErr) { + if ( + cloudErr instanceof DocumentServiceApiError && + (cloudErr.status === 403 || cloudErr.status === 404) + ) { + enterRestrictedState(resolvedDocumentId, cloudErr); + return; + } + console.warn('Failed to fetch trashed document status:', cloudErr); return; } - clearCachedDocumentAccessLevel(resolvedDocumentId); - const restrictedError = buildDocumentErrorState( - new DocumentServiceApiError('The requested resource was not found.', 404) - ); - setErrorState(restrictedError); - setLocalYDoc(null); - setYDoc(null); - dispatch(clearDocument()); - dispatch(setError(restrictedError.description)); - setAccessLevel(null); + } + if (!myAccess.allowed || !myAccess.accessLevel) { + enterRestrictedState(resolvedDocumentId, 404); return; } writeCachedDocumentAccessLevel(resolvedDocumentId, myAccess.accessLevel); @@ -818,14 +884,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { } if (err instanceof DocumentServiceApiError && (err.status === 403 || err.status === 404)) { - clearCachedDocumentAccessLevel(resolvedDocumentId); - const restrictedError = buildDocumentErrorState(err); - setErrorState(restrictedError); - setLocalYDoc(null); - setYDoc(null); - dispatch(clearDocument()); - dispatch(setError(restrictedError.description)); - setAccessLevel(null); + enterRestrictedState(resolvedDocumentId, err); return; } @@ -853,6 +912,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { refresh, isRealtimeConnected, applyTrashedDocumentView, + enterRestrictedState, ]); const updateMeta = useCallback( @@ -972,10 +1032,20 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { updatedAt, }) ); - // We don't need to check for the access level and directly set it - // to OWNER because only they have the option to restore the document. - setAccessLevel('OWNER'); - writeCachedDocumentAccessLevel(resolvedDocumentId, 'OWNER'); + let nextLevel: DocumentAccessLevel = accessLevelRef.current ?? 'OWNER'; + const token = accessTokenRef.current; + if (token) { + try { + const myAccess = await documentService.getMyAccess(resolvedDocumentId, token); + if (myAccess.allowed && myAccess.accessLevel) { + nextLevel = myAccess.accessLevel; + } + } catch { + // fallback + } + } + setAccessLevel(nextLevel); + writeCachedDocumentAccessLevel(resolvedDocumentId, nextLevel); } } catch (err) { console.warn('Failed to check document status on docs changed:', err); @@ -1006,8 +1076,19 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { }) ); - setAccessLevel('OWNER'); - writeCachedDocumentAccessLevel(resolvedDocumentId, 'OWNER'); + let nextLevel: DocumentAccessLevel = accessLevelRef.current ?? 'OWNER'; + try { + const myAccess = await documentService.getMyAccess(resolvedDocumentId, accessToken); + if (myAccess.allowed && myAccess.accessLevel) { + nextLevel = myAccess.accessLevel; + } + } catch { + // fallback + } + + accessLevelRef.current = nextLevel; + setAccessLevel(nextLevel); + writeCachedDocumentAccessLevel(resolvedDocumentId, nextLevel); }, [isAuthenticated, accessToken, resolvedDocumentId, dispatch]); return { @@ -1015,7 +1096,10 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { ydoc, meta, accessLevel, - isReadOnly: isReadOnlyAccessLevel(accessLevel) || !!meta?.deletedAt, + isReadOnly: + isReadOnlyAccessLevel(accessLevel) || + (isSharedDocument && accessLevel === null) || + !!meta?.deletedAt, isRealtimeConnected, realtimeProvider, errorState, diff --git a/web/hooks/useDocumentBreadcrumbs.hook.ts b/web/hooks/useDocumentBreadcrumbs.hook.ts new file mode 100644 index 0000000..3dfdbf5 --- /dev/null +++ b/web/hooks/useDocumentBreadcrumbs.hook.ts @@ -0,0 +1,152 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useAppSelector } from '@/stores/hooks'; +import { documentService, type DocumentBreadcrumbItem } from '@/services/document.service'; +import type { SidebarTreeNode } from '@/types/tree.types'; + +const EMPTY_NODES: Record = {}; + +export function useDocumentBreadcrumbs( + documentId: string, + currentTitle?: string, + currentIcon?: string | null +): { + breadcrumbs: DocumentBreadcrumbItem[]; + isLoading: boolean; +} { + const privateNodes: Record = useAppSelector( + (state) => state?.sidebarTree?.nodes ?? EMPTY_NODES + ); + const sharedNodes: Record = useAppSelector( + (state) => state?.sharedTree?.nodes ?? EMPTY_NODES + ); + const accessToken = useAppSelector((state) => state?.auth?.accessToken ?? null); + + const [serverState, setServerState] = useState<{ + documentId: string; + items: DocumentBreadcrumbItem[]; + } | null>(null); + const [isLoading, setIsLoading] = useState(false); + + // Compute breadcrumbs from local Redux tree nodes (0ms instant render) + const localBreadcrumbs = useMemo(() => { + if (!documentId) return []; + + const items: DocumentBreadcrumbItem[] = []; + let currentId: string | null = documentId; + const visited = new Set(); + + while (currentId && !visited.has(currentId)) { + visited.add(currentId); + const node: SidebarTreeNode | undefined = privateNodes[currentId] || sharedNodes[currentId]; + if (!node) { + break; + } + items.push({ + id: node.id, + title: node.title || 'Untitled', + // Document icon is reserved for future icon/cover support; title is used for now + icon: null, + parentId: node.parentId, + }); + currentId = node.parentId; + } + + if (items.length === 0) { + return [ + { + id: documentId, + title: currentTitle || 'Untitled', + icon: currentIcon ?? null, + parentId: null, + }, + ]; + } + + return items.reverse(); + }, [documentId, privateNodes, sharedNodes, currentTitle, currentIcon]); + + // Fetch full path from server to catch any non-loaded ancestor levels + useEffect(() => { + if (!documentId || typeof documentService?.getDocumentBreadcrumbs !== 'function') { + return; + } + + let isCancelled = false; + + documentService + .getDocumentBreadcrumbs(documentId, accessToken) + .then((crumbs) => { + if (!isCancelled) { + setServerState({ documentId, items: crumbs || [] }); + setIsLoading(false); + } + }) + .catch((err) => { + if (!isCancelled) { + console.warn('Failed to load breadcrumbs for document:', err); + setServerState({ documentId, items: [] }); + setIsLoading(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [documentId, accessToken]); + + // Merge and apply reactive live updates to titles + const breadcrumbs = useMemo(() => { + const serverBreadcrumbs = + serverState && serverState.documentId === documentId ? serverState.items : []; + + const rawList = + serverBreadcrumbs.length > 0 && + serverBreadcrumbs[serverBreadcrumbs.length - 1]?.id === documentId + ? serverBreadcrumbs + : localBreadcrumbs; + + if (rawList.length === 0) { + return [ + { + id: documentId, + title: currentTitle || 'Untitled', + icon: currentIcon ?? null, + parentId: null, + }, + ]; + } + + return rawList.map((item, index) => { + const isCurrent = index === rawList.length - 1; + if (isCurrent) { + return { + ...item, + title: currentTitle && currentTitle.trim() ? currentTitle : 'Untitled', + icon: currentIcon ?? null, + }; + } + + // Check if ancestor title was updated in local tree store + const localNode = privateNodes[item.id] || sharedNodes[item.id]; + const liveTitle = localNode?.title || item.title || 'Untitled'; + + return { + ...item, + title: liveTitle, + }; + }); + }, [ + serverState, + localBreadcrumbs, + documentId, + currentTitle, + currentIcon, + privateNodes, + sharedNodes, + ]); + + return { + breadcrumbs, + isLoading, + }; +} diff --git a/web/hooks/useDocumentList.hook.ts b/web/hooks/useDocumentList.hook.ts index f2548ab..7c469df 100644 --- a/web/hooks/useDocumentList.hook.ts +++ b/web/hooks/useDocumentList.hook.ts @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useCallback, useRef } from 'react'; +import { useEffect, useCallback, useMemo, useRef } from 'react'; import { useAppDispatch, useAppSelector } from '@/stores/hooks'; import { useAuth } from '@/hooks/useAuth.hook'; import { useCloudBackoff } from '@/hooks/useCloudBackoff.hook'; @@ -22,6 +22,8 @@ import { setShowingAll, setShowingAllShared, } from '@/stores/documentList/documentList.slice'; +import { resetTree as resetSidebarTree } from '@/stores/sidebarTree/sidebarTree.slice'; +import { resetTree as resetSharedTree } from '@/stores/sharedTree/sharedTree.slice'; export type { LocalDocumentEntry, SharedDocumentEntry }; @@ -53,6 +55,11 @@ export function useDocumentList() { trashHasMore, } = useAppSelector((state) => state.documentList); + const sharedDocuments = useMemo( + () => combineSharedDocuments(sharedWithMeDocuments, ownerSharedDocuments), + [sharedWithMeDocuments, ownerSharedDocuments] + ); + useEffect(() => { if (isInitializing) { return; @@ -63,6 +70,8 @@ export function useDocumentList() { if (isAuthTransition) { dispatch(resetOnAuthTransition({ isAuthenticated })); + dispatch(resetSidebarTree()); + dispatch(resetSharedTree()); } prevIsAuthenticatedRef.current = isAuthenticated; @@ -394,7 +403,7 @@ export function useDocumentList() { return { documents, - sharedDocuments: combineSharedDocuments(sharedWithMeDocuments, ownerSharedDocuments), + sharedDocuments, trashedDocuments, isLoading, isLoadingMore, diff --git a/web/icons/Plus.tsx b/web/icons/Plus.tsx new file mode 100644 index 0000000..5e989ac --- /dev/null +++ b/web/icons/Plus.tsx @@ -0,0 +1,7 @@ +import { IconBase, type IconProps } from './IconBase'; + +export const Plus = ({ className, size = 12, strokeWidth = 2 }: IconProps) => ( + + + +); diff --git a/web/icons/index.ts b/web/icons/index.ts index 2ebb3fe..a8926c2 100644 --- a/web/icons/index.ts +++ b/web/icons/index.ts @@ -23,4 +23,5 @@ export { GlobeSolid } from './GlobeSolid'; export { Google } from './Google'; export { Lock } from './Lock'; export { UserCircle } from './UserCircle'; +export { Plus } from './Plus'; export type { IconProps } from './IconBase'; diff --git a/web/jest.setup.js b/web/jest.setup.js index 6a5153d..c42e88e 100644 --- a/web/jest.setup.js +++ b/web/jest.setup.js @@ -7,9 +7,13 @@ import '@testing-library/jest-dom'; // The warning is harmless in tests, so it's better to suppress it. const originalError = console.error; console.error = (...args) => { - if (typeof args[0] === 'string' && args[0].includes('Yjs was already imported')) { - // Suppress yjs double import warning in tests - return; + if (typeof args[0] === 'string') { + if (args[0].includes('Yjs was already imported')) { + return; + } + if (args[0].includes('not wrapped in act(...)')) { + return; + } } originalError.apply(console, args); }; diff --git a/web/lib/sidebar-drop-rules.ts b/web/lib/sidebar-drop-rules.ts new file mode 100644 index 0000000..d64e8c3 --- /dev/null +++ b/web/lib/sidebar-drop-rules.ts @@ -0,0 +1,81 @@ +/** + * Drop rules for the sidebar's two document trees (Private / Shared). + * + * Kept pure and side-effect free so both the drag-over visual gating and the + * drop handler enforce identical policy. + * + * TODO(full-access): collaborators currently cannot re-share documents they do not + * own - there is no FULL_ACCESS access level yet (sharing administration is + * owner-only). Until that exists: + * - moving a document between two shared documents is blocked in the UI; + * - only sibling reordering inside the Shared section is allowed. + * Owners can still reorganize their own trees; the backend enforces the same + * ownership model server-side. + */ + +export type SidebarDropZone = 'top' | 'bottom' | 'mid' | 'empty'; + +export interface SidebarDropRuleContext { + draggedId: string; + /** The dragged document lives in the Shared section. */ + draggedIsShared: boolean; + /** The drop target row lives in the Shared section. */ + targetIsShared: boolean; + draggedParentId: string | null; + targetParentId: string | null; +} + +export type SidebarMoveRoute = + | { kind: 'private' } + | { kind: 'shared-reorder' } + | { kind: 'shared-nest-adopt'; hostParentId: string } + | { kind: 'blocked' }; + +/** Whether the drag-over state for this zone should be shown at all. */ +export function isSidebarDropAllowed(zone: SidebarDropZone, ctx: SidebarDropRuleContext): boolean { + const nestingDrop = zone === 'mid' || zone === 'empty'; + + if (!ctx.draggedIsShared && ctx.targetIsShared) { + // Private -> Shared: only "drop INTO document" nesting is offered. Line drops + // would mean moving to the shared root level, which is not a thing. + return nestingDrop; + } + + if (ctx.draggedIsShared && ctx.targetIsShared) { + if (nestingDrop) { + // TODO(full-access): reparenting inside Shared requires membership control. + return false; + } + // Pure sibling reorder within the same parent (root level included). + return (ctx.draggedParentId ?? null) === (ctx.targetParentId ?? null); + } + + if (!ctx.draggedIsShared && !ctx.targetIsShared) { + // Private intra-tree moves keep the legacy behavior for every zone kind. + return true; + } + + // Shared -> Private is not supported. + return false; +} + +/** Where a resolved drop should be dispatched. */ +export function resolveSidebarMoveRoute( + args: { documentId: string; newParentId: string | null }, + ctx: { draggedIsShared: boolean; targetParentIdIsShared: boolean } +): SidebarMoveRoute { + if (!ctx.draggedIsShared) { + if (args.newParentId != null && ctx.targetParentIdIsShared) { + return { kind: 'shared-nest-adopt', hostParentId: args.newParentId }; + } + return { kind: 'private' }; + } + // For shared documents: root-level documents have newParentId === null (where + // targetParentIdIsShared is false). Sibling reorders at root or within the same + // shared parent route to 'shared-reorder'. Note: Cross-tree drops (shared -> private) + // are already filtered out earlier by isSidebarDropAllowed. + if (args.newParentId == null || ctx.targetParentIdIsShared) { + return { kind: 'shared-reorder' }; + } + return { kind: 'blocked' }; +} diff --git a/web/package.json b/web/package.json index 1b3b6a6..a744032 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,9 @@ "@blocknote/core": "^0.51.4", "@blocknote/react": "^0.51.4", "@blocknote/shadcn": "^0.51.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.12.0", "idb": "^8.0.3", diff --git a/web/services/document.service.ts b/web/services/document.service.ts index 3646164..7f9de63 100644 --- a/web/services/document.service.ts +++ b/web/services/document.service.ts @@ -7,6 +7,7 @@ import { createDefaultDocumentMeta, } from '@/lib/yjs.util'; import type { DocumentMeta, DocumentLoadResult, StoredDocument } from '@/types/document.types'; +import type { TreeNode, TreeNodePage, MoveDocumentRequest } from '@/types/tree.types'; const CURRENT_SCHEMA_VERSION = 1; const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'; @@ -34,6 +35,11 @@ interface ApiDocument { icon?: string | null; coverImage?: string | null; yjsState?: string | null; + parentId?: string | null; + orderKey?: string | null; + hasChildren?: boolean; + hasCollaborators?: boolean; + accessLevel?: DocumentAccessLevel | null; createdBy?: string | null; createdAt: string; updatedAt: string; @@ -49,6 +55,7 @@ interface ApiDocumentAccess { allowed: boolean; accessLevel: DocumentAccessLevel | null; owner: boolean; + trashed?: boolean; } interface ApiCollaborator { @@ -66,7 +73,15 @@ interface ApiSharingSettings { } export interface CloudDocumentsPage { - items: { id: string; meta: DocumentMeta }[]; + items: { + id: string; + meta: DocumentMeta; + parentId: string | null; + orderKey?: string | null; + hasChildren?: boolean; + hasCollaborators?: boolean; + accessLevel?: DocumentAccessLevel | null; + }[]; page: number; size: number; totalElements: number; @@ -79,6 +94,8 @@ export interface DocumentAccess { allowed: boolean; accessLevel: DocumentAccessLevel | null; owner: boolean; + /** True when the document is in trash; accessLevel then reflects pre-trash access. */ + trashed?: boolean; } export interface Collaborator { @@ -95,6 +112,13 @@ export interface SharingSettings { hasActiveLink: boolean; } +export interface DocumentBreadcrumbItem { + id: string; + title: string; + icon?: string | null; + parentId?: string | null; +} + export class DocumentServiceApiError extends Error { readonly status: number; @@ -202,16 +226,30 @@ class DocumentService { accessToken: string, page = 0, size = 20, - options?: { trashed?: boolean } + options?: { + parentId?: string; + scope?: 'all' | 'private' | 'shared'; + trashed?: boolean; + sort?: string; + } ): Promise { const params = new URLSearchParams({ page: String(page), size: String(size), }); + if (options?.parentId) { + params.set('parentId', options.parentId); + } + if (options?.scope) { + params.set('scope', options.scope); + } if (options?.trashed) { params.set('trashed', 'true'); } + if (options?.sort) { + params.set('sort', options.sort); + } const body = await this.fetchApi>( `/api/v1/documents?${params.toString()}`, @@ -221,7 +259,15 @@ class DocumentService { } ); - const items = body.content.map((doc) => ({ id: doc.id, meta: this.toDocumentMeta(doc) })); + const items = body.content.map((doc) => ({ + id: doc.id, + meta: this.toDocumentMeta(doc), + parentId: doc.parentId ?? null, + orderKey: doc.orderKey ?? null, + hasChildren: doc.hasChildren ?? false, + hasCollaborators: doc.hasCollaborators ?? false, + accessLevel: doc.accessLevel ?? null, + })); return { items, @@ -233,11 +279,94 @@ class DocumentService { }; } + public async listRootTreeNodes(accessToken: string, page = 0, size = 50): Promise { + const pageResult = await this.listCloudDocuments(accessToken, page, size, { + parentId: 'root', + scope: 'private', + }); + + return { + items: pageResult.items.map((item) => ({ + id: item.id, + title: item.meta.title, + parentId: item.parentId, + orderKey: item.orderKey ?? '', + hasChildren: item.hasChildren ?? false, + effectiveAccessLevel: item.accessLevel ?? 'OWNER', + createdAt: item.meta.createdAt, + updatedAt: item.meta.updatedAt, + })), + page: pageResult.page, + size: pageResult.size, + totalElements: pageResult.totalElements, + totalPages: pageResult.totalPages, + hasMore: pageResult.hasMore, + }; + } + + public async listChildTreeNodes( + parentId: string, + accessToken: string, + page = 0, + size = 50 + ): Promise { + const pageResult = await this.listCloudDocuments(accessToken, page, size, { + parentId, + }); + + return { + items: pageResult.items.map((item) => ({ + id: item.id, + title: item.meta.title, + parentId: item.parentId, + orderKey: item.orderKey ?? '', + hasChildren: item.hasChildren ?? false, + effectiveAccessLevel: item.accessLevel ?? null, + createdAt: item.meta.createdAt, + updatedAt: item.meta.updatedAt, + })), + page: pageResult.page, + size: pageResult.size, + totalElements: pageResult.totalElements, + totalPages: pageResult.totalPages, + hasMore: pageResult.hasMore, + }; + } + + public async moveDocument( + documentId: string, + request: MoveDocumentRequest, + accessToken: string + ): Promise { + const body = await this.fetchApi( + `/api/v1/documents/${encodeURIComponent(documentId)}/move`, + { + method: 'POST', + accessToken, + body: JSON.stringify(request), + } + ); + + this.emitCloudDocumentsChanged(); + return { + id: body.id, + title: body.title, + parentId: body.parentId ?? null, + orderKey: body.orderKey ?? '', + hasChildren: body.hasChildren ?? false, + effectiveAccessLevel: body.accessLevel ?? null, + createdAt: body.createdAt, + updatedAt: body.updatedAt, + }; + } + public async getCloudDocument( id: string, accessToken: string, - includeTrashed = true + options?: { includeTrashed?: boolean } | boolean ): Promise { + const includeTrashed = + typeof options === 'boolean' ? options : (options?.includeTrashed ?? false); const body = await this.fetchApi( `/api/v1/documents/${encodeURIComponent(id)}${includeTrashed ? '?includeTrashed=true' : ''}`, { @@ -274,6 +403,28 @@ class DocumentService { }; } + public async getDocumentBreadcrumbs( + id: string, + accessToken?: string | null + ): Promise { + if (accessToken) { + return await this.fetchApi( + `/api/v1/documents/${encodeURIComponent(id)}/path`, + { + method: 'GET', + accessToken, + } + ); + } else { + return await this.fetchApi( + `/api/v1/documents/${encodeURIComponent(id)}/public/path`, + { + method: 'GET', + } + ); + } + } + public async getMyAccess(id: string, accessToken: string): Promise { const body = await this.fetchApi( `/api/v1/documents/${encodeURIComponent(id)}/my-access`, @@ -288,6 +439,7 @@ class DocumentService { allowed: body.allowed, accessLevel: body.accessLevel, owner: body.owner, + trashed: body.trashed, }; } @@ -296,29 +448,9 @@ class DocumentService { page = 0, size = 20 ): Promise { - const params = new URLSearchParams({ - page: String(page), - size: String(size), + return this.listCloudDocuments(accessToken, page, size, { + scope: 'shared', }); - - const body = await this.fetchApi>( - `/api/v1/documents/shared-with-me?${params.toString()}`, - { - method: 'GET', - accessToken, - } - ); - - const items = body.content.map((doc) => ({ id: doc.id, meta: this.toDocumentMeta(doc) })); - - return { - items, - page: body.number, - size: body.size, - totalElements: body.totalElements, - totalPages: body.totalPages, - hasMore: !body.last, - }; } public async listCollaborators(documentId: string, accessToken: string): Promise { @@ -468,7 +600,10 @@ class DocumentService { id: string, title = 'Untitled', ydoc?: Y.Doc, - createdBy?: string | null + createdBy?: string | null, + parentId?: string | null, + prevSiblingId?: string | null, + nextSiblingId?: string | null ): Promise<{ id: string; ydoc: Y.Doc; meta: DocumentMeta }> { const documentYDoc = ydoc ?? createYjsDoc(); const payload = { @@ -476,6 +611,9 @@ class DocumentService { title, yjsState: this.uint8ArrayToBase64(encodeYjsState(documentYDoc)), createdBy: createdBy ?? 'NextDocs User', + parentId: parentId ?? null, + prevSiblingId: prevSiblingId ?? null, + nextSiblingId: nextSiblingId ?? null, }; const body = await this.fetchApi('/api/v1/documents', { @@ -743,7 +881,7 @@ class DocumentService { if (!res.ok || !body?.success || body.data == null) { throw new DocumentServiceApiError( - body?.error ?? `Request failed: ${options.method} ${path}`, + body?.message || body?.error || `Request failed: ${options.method} ${path}`, res.status ); } diff --git a/web/stores/documentList/documentList.selectors.ts b/web/stores/documentList/documentList.selectors.ts new file mode 100644 index 0000000..70d588e --- /dev/null +++ b/web/stores/documentList/documentList.selectors.ts @@ -0,0 +1,12 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from '../store'; + +export const selectSharedWithMeDocumentIds = createSelector( + (state: RootState) => state.documentList?.sharedWithMeDocuments ?? [], + (sharedWithMe) => sharedWithMe.map((doc) => doc.id) +); + +export const selectRootLevelOwnerSharedDocumentIds = createSelector( + (state: RootState) => state.documentList?.ownerSharedDocuments ?? [], + (ownerShared) => ownerShared.filter((doc) => doc.parentId == null).map((doc) => doc.id) +); diff --git a/web/stores/documentList/documentList.slice.ts b/web/stores/documentList/documentList.slice.ts index 4651311..adea346 100644 --- a/web/stores/documentList/documentList.slice.ts +++ b/web/stores/documentList/documentList.slice.ts @@ -10,8 +10,7 @@ import type { SharedDocumentEntry, } from './documentList.types'; import { - INITIAL_DOCS_COUNT, - PAGE_SIZE, + DOCS_PAGE_SIZE, sortByUpdatedAtDesc, mergeUniqueDocuments, updateDocumentMetaInList, @@ -83,37 +82,11 @@ export const fetchDocumentsThunk = createAsyncThunk< const expandedMode = keepExpanded && isShowingAll; if (isAuthenticated && accessToken && !isCloudUnavailable) { - const pageSize = expandedMode ? PAGE_SIZE : INITIAL_DOCS_COUNT; + const pageSize = DOCS_PAGE_SIZE; try { const page = await documentService.listCloudDocuments(accessToken, 0, pageSize); - let { privateDocs, sharedByOwnerDocs } = await classifyOwnedDocuments( - page.items, - isAuthenticated, - accessToken - ); - let hasMoreAfterClassify = page.hasMore; - - if ( - !expandedMode && - page.hasMore && - page.items.length === INITIAL_DOCS_COUNT && - sharedByOwnerDocs.length > 0 && - privateDocs.length < INITIAL_DOCS_COUNT - ) { - const expandedSeedPage = await documentService.listCloudDocuments( - accessToken, - 0, - PAGE_SIZE - ); - const expandedClassified = await classifyOwnedDocuments( - expandedSeedPage.items, - isAuthenticated, - accessToken - ); - privateDocs = expandedClassified.privateDocs; - sharedByOwnerDocs = expandedClassified.sharedByOwnerDocs; - hasMoreAfterClassify = expandedSeedPage.hasMore; - } + const { privateDocs, sharedByOwnerDocs } = classifyOwnedDocuments(page.items); + const hasMoreAfterClassify = page.hasMore; void ensureCloudDocsCachedLocally( [...privateDocs, ...sharedByOwnerDocs], @@ -150,9 +123,7 @@ export const fetchDocumentsThunk = createAsyncThunk< ]); const localPrivateDocs = allLocalDocs.filter((doc) => !sharedDocIds.has(doc.id)); - const initialCount = expandedMode - ? Math.min(PAGE_SIZE, localPrivateDocs.length) - : Math.min(INITIAL_DOCS_COUNT, localPrivateDocs.length); + const initialCount = Math.min(DOCS_PAGE_SIZE, localPrivateDocs.length); const initialDocs = localPrivateDocs.slice(0, initialCount); return { @@ -182,9 +153,7 @@ export const fetchDocumentsThunk = createAsyncThunk< privateDocs = docs; } - const initialCount = expandedMode - ? Math.min(PAGE_SIZE, privateDocs.length) - : Math.min(INITIAL_DOCS_COUNT, privateDocs.length); + const initialCount = Math.min(DOCS_PAGE_SIZE, privateDocs.length); const initialDocs = privateDocs.slice(0, initialCount); return { @@ -222,7 +191,6 @@ export const fetchSharedDocumentsThunk = createAsyncThunk< const state = getState(); const { user, accessToken } = state.auth; const isAuthenticated = !!user && !!accessToken; - const { isShowingAllShared } = state.documentList; if (!isAuthenticated || !accessToken) { return { @@ -240,7 +208,7 @@ export const fetchSharedDocumentsThunk = createAsyncThunk< }; } - const pageSize = isShowingAllShared ? PAGE_SIZE : INITIAL_DOCS_COUNT; + const pageSize = DOCS_PAGE_SIZE; try { const sharedPage = await documentService.listSharedDocuments(accessToken, 0, pageSize); const items = sharedPage.items.map((doc) => ({ @@ -311,7 +279,7 @@ export const fetchTrashDocumentsThunk = createAsyncThunk< } try { - const page = await documentService.listCloudDocuments(accessToken, 0, PAGE_SIZE, { + const page = await documentService.listCloudDocuments(accessToken, 0, DOCS_PAGE_SIZE, { trashed: true, }); return { @@ -371,10 +339,10 @@ export const loadMoreDocumentsThunk = createAsyncThunk< const page = await documentService.listCloudDocuments( accessToken, nextCloudPage, - PAGE_SIZE + DOCS_PAGE_SIZE ); const { privateDocs: nextPrivateDocs, sharedByOwnerDocs: nextOwnerSharedDocs } = - await classifyOwnedDocuments(page.items, isAuthenticated, accessToken); + classifyOwnedDocuments(page.items); const seen = new Set(documents.map((doc) => doc.id)); const filteredDocs = nextPrivateDocs.filter((doc) => !seen.has(doc.id)); @@ -421,7 +389,7 @@ export const loadMoreDocumentsThunk = createAsyncThunk< ]); const localPrivateDocs = localAllDocs.filter((doc) => !sharedDocIds.has(doc.id)); - const nextChunk = localPrivateDocs.slice(localLoadedCount, localLoadedCount + PAGE_SIZE); + const nextChunk = localPrivateDocs.slice(localLoadedCount, localLoadedCount + DOCS_PAGE_SIZE); const seen = new Set(documents.map((doc) => doc.id)); const filteredDocs = nextChunk.filter((doc) => !seen.has(doc.id)); @@ -470,7 +438,7 @@ export const loadMoreSharedDocumentsThunk = createAsyncThunk< const page = await documentService.listSharedDocuments( accessToken!, nextSharedPage, - PAGE_SIZE + DOCS_PAGE_SIZE ); const mapped = page.items.map((doc) => ({ ...doc, @@ -555,7 +523,7 @@ export const loadMoreTrashDocumentsThunk = createAsyncThunk< const page = await documentService.listCloudDocuments( accessToken!, nextTrashCloudPage, - PAGE_SIZE, + DOCS_PAGE_SIZE, { trashed: true } ); @@ -638,7 +606,7 @@ const documentListSlice = createSlice({ state.documents = state.isShowingAll ? sortedPrivateDocs - : sortedPrivateDocs.slice(0, INITIAL_DOCS_COUNT); + : sortedPrivateDocs.slice(0, DOCS_PAGE_SIZE); } const existsLocally = state.localAllDocs.some((doc) => doc.id === id); diff --git a/web/stores/documentList/documentList.types.ts b/web/stores/documentList/documentList.types.ts index b57784d..3fc3578 100644 --- a/web/stores/documentList/documentList.types.ts +++ b/web/stores/documentList/documentList.types.ts @@ -1,12 +1,21 @@ +import type { DocumentAccessLevel } from '@/services/document.service'; import type { DocumentMeta } from '@/types/document.types'; export interface LocalDocumentEntry { id: string; meta: DocumentMeta; + /** Personal navigation order key from the server (null for nested documents). */ + orderKey?: string | null; + /** Real parent in the owning user's tree (null for root-level documents). */ + parentId?: string | null; + /** Document access level for the current user. */ + accessLevel?: DocumentAccessLevel | null; } export interface SharedDocumentEntry extends LocalDocumentEntry { relationship: 'owner' | 'collaborator'; + /** Real parent in the owning user's tree (null for root-level documents). */ + parentId: string | null; } export interface DocumentListState { diff --git a/web/stores/documentList/documentList.utils.ts b/web/stores/documentList/documentList.utils.ts index 5beb1fd..090c9bf 100644 --- a/web/stores/documentList/documentList.utils.ts +++ b/web/stores/documentList/documentList.utils.ts @@ -3,9 +3,9 @@ import { toSortableTimestamp } from '@/lib/timestamp.util'; import type { DocumentMeta } from '@/types/document.types'; import type { LocalDocumentEntry, SharedDocumentEntry } from './documentList.types'; -export const INITIAL_DOCS_COUNT = 7; -export const PAGE_SIZE = 20; -export const COLLABORATOR_CHECK_CONCURRENCY = 4; +export const DOCS_PAGE_SIZE = 50; +export const INITIAL_DOCS_COUNT = DOCS_PAGE_SIZE; +export const PAGE_SIZE = DOCS_PAGE_SIZE; export const CACHE_SYNC_CONCURRENCY = 4; export async function mapWithConcurrency( @@ -81,33 +81,25 @@ export function combineSharedDocuments( return mergeUniqueDocuments(sharedWithMeDocuments, ownerSharedDocuments); } -export async function classifyOwnedDocuments( - docs: LocalDocumentEntry[], - isAuthenticated: boolean, - accessToken: string | null -): Promise<{ privateDocs: LocalDocumentEntry[]; sharedByOwnerDocs: SharedDocumentEntry[] }> { - if (!isAuthenticated || !accessToken || docs.length === 0) { - return { privateDocs: docs, sharedByOwnerDocs: [] }; +export function classifyOwnedDocuments( + docs: (LocalDocumentEntry & { parentId: string | null; hasCollaborators?: boolean })[] +): { privateDocs: LocalDocumentEntry[]; sharedByOwnerDocs: SharedDocumentEntry[] } { + if (docs.length === 0) { + return { privateDocs: [], sharedByOwnerDocs: [] }; } - const checks = await mapWithConcurrency(docs, COLLABORATOR_CHECK_CONCURRENCY, async (doc) => { - try { - const collaborators = await documentService.listCollaborators(doc.id, accessToken); - const hasExtraUser = collaborators.some( - (collaborator) => collaborator.accessLevel !== 'OWNER' - ); - return { doc, hasExtraUser }; - } catch (error) { - console.warn(`Failed to resolve collaborators for document ${doc.id}:`, error); - return { doc, hasExtraUser: false }; - } - }); - return { - privateDocs: checks.filter((entry) => !entry.hasExtraUser).map((entry) => entry.doc), - sharedByOwnerDocs: checks - .filter((entry) => entry.hasExtraUser) - .map((entry) => ({ ...entry.doc, relationship: 'owner' })), + // Nested documents (real parentId set) stay in the private tree under their + // actual parent even when they have collaborators; only root-level shared + // documents are surfaced in the Shared section. + privateDocs: docs.filter((doc) => !doc.hasCollaborators || doc.parentId != null), + sharedByOwnerDocs: docs + .filter((doc) => Boolean(doc.hasCollaborators) && doc.parentId == null) + .map((doc) => ({ + ...doc, + relationship: 'owner' as const, + accessLevel: 'OWNER' as const, + })), }; } @@ -151,7 +143,9 @@ export async function ensureCloudDocsCachedLocally( cacheSyncInFlight.add(flightKey); try { - const cloudDoc = await documentService.getCloudDocument(doc.id, accessToken); + const cloudDoc = await documentService.getCloudDocument(doc.id, accessToken, { + includeTrashed: false, + }); await documentService.saveDocument(doc.id, cloudDoc.ydoc, cloudDoc.meta, { touchUpdatedAt: false, }); diff --git a/web/stores/sharedTree/sharedTree.slice.ts b/web/stores/sharedTree/sharedTree.slice.ts new file mode 100644 index 0000000..f8f9903 --- /dev/null +++ b/web/stores/sharedTree/sharedTree.slice.ts @@ -0,0 +1,377 @@ +import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; +import { documentService } from '@/services/document.service'; +import type { RootState } from '../store'; +import type { SidebarTreeNode, TreeNode, MoveDocumentRequest } from '@/types/tree.types'; +import type { SharedDocumentEntry } from '../documentList/documentList.types'; +import { compareOrderKeys, toSidebarTreeNode } from '../sidebarTree/sidebarTree.slice'; + +export interface SharedTreeState { + nodes: Record; + rootIds: string[]; +} + +const initialState: SharedTreeState = { + nodes: {}, + rootIds: [], +}; + +export interface MoveDocumentArgs { + documentId: string; + newParentId: string | null; + prevSiblingId: string | null; + nextSiblingId: string | null; +} + +export const fetchChildrenThunk = createAsyncThunk< + { parentId: string; children: TreeNode[] }, + { parentId: string }, + { state: RootState } +>('sharedTree/fetchChildren', async ({ parentId }, { getState }) => { + const state = getState(); + const { accessToken } = state.auth; + if (!accessToken) { + return { parentId, children: [] }; + } + + const result = await documentService.listChildTreeNodes(parentId, accessToken, 0, 50); + return { + parentId, + children: result.items, + }; +}); + +export const moveDocumentThunk = createAsyncThunk< + { updatedNode: TreeNode; prevSiblingId: string | null; nextSiblingId: string | null }, + MoveDocumentArgs, + { state: RootState } +>( + 'sharedTree/moveDocument', + async ({ documentId, newParentId, prevSiblingId, nextSiblingId }, { getState }) => { + const state = getState(); + const { accessToken } = state.auth; + if (!accessToken) { + throw new Error('Not authenticated'); + } + + const request: MoveDocumentRequest = { + newParentId, + prevSiblingId, + nextSiblingId, + }; + + const updatedNode = await documentService.moveDocument(documentId, request, accessToken); + return { updatedNode, prevSiblingId, nextSiblingId }; + } +); + +const sharedTreeSlice = createSlice({ + name: 'sharedTree', + initialState, + reducers: { + toggleExpanded(state, action: PayloadAction) { + const id = action.payload; + const node = state.nodes[id]; + if (node) { + node.isExpanded = !node.isExpanded; + } + }, + + removeNode(state, action: PayloadAction) { + const id = action.payload; + const node = state.nodes[id]; + if (!node) return; + + if (node.parentId && state.nodes[node.parentId]) { + const parent = state.nodes[node.parentId]; + parent.children = parent.children.filter((childId) => childId !== id); + if (parent.children.length === 0) { + parent.hasChildren = false; + } + } else { + state.rootIds = state.rootIds.filter((rootId) => rootId !== id); + } + + const toDelete = [id]; + const queue = [id]; + while (queue.length > 0) { + const currentId = queue.shift()!; + const currentNode = state.nodes[currentId]; + if (currentNode?.children) { + for (const childId of currentNode.children) { + toDelete.push(childId); + queue.push(childId); + } + } + } + + for (const deleteId of toDelete) { + delete state.nodes[deleteId]; + } + }, + + resetTree(state) { + state.nodes = {}; + state.rootIds = []; + }, + + /** + * Rebuilds the root list from the shared-documents list. + * A document appears in the Shared section only when it is BOTH truly + * root-level (real `parentId` is null) AND has active collaborators — + * except shared-with-me documents, which have no position in the user's + * own tree and therefore float at the root of the Shared section when + * their real parent is not also shared with the user. + * Nested documents owned by the user (real `parentId` set) are NOT + * synthesized here: they stay in the Private tree under their actual + * parent. This guarantees that reordering inside the Shared section can + * never detach a nested document from its real parent. + */ + syncSharedRoots(state, action: PayloadAction) { + const entries = action.payload; + const entryIds = new Set(entries.map((entry) => entry.id)); + const entryById = new Map(entries.map((entry) => [entry.id, entry])); + + const rootEntryIds = new Set(); + const entryNodes: Record = {}; + + for (const entry of entries) { + if (entry.relationship === 'owner' && entry.parentId != null) { + continue; + } + + const parentEntry = entry.parentId ? entryById.get(entry.parentId) : undefined; + const parentIsPrivateNested = + !!parentEntry && parentEntry.relationship === 'owner' && parentEntry.parentId != null; + const isChild = + entry.relationship === 'collaborator' && + entry.parentId != null && + entryIds.has(entry.parentId) && + !parentIsPrivateNested; + + entryNodes[entry.id] = { + id: entry.id, + title: entry.meta.title || 'Untitled', + parentId: isChild ? entry.parentId : null, + orderKey: entry.orderKey ?? `shared:${entry.id}`, + hasChildren: false, + effectiveAccessLevel: + entry.relationship === 'owner' ? 'OWNER' : (entry.accessLevel ?? null), + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: entry.meta.createdAt, + updatedAt: entry.meta.updatedAt, + }; + + if (!isChild) { + rootEntryIds.add(entry.id); + } + } + + // Preserve fetched children / expansion state for entries that keep a + // place in the Shared tree, then merge the fresh entries. + const keepIds = new Set([...rootEntryIds]); + for (const id of Object.keys(entryNodes)) { + keepIds.add(id); + } + for (const id of Object.keys(state.nodes)) { + if (!keepIds.has(id) || !entryNodes[id]) { + continue; + } + const existing = state.nodes[id]; + const preservedOrderKey = + entryNodes[id].orderKey && !entryNodes[id].orderKey.startsWith('shared:') + ? entryNodes[id].orderKey + : existing.orderKey && !existing.orderKey.startsWith('shared:') + ? existing.orderKey + : entryNodes[id].orderKey; + entryNodes[id] = { + ...entryNodes[id], + orderKey: preservedOrderKey, + effectiveAccessLevel: + entryNodes[id].effectiveAccessLevel ?? existing.effectiveAccessLevel, + hasChildren: existing.hasChildren, + isExpanded: existing.isExpanded, + isLoading: existing.isLoading, + children: existing.children, + childrenLoaded: existing.childrenLoaded, + }; + } + + // Link nested shared-with-me entries under their shared parent so the + // tree reflects the documents' real parent-child relationships. + // Two-pass: all nodes are created before linking so child-before-parent + // ordering (pagination) does not matter. + for (const entry of entries) { + if (entry.relationship !== 'collaborator' || entry.parentId == null) { + continue; + } + if (!entryIds.has(entry.parentId)) { + continue; + } + const parentEntry = entryById.get(entry.parentId); + if (parentEntry?.relationship === 'owner' && parentEntry.parentId != null) { + continue; + } + const parentNode = entryNodes[entry.parentId]; + if (!parentNode) { + continue; + } + parentNode.hasChildren = true; + if (!parentNode.children.includes(entry.id)) { + parentNode.children.push(entry.id); + } + } + + Object.assign(state.nodes, entryNodes); + + // Prune nodes that are no longer reachable from the roots (unshared + // documents, nested owned documents, removed parents, ...). + const reachable = new Set(rootEntryIds); + const queue = [...rootEntryIds]; + while (queue.length > 0) { + const id = queue.shift()!; + const node = state.nodes[id]; + if (!node) { + continue; + } + for (const childId of node.children) { + if (!reachable.has(childId)) { + reachable.add(childId); + queue.push(childId); + } + } + } + for (const id of Object.keys(state.nodes)) { + if (!reachable.has(id)) { + delete state.nodes[id]; + } + } + + // Roots: always order root documents by the caller's personal navigation order key. + const newRootIds = entries + .filter((entry) => rootEntryIds.has(entry.id)) + .map((entry) => entry.id); + + newRootIds.sort((aId, bId) => { + const keyA = entryNodes[aId]?.orderKey ?? ''; + const keyB = entryNodes[bId]?.orderKey ?? ''; + if (!keyA.startsWith('shared:') && !keyB.startsWith('shared:')) { + return compareOrderKeys(keyA, keyB); + } + return 0; + }); + state.rootIds = newRootIds; + }, + }, + extraReducers: (builder) => { + // fetchChildrenThunk + builder + .addCase(fetchChildrenThunk.pending, (state, action) => { + const parentId = action.meta.arg.parentId; + if (state.nodes[parentId]) { + state.nodes[parentId].isLoading = true; + } + }) + .addCase(fetchChildrenThunk.fulfilled, (state, action) => { + const { parentId, children } = action.payload; + const parent = state.nodes[parentId]; + if (parent) { + parent.isLoading = false; + parent.childrenLoaded = true; + parent.hasChildren = children.length > 0; + + const childIds: string[] = []; + for (const rawChild of children) { + childIds.push(rawChild.id); + const existing = state.nodes[rawChild.id]; + state.nodes[rawChild.id] = toSidebarTreeNode( + rawChild, + existing ? existing.isExpanded : false + ); + if (existing) { + state.nodes[rawChild.id].children = existing.children; + state.nodes[rawChild.id].childrenLoaded = existing.childrenLoaded; + } + } + parent.children = childIds; + } + }) + .addCase(fetchChildrenThunk.rejected, (state, action) => { + const parentId = action.meta.arg.parentId; + if (state.nodes[parentId]) { + state.nodes[parentId].isLoading = false; + } + }); + + // moveDocumentThunk + builder.addCase(moveDocumentThunk.fulfilled, (state, action) => { + const { updatedNode } = action.payload; + const existing = state.nodes[updatedNode.id]; + const oldParentId = existing?.parentId ?? null; + + // Remove from old location + if (oldParentId && state.nodes[oldParentId]) { + const oldParent = state.nodes[oldParentId]; + oldParent.children = oldParent.children.filter((cid) => cid !== updatedNode.id); + if (oldParent.children.length === 0) { + oldParent.hasChildren = false; + } + } else if (!oldParentId) { + state.rootIds = state.rootIds.filter((rid) => rid !== updatedNode.id); + } + + // Update node record. + // If a moved document has a parentId that is not in state.nodes, its parent is private to + // the owner and not shared with this user. It therefore acts as a floated root in the + // caller's Shared tree (effectiveParentId = null). + // Note: Storing effectiveParentId into state.nodes[id].parentId is intentional for SidebarTreeNode + // UI state so that tree rendering, reachability, and root drag-and-drop constraints function + // correctly without referencing missing private parent nodes. + const isFloatedRoot = Boolean(updatedNode.parentId && !state.nodes[updatedNode.parentId]); + const effectiveParentId = isFloatedRoot ? null : updatedNode.parentId; + + state.nodes[updatedNode.id] = toSidebarTreeNode( + { + ...updatedNode, + parentId: effectiveParentId, + }, + existing ? existing.isExpanded : false + ); + if (existing) { + state.nodes[updatedNode.id].children = existing.children; + state.nodes[updatedNode.id].childrenLoaded = existing.childrenLoaded; + } + + if (effectiveParentId && state.nodes[effectiveParentId]) { + const newParent = state.nodes[effectiveParentId]; + newParent.hasChildren = true; + if (!newParent.children.includes(updatedNode.id)) { + newParent.children.push(updatedNode.id); + } + newParent.isExpanded = true; + newParent.children.sort((aId, bId) => { + const keyA = state.nodes[aId]?.orderKey ?? ''; + const keyB = state.nodes[bId]?.orderKey ?? ''; + return compareOrderKeys(keyA, keyB); + }); + } else { + // Root-level move or floated nested document (whose parent is external): + // place the node at the root of the Shared tree and sort by personal orderKey. + if (!state.rootIds.includes(updatedNode.id)) { + state.rootIds.push(updatedNode.id); + } + state.rootIds.sort((aId, bId) => { + const keyA = state.nodes[aId]?.orderKey ?? ''; + const keyB = state.nodes[bId]?.orderKey ?? ''; + return compareOrderKeys(keyA, keyB); + }); + } + }); + }, +}); + +export const { toggleExpanded, syncSharedRoots, removeNode, resetTree } = sharedTreeSlice.actions; + +export default sharedTreeSlice.reducer; diff --git a/web/stores/sidebar/sidebar.slice.ts b/web/stores/sidebar/sidebar.slice.ts index 208fa05..75591dd 100644 --- a/web/stores/sidebar/sidebar.slice.ts +++ b/web/stores/sidebar/sidebar.slice.ts @@ -3,6 +3,7 @@ import type { DocumentsPanelMode, DocActionsAnchor } from '@/components/sidebar/ export interface SidebarState { isCollapsed: boolean; + sidebarWidth: number; panelMode: DocumentsPanelMode; searchQuery: string; isPrivateOpen: boolean; @@ -12,6 +13,7 @@ export interface SidebarState { const initialState: SidebarState = { isCollapsed: false, + sidebarWidth: 256, panelMode: null, searchQuery: '', isPrivateOpen: true, @@ -29,6 +31,9 @@ const sidebarSlice = createSlice({ setCollapsed(state, action: PayloadAction) { state.isCollapsed = action.payload; }, + setSidebarWidth(state, action: PayloadAction) { + state.sidebarWidth = action.payload; + }, setPanelMode(state, action: PayloadAction) { state.panelMode = action.payload; }, @@ -55,6 +60,7 @@ const sidebarSlice = createSlice({ export const { toggleCollapsed, setCollapsed, + setSidebarWidth, setPanelMode, setSearchQuery, togglePrivateOpen, diff --git a/web/stores/sidebarTree/sidebarTree.slice.ts b/web/stores/sidebarTree/sidebarTree.slice.ts new file mode 100644 index 0000000..4b4abf4 --- /dev/null +++ b/web/stores/sidebarTree/sidebarTree.slice.ts @@ -0,0 +1,382 @@ +import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; +import { documentService } from '@/services/document.service'; +import type { RootState } from '../store'; +import type { SidebarTreeNode, TreeNode, MoveDocumentRequest } from '@/types/tree.types'; + +export interface SidebarTreeState { + nodes: Record; + rootIds: string[]; + isRootLoading: boolean; + rootHasMore: boolean; + rootPage: number; +} + +const initialState: SidebarTreeState = { + nodes: {}, + rootIds: [], + isRootLoading: false, + rootHasMore: false, + rootPage: 0, +}; + +function compareOrderKeys(keyA: string, keyB: string): number { + if (keyA < keyB) return -1; + if (keyA > keyB) return 1; + return 0; +} + +export { compareOrderKeys }; + +function makeLocalOrderKey(index: number): string { + return `a${String(index).padStart(4, '0')}`; +} + +export function toSidebarTreeNode(node: TreeNode, isExpanded = false): SidebarTreeNode { + return { + id: node.id, + title: node.title || 'Untitled', + parentId: node.parentId, + orderKey: node.orderKey, + hasChildren: node.hasChildren, + effectiveAccessLevel: node.effectiveAccessLevel, + isExpanded, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + }; +} + +export const fetchRootNodesThunk = createAsyncThunk< + { nodes: TreeNode[]; hasMore: boolean; page: number }, + { page?: number; size?: number; append?: boolean } | undefined, + { state: RootState } +>('sidebarTree/fetchRootNodes', async (params, { getState }) => { + const state = getState(); + const { accessToken } = state.auth; + const page = params?.page ?? 0; + const size = params?.size ?? 50; + + if (!accessToken) { + const localDocs = await documentService.getAllDocumentsMeta(); + const activeLocalDocs = localDocs.filter((d) => !d.meta.deletedAt); + const nodes: TreeNode[] = activeLocalDocs.map((d, index) => ({ + id: d.id, + title: d.meta.title || 'Untitled', + parentId: null, + orderKey: makeLocalOrderKey(index), + hasChildren: false, + effectiveAccessLevel: 'OWNER', + createdAt: d.meta.createdAt, + updatedAt: d.meta.updatedAt, + })); + return { nodes, hasMore: false, page: 0 }; + } + + try { + const result = await documentService.listRootTreeNodes(accessToken, page, size); + return { + nodes: result.items, + hasMore: result.hasMore, + page: result.page, + }; + } catch (err) { + console.warn('Failed to fetch cloud tree nodes, falling back to local documents:', err); + const localDocs = await documentService.getAllDocumentsMeta(); + const activeLocalDocs = localDocs.filter((d) => !d.meta.deletedAt); + const nodes: TreeNode[] = activeLocalDocs.map((d, index) => ({ + id: d.id, + title: d.meta.title || 'Untitled', + parentId: null, + orderKey: makeLocalOrderKey(index), + hasChildren: false, + effectiveAccessLevel: 'OWNER', + createdAt: d.meta.createdAt, + updatedAt: d.meta.updatedAt, + })); + return { nodes, hasMore: false, page: 0 }; + } +}); + +export const fetchChildrenThunk = createAsyncThunk< + { parentId: string; children: TreeNode[] }, + { parentId: string }, + { state: RootState } +>('sidebarTree/fetchChildren', async ({ parentId }, { getState }) => { + const state = getState(); + const { accessToken } = state.auth; + if (!accessToken) { + return { parentId, children: [] }; + } + + const result = await documentService.listChildTreeNodes(parentId, accessToken, 0, 50); + return { + parentId, + children: result.items, + }; +}); + +export const moveDocumentThunk = createAsyncThunk< + { updatedNode: TreeNode }, + { + documentId: string; + newParentId: string | null; + prevSiblingId: string | null; + nextSiblingId: string | null; + }, + { state: RootState } +>( + 'sidebarTree/moveDocument', + async ({ documentId, newParentId, prevSiblingId, nextSiblingId }, { getState }) => { + const state = getState(); + const { accessToken } = state.auth; + if (!accessToken) { + throw new Error('Not authenticated'); + } + + const request: MoveDocumentRequest = { + newParentId, + prevSiblingId, + nextSiblingId, + }; + + const updatedNode = await documentService.moveDocument(documentId, request, accessToken); + return { updatedNode }; + } +); + +const sidebarTreeSlice = createSlice({ + name: 'sidebarTree', + initialState, + reducers: { + toggleExpanded(state, action: PayloadAction) { + const id = action.payload; + const node = state.nodes[id]; + if (node) { + node.isExpanded = !node.isExpanded; + } + }, + + setNodeExpanded(state, action: PayloadAction<{ id: string; expanded: boolean }>) { + const { id, expanded } = action.payload; + const node = state.nodes[id]; + if (node) { + node.isExpanded = expanded; + } + }, + + updateNodeMeta(state, action: PayloadAction<{ id: string; title?: string }>) { + const { id, title } = action.payload; + const node = state.nodes[id]; + if (node) { + if (title !== undefined) { + node.title = title || 'Untitled'; + } + } + }, + + addNode(state, action: PayloadAction) { + const newNode = toSidebarTreeNode(action.payload); + state.nodes[newNode.id] = newNode; + + if (newNode.parentId && state.nodes[newNode.parentId]) { + const parent = state.nodes[newNode.parentId]; + parent.hasChildren = true; + if (!parent.children.includes(newNode.id)) { + parent.children.push(newNode.id); + } + parent.isExpanded = true; + parent.children.sort((aId, bId) => { + const keyA = state.nodes[aId]?.orderKey ?? ''; + const keyB = state.nodes[bId]?.orderKey ?? ''; + return compareOrderKeys(keyA, keyB); + }); + } else if (!newNode.parentId) { + if (!state.rootIds.includes(newNode.id)) { + state.rootIds.push(newNode.id); + } + state.rootIds.sort((aId, bId) => { + const keyA = state.nodes[aId]?.orderKey ?? ''; + const keyB = state.nodes[bId]?.orderKey ?? ''; + return compareOrderKeys(keyA, keyB); + }); + } + }, + + removeNode(state, action: PayloadAction) { + const id = action.payload; + const node = state.nodes[id]; + if (!node) return; + + if (node.parentId && state.nodes[node.parentId]) { + const parent = state.nodes[node.parentId]; + parent.children = parent.children.filter((childId) => childId !== id); + if (parent.children.length === 0) { + parent.hasChildren = false; + } + } else { + state.rootIds = state.rootIds.filter((rootId) => rootId !== id); + } + + const toDelete = [id]; + const queue = [id]; + while (queue.length > 0) { + const currentId = queue.shift()!; + const currentNode = state.nodes[currentId]; + if (currentNode?.children) { + for (const childId of currentNode.children) { + toDelete.push(childId); + queue.push(childId); + } + } + } + + for (const deleteId of toDelete) { + delete state.nodes[deleteId]; + } + }, + + resetTree(state) { + state.nodes = {}; + state.rootIds = []; + state.isRootLoading = false; + state.rootHasMore = false; + state.rootPage = 0; + }, + }, + extraReducers: (builder) => { + // fetchRootNodesThunk + builder + .addCase(fetchRootNodesThunk.pending, (state) => { + state.isRootLoading = true; + }) + .addCase(fetchRootNodesThunk.fulfilled, (state, action) => { + state.isRootLoading = false; + state.rootHasMore = action.payload.hasMore; + state.rootPage = action.payload.page; + + const newRootIds: string[] = []; + for (const rawNode of action.payload.nodes) { + newRootIds.push(rawNode.id); + const existing = state.nodes[rawNode.id]; + state.nodes[rawNode.id] = toSidebarTreeNode( + rawNode, + existing ? existing.isExpanded : false + ); + if (existing) { + state.nodes[rawNode.id].children = existing.children; + state.nodes[rawNode.id].childrenLoaded = existing.childrenLoaded; + } + } + + if (action.meta.arg?.append) { + // Pagination: keep the current (drag) root order and append roots + // from the next page that are not already loaded. + const existingSet = new Set(state.rootIds); + state.rootIds = [...state.rootIds, ...newRootIds.filter((id) => !existingSet.has(id))]; + } else { + state.rootIds = newRootIds; + } + }) + .addCase(fetchRootNodesThunk.rejected, (state) => { + state.isRootLoading = false; + }); + + // fetchChildrenThunk + builder + .addCase(fetchChildrenThunk.pending, (state, action) => { + const parentId = action.meta.arg.parentId; + if (state.nodes[parentId]) { + state.nodes[parentId].isLoading = true; + } + }) + .addCase(fetchChildrenThunk.fulfilled, (state, action) => { + const { parentId, children } = action.payload; + const parent = state.nodes[parentId]; + if (parent) { + parent.isLoading = false; + parent.childrenLoaded = true; + parent.hasChildren = children.length > 0; + + const childIds: string[] = []; + for (const rawChild of children) { + childIds.push(rawChild.id); + const existing = state.nodes[rawChild.id]; + state.nodes[rawChild.id] = toSidebarTreeNode( + rawChild, + existing ? existing.isExpanded : false + ); + if (existing) { + state.nodes[rawChild.id].children = existing.children; + state.nodes[rawChild.id].childrenLoaded = existing.childrenLoaded; + } + } + parent.children = childIds; + } + }) + .addCase(fetchChildrenThunk.rejected, (state, action) => { + const parentId = action.meta.arg.parentId; + if (state.nodes[parentId]) { + state.nodes[parentId].isLoading = false; + } + }); + + // moveDocumentThunk + builder.addCase(moveDocumentThunk.fulfilled, (state, action) => { + const { updatedNode } = action.payload; + const existing = state.nodes[updatedNode.id]; + const oldParentId = existing?.parentId ?? null; + const newParentId = updatedNode.parentId; + + // Remove from old location + if (oldParentId && state.nodes[oldParentId]) { + const oldParent = state.nodes[oldParentId]; + oldParent.children = oldParent.children.filter((cid) => cid !== updatedNode.id); + if (oldParent.children.length === 0) { + oldParent.hasChildren = false; + } + } else if (!oldParentId) { + state.rootIds = state.rootIds.filter((rid) => rid !== updatedNode.id); + } + + // Add to new location + state.nodes[updatedNode.id] = toSidebarTreeNode( + updatedNode, + existing ? existing.isExpanded : false + ); + if (existing) { + state.nodes[updatedNode.id].children = existing.children; + state.nodes[updatedNode.id].childrenLoaded = existing.childrenLoaded; + } + + if (newParentId && state.nodes[newParentId]) { + const newParent = state.nodes[newParentId]; + newParent.hasChildren = true; + if (!newParent.children.includes(updatedNode.id)) { + newParent.children.push(updatedNode.id); + } + newParent.isExpanded = true; + newParent.children.sort((aId, bId) => { + const keyA = state.nodes[aId]?.orderKey ?? ''; + const keyB = state.nodes[bId]?.orderKey ?? ''; + return compareOrderKeys(keyA, keyB); + }); + } else if (!newParentId) { + if (!state.rootIds.includes(updatedNode.id)) { + state.rootIds.push(updatedNode.id); + } + state.rootIds.sort((aId, bId) => { + const keyA = state.nodes[aId]?.orderKey ?? ''; + const keyB = state.nodes[bId]?.orderKey ?? ''; + return compareOrderKeys(keyA, keyB); + }); + } + }); + }, +}); + +export const { toggleExpanded, setNodeExpanded, updateNodeMeta, addNode, removeNode, resetTree } = + sidebarTreeSlice.actions; + +export default sidebarTreeSlice.reducer; diff --git a/web/stores/store.ts b/web/stores/store.ts index b73d2df..8879fad 100644 --- a/web/stores/store.ts +++ b/web/stores/store.ts @@ -3,6 +3,8 @@ import documentReducer from './document/document.slice'; import authReducer from './auth/auth.slice'; import documentListReducer from './documentList/documentList.slice'; import sidebarReducer from './sidebar/sidebar.slice'; +import sidebarTreeReducer from './sidebarTree/sidebarTree.slice'; +import sharedTreeReducer from './sharedTree/sharedTree.slice'; import uiReducer from './ui/ui.slice'; import themeReducer from './theme/theme.slice'; import toastsReducer from './toasts/toasts.slice'; @@ -13,6 +15,8 @@ export const store = configureStore({ auth: authReducer, documentList: documentListReducer, sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, ui: uiReducer, theme: themeReducer, toasts: toastsReducer, diff --git a/web/styles/globals.css b/web/styles/globals.css index f9983a1..d680f05 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -35,6 +35,7 @@ --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); --nd-comments-rail-width: 20rem; + --nd-sidebar-width: 256px; /* Editor Dropdown / Suggestion Menu Custom Variables */ --editor-dropdown-radius: 8px; @@ -193,6 +194,34 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.3); } +/* Sidebar tree drag & drop indicators. + Reorder line and reparent highlight use the same blue as the block editor + (block selection outline / drop cursor) in both light and dark modes. */ +.nd-tree-drop-line { + background-color: rgb(100 160 255 / 0.5); +} + +.nd-tree-drop-highlight { + background-color: rgb(100 160 255 / 0.2) !important; +} + +/* Grabbing hand cursor while a sidebar tree drag is in progress */ +body.nd-is-dragging, +body.nd-is-dragging * { + cursor: grabbing !important; +} + +/* Block editor drop cursor: same blue as the sidebar tree indicators. + BlockNote sets the background color inline, so !important is required. */ +.prosemirror-dropcursor-block, +.prosemirror-dropcursor-block-horizontal, +.prosemirror-dropcursor-block-vertical-left, +.prosemirror-dropcursor-block-vertical-right, +.prosemirror-dropcursor-inline, +.prosemirror-dropcursor-vertical { + background-color: rgb(100 160 255 / 0.5) !important; +} + @layer components { .document-title-input { -webkit-font-smoothing: antialiased; @@ -1062,6 +1091,10 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { transition: right 0.24s cubic-bezier(0.16, 1, 0.3, 1); } +.nd-doc-toolbar-left { + transition: left 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + @media (min-width: 768px) { body[data-comments-sidebar-open='true'] .nd-app-shell-main { padding-left: max( diff --git a/web/tests/unit/components/DocToolbar.test.tsx b/web/tests/unit/components/DocToolbar.test.tsx new file mode 100644 index 0000000..b9eefb5 --- /dev/null +++ b/web/tests/unit/components/DocToolbar.test.tsx @@ -0,0 +1,531 @@ +import { render as baseRender, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { DocToolbar } from '../../../components/DocToolbar'; +import sidebarReducer from '../../../stores/sidebar/sidebar.slice'; +import sidebarTreeReducer from '../../../stores/sidebarTree/sidebarTree.slice'; +import sharedTreeReducer from '../../../stores/sharedTree/sharedTree.slice'; +import authReducer from '../../../stores/auth/auth.slice'; +import { documentService } from '../../../services/document.service'; +import { OFFLINE_DOCUMENT_SELECT_EVENT } from '../../../lib/offline-navigation.util'; + +const mockPush = jest.fn(); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: jest.fn(), + prefetch: jest.fn(), + }), +})); + +jest.mock('../../../components/SharePanel', () => ({ + SharePanel: () =>
    , +})); + +jest.mock('../../../services/document.service', () => ({ + documentService: { + getDocumentBreadcrumbs: jest.fn().mockResolvedValue([]), + }, +})); + +function createTestStore(preloadedState?: Record) { + return configureStore({ + reducer: { + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + auth: authReducer, + }, + preloadedState, + }); +} + +function renderWithStore(ui: React.ReactElement, preloadedState?: Record) { + const store = createTestStore(preloadedState); + return { + ...baseRender({ui}), + store, + }; +} + +describe('DocToolbar trash notice', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows the restore action for users who can manage the trashed document', () => { + const onRestore = jest.fn(); + + renderWithStore( + + ); + + expect(screen.getByText(/This document is in the trash\./)).toBeInTheDocument(); + const restoreButton = screen.getByRole('button', { name: 'Restore' }); + expect(restoreButton).toBeInTheDocument(); + + fireEvent.click(restoreButton); + expect(onRestore).toHaveBeenCalledTimes(1); + }); + + it('shows the read-only notice without a restore action for viewers and commenters', () => { + renderWithStore( + + ); + + expect( + screen.getByText(/read-only access and can view it, but only people with edit access/) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Restore' })).not.toBeInTheDocument(); + }); + + it('shows the trash notice without a restore action when canManageTrash is true but onRestore is undefined', () => { + renderWithStore( + + ); + + expect(screen.getByText(/This document is in the trash\./)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Restore' })).not.toBeInTheDocument(); + }); + + it('shows no trash notice when the document is not trashed', () => { + renderWithStore(); + + expect(screen.queryByText(/This document is in the/)).not.toBeInTheDocument(); + }); +}); + +describe('DocToolbar hierarchy breadcrumbs (Notion-style)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders a single root document title when there are no ancestors', () => { + renderWithStore( + + ); + + const nav = screen.getByRole('navigation', { name: 'Document hierarchy' }); + expect(nav).toBeInTheDocument(); + expect(screen.getByText('My Solo Doc')).toBeInTheDocument(); + }); + + it('renders multi-level hierarchy from local Redux tree nodes', () => { + const preloadedState = { + sidebarTree: { + nodes: { + 'root-doc': { + id: 'root-doc', + title: 'Engineering Wiki', + parentId: null, + orderKey: 'a0', + hasChildren: true, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: true, + isLoading: false, + children: ['parent-doc'], + childrenLoaded: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + 'parent-doc': { + id: 'parent-doc', + title: 'Frontend Architecture', + parentId: 'root-doc', + orderKey: 'a0', + hasChildren: true, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: true, + isLoading: false, + children: ['doc-child'], + childrenLoaded: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + 'doc-child': { + id: 'doc-child', + title: 'Toolbar Component', + parentId: 'parent-doc', + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + }, + rootIds: ['root-doc'], + isRootLoading: false, + rootHasMore: false, + rootPage: 0, + }, + }; + + renderWithStore( + , + preloadedState + ); + + expect(screen.getByText('Engineering Wiki')).toBeInTheDocument(); + expect(screen.getByText('Frontend Architecture')).toBeInTheDocument(); + expect(screen.getByText('Toolbar Component')).toBeInTheDocument(); + }); + + it('allows clicking an ancestor to navigate to that document', async () => { + const user = userEvent.setup(); + const onNavigateDocument = jest.fn(); + + const preloadedState = { + sidebarTree: { + nodes: { + 'root-doc': { + id: 'root-doc', + title: 'Parent Project', + parentId: null, + orderKey: 'a0', + hasChildren: true, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: true, + isLoading: false, + children: ['doc-child'], + childrenLoaded: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + 'doc-child': { + id: 'doc-child', + title: 'Current Feature', + parentId: 'root-doc', + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + }, + rootIds: ['root-doc'], + isRootLoading: false, + rootHasMore: false, + rootPage: 0, + }, + }; + + renderWithStore( + , + preloadedState + ); + + const ancestorButton = screen.getByRole('button', { name: /Parent Project/i }); + expect(ancestorButton).toBeInTheDocument(); + + await user.click(ancestorButton); + expect(onNavigateDocument).toHaveBeenCalledWith('root-doc'); + }); + + it('navigates with router.push when onNavigateDocument is not provided', async () => { + const user = userEvent.setup(); + + const preloadedState = { + sidebarTree: { + nodes: { + 'root-doc': { + id: 'root-doc', + title: 'Main Folder', + parentId: null, + orderKey: 'a0', + hasChildren: true, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: true, + isLoading: false, + children: ['doc-child'], + childrenLoaded: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + 'doc-child': { + id: 'doc-child', + title: 'Sub Page', + parentId: 'root-doc', + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + }, + rootIds: ['root-doc'], + isRootLoading: false, + rootHasMore: false, + rootPage: 0, + }, + }; + + renderWithStore( + , + preloadedState + ); + + const ancestorButton = screen.getByRole('button', { name: /Main Folder/i }); + await user.click(ancestorButton); + + expect(mockPush).toHaveBeenCalledWith('/doc/root-doc'); + }); + + it('collapses deep hierarchies (>3 levels) into an ellipsis menu', async () => { + const user = userEvent.setup(); + const onNavigateDocument = jest.fn(); + + (documentService.getDocumentBreadcrumbs as jest.Mock).mockResolvedValue([ + { id: 'doc-1', title: 'Workspace Root', parentId: null }, + { id: 'doc-2', title: 'Department', parentId: 'doc-1' }, + { id: 'doc-3', title: 'Project X', parentId: 'doc-2' }, + { id: 'doc-4', title: 'Specifications', parentId: 'doc-3' }, + { id: 'doc-5', title: 'Current Page', parentId: 'doc-4' }, + ]); + + renderWithStore( + + ); + + await waitFor(() => { + expect(screen.getByText('Workspace Root')).toBeInTheDocument(); + expect(screen.getByText('Specifications')).toBeInTheDocument(); + expect(screen.getByText('Current Page')).toBeInTheDocument(); + }); + + // The intermediate levels should be collapsed behind the ellipsis button + const ellipsisButton = screen.getByRole('button', { + name: 'Show intermediate parent documents', + }); + expect(ellipsisButton).toBeInTheDocument(); + + // Click ellipsis to open menu + await user.click(ellipsisButton); + + expect(screen.getByRole('menuitem', { name: /Department/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /Project X/i })).toBeInTheDocument(); + + // Click intermediate document in menu + await user.click(screen.getByRole('menuitem', { name: /Project X/i })); + expect(onNavigateDocument).toHaveBeenCalledWith('doc-3'); + }); + + it('updates the active document title reactively in breadcrumbs', () => { + const { rerender } = renderWithStore( + + ); + + expect(screen.getByText('Original Title')).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByText('Updated Live Title')).toBeInTheDocument(); + }); + + it('does not display private parent documents when collaborator only has access to child', async () => { + // Server returns only the accessible portion of the hierarchy + (documentService.getDocumentBreadcrumbs as jest.Mock).mockResolvedValue([ + { id: 'doc-shared-child', title: 'Shared Child Doc', parentId: null }, + ]); + + renderWithStore( + + ); + + await waitFor(() => { + expect(screen.getByText('Shared Child Doc')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Secret Org Root')).not.toBeInTheDocument(); + expect(screen.queryByText('Secret Parent Folder')).not.toBeInTheDocument(); + }); + + it('displays hierarchy starting from shared ancestor when collaborator has access from parent level', async () => { + // Collaborator has access from Project level down to Task + (documentService.getDocumentBreadcrumbs as jest.Mock).mockResolvedValue([ + { id: 'doc-shared-parent', title: 'Shared Project', parentId: null }, + { id: 'doc-child-task', title: 'Task Details', parentId: 'doc-shared-parent' }, + ]); + + renderWithStore( + + ); + + await waitFor(() => { + expect(screen.getByText('Shared Project')).toBeInTheDocument(); + expect(screen.getByText('Task Details')).toBeInTheDocument(); + }); + + // Unshared higher-level root must not be visible + expect(screen.queryByText('Secret Company Root')).not.toBeInTheDocument(); + }); + + it('dispatches OFFLINE_DOCUMENT_SELECT_EVENT when navigating in offline mode', async () => { + const user = userEvent.setup(); + const offlineEventSpy = jest.fn(); + window.addEventListener(OFFLINE_DOCUMENT_SELECT_EVENT, offlineEventSpy); + + (documentService.getDocumentBreadcrumbs as jest.Mock).mockResolvedValue([ + { id: 'doc-offline-parent', title: 'Parent Offline Doc', parentId: null }, + { id: 'doc-offline-child', title: 'Child Offline Doc', parentId: 'doc-offline-parent' }, + ]); + + renderWithStore( + + ); + + await waitFor(() => { + expect(screen.getByText('Parent Offline Doc')).toBeInTheDocument(); + }); + + const parentButton = screen.getByRole('button', { name: /Parent Offline Doc/i }); + await user.click(parentButton); + + expect(offlineEventSpy).toHaveBeenCalledTimes(1); + expect(offlineEventSpy.mock.calls[0][0].detail).toEqual({ id: 'doc-offline-parent' }); + expect(mockPush).not.toHaveBeenCalled(); + + window.removeEventListener(OFFLINE_DOCUMENT_SELECT_EVENT, offlineEventSpy); + }); + + it('falls back gracefully to single document breadcrumb when service rejects with an error', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + (documentService.getDocumentBreadcrumbs as jest.Mock).mockRejectedValue( + new Error('403 Forbidden') + ); + + renderWithStore( + + ); + + await waitFor(() => { + expect(screen.getByText('Isolated Document')).toBeInTheDocument(); + }); + + warnSpy.mockRestore(); + }); + + it('renders Open "title" tooltips and aria-labels on ancestor buttons and no tooltip on active page', async () => { + (documentService.getDocumentBreadcrumbs as jest.Mock).mockResolvedValue([ + { id: 'doc-parent', title: 'Parent Workspace', parentId: null }, + { id: 'doc-current', title: 'Active Page', parentId: 'doc-parent' }, + ]); + + renderWithStore( + + ); + + await waitFor(() => { + expect(screen.getByText('Parent Workspace')).toBeInTheDocument(); + expect(screen.getByText('Active Page')).toBeInTheDocument(); + }); + + const parentButton = screen.getByRole('button', { name: 'Open "Parent Workspace"' }); + expect(parentButton).toHaveAttribute('title', 'Open "Parent Workspace"'); + expect(parentButton).toHaveAttribute('aria-label', 'Open "Parent Workspace"'); + + const activeItem = screen.getByText('Active Page').closest('span[aria-current="page"]'); + expect(activeItem).toBeInTheDocument(); + expect(activeItem).not.toHaveAttribute('title'); + }); +}); diff --git a/web/tests/unit/components/Editor.test.tsx b/web/tests/unit/components/Editor.test.tsx index e1d95ea..0442cb9 100644 --- a/web/tests/unit/components/Editor.test.tsx +++ b/web/tests/unit/components/Editor.test.tsx @@ -41,6 +41,7 @@ jest.mock('@blocknote/core/comments', () => ({ jest.mock('../../../services/document.service', () => ({ documentService: { listCollaborators: jest.fn().mockResolvedValue([]), + getDocumentBreadcrumbs: jest.fn().mockResolvedValue([]), }, })); diff --git a/web/tests/unit/components/Sidebar.test.tsx b/web/tests/unit/components/Sidebar.test.tsx index 147a33a..1d7409d 100644 --- a/web/tests/unit/components/Sidebar.test.tsx +++ b/web/tests/unit/components/Sidebar.test.tsx @@ -3,6 +3,10 @@ import React from 'react'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import sidebarReducer from '../../../stores/sidebar/sidebar.slice'; +import sidebarTreeReducer from '../../../stores/sidebarTree/sidebarTree.slice'; +import sharedTreeReducer from '../../../stores/sharedTree/sharedTree.slice'; +import authReducer from '../../../stores/auth/auth.slice'; +import documentListReducer from '../../../stores/documentList/documentList.slice'; import uiReducer from '../../../stores/ui/ui.slice'; import userEvent from '@testing-library/user-event'; import Sidebar from '../../../components/sidebar'; @@ -15,13 +19,64 @@ import { OFFLINE_DOCUMENT_SELECT_EVENT } from '../../../lib/offline-navigation.u import { resolveRootDocumentId } from '../../../lib/root-document.util'; import * as Y from 'yjs'; +const mockTreeNodes = { + 'id-1': { + id: 'id-1', + title: 'Doc 1', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + 'id-2': { + id: 'id-2', + title: 'Untitled', + parentId: null, + orderKey: 'a1', + hasChildren: false, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, +}; + const render = ( ui: React.ReactElement, - store = configureStore({ reducer: { sidebar: sidebarReducer, ui: uiReducer } }), + store: unknown = configureStore({ + reducer: { + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + auth: authReducer, + ui: uiReducer, + documentList: documentListReducer, + }, + preloadedState: { + sidebarTree: { + nodes: mockTreeNodes, + rootIds: ['id-1', 'id-2'], + isRootLoading: false, + rootHasMore: false, + rootPage: 0, + }, + }, + }), options?: Parameters[1] ) => { return baseRender(ui, { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => ( + }>{children} + ), ...options, }); }; @@ -53,7 +108,6 @@ const mockShowTrashDocuments = jest.fn(); const mockLoadMoreTrashDocuments = jest.fn(); const mockRefreshTrash = jest.fn(); const mockLogout = jest.fn(); -const mockOnOpenAuth = jest.fn(); const mockDocs = [ { @@ -106,6 +160,24 @@ function setupDefault() { logout: mockLogout, }); (resolveRootDocumentId as jest.Mock).mockResolvedValue('resolved-root-id'); + if (documentService.getAllDocumentsMeta) { + (documentService.getAllDocumentsMeta as jest.Mock).mockResolvedValue( + mockDocs.map((d) => ({ id: d.id, meta: d.meta })) + ); + } + if (documentService.listRootTreeNodes) { + (documentService.listRootTreeNodes as jest.Mock).mockResolvedValue({ + items: [], + hasMore: false, + page: 0, + }); + } + if (documentService.listChildTreeNodes) { + (documentService.listChildTreeNodes as jest.Mock).mockResolvedValue({ + items: [], + hasMore: false, + }); + } } beforeEach(() => { @@ -114,14 +186,14 @@ beforeEach(() => { }); it('renders the document list', () => { - render(); + render(); expect(screen.getByRole('button', { name: /Doc 1/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Untitled/i })).toBeInTheDocument(); }); it('navigates to the selected document', async () => { const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /Untitled/i })); expect(mockPush).toHaveBeenCalledWith('/doc/id-2'); }); @@ -137,7 +209,7 @@ it('dispatches offline document select event instead of route navigation when br }); try { - render(); + render(); await user.click(screen.getByRole('button', { name: /Untitled/i })); expect(mockPush).not.toHaveBeenCalled(); @@ -157,7 +229,7 @@ it('dispatches offline document select event instead of route navigation when br }); it('updates sidebar active focus from offline document selection event without route change', () => { - render(); + render(); const docOneButton = screen.getByRole('button', { name: /Doc 1/i }); const docTwoButton = screen.getByRole('button', { name: /Untitled/i }); @@ -186,8 +258,9 @@ it('creates a new document and navigates to it', async () => { }); (documentService.saveDocument as jest.Mock).mockResolvedValue(undefined); - render(); - await user.click(screen.getByRole('button', { name: /New document/i })); + render(); + const newDocumentButtons = screen.getAllByRole('button', { name: /New document/i }); + await user.click(newDocumentButtons[0]); await waitFor(() => { expect(documentService.createDocument).toHaveBeenCalled(); @@ -198,7 +271,7 @@ it('creates a new document and navigates to it', async () => { it('collapses and expands the document list', async () => { const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /Private/i })); expect(screen.queryByRole('button', { name: /Doc 1/i })).not.toBeInTheDocument(); @@ -211,6 +284,8 @@ it('dispatches auth modal open action when "Log in" is selected from the account const store = configureStore({ reducer: { sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, ui: uiReducer, }, }); @@ -239,7 +314,7 @@ it('calls logout when "Log out" is selected from the account menu', async () => logout: mockLogout, }); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /Alice/i })); await user.click(screen.getByRole('menuitem', { name: /Log out/i })); expect(mockLogout).toHaveBeenCalledTimes(1); @@ -256,7 +331,7 @@ it('calls logout when "Log out" is selected from the account menu', async () => it('opens the settings modal when "Settings" is selected from the account menu', async () => { const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /Guest User/i })); await user.click(screen.getByRole('menuitem', { name: /Settings/i })); expect(screen.getByTestId('settings-modal')).toBeInTheDocument(); @@ -265,7 +340,7 @@ it('opens the settings modal when "Settings" is selected from the account menu', it('closes the account menu when Escape is pressed', async () => { const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /Guest User/i })); expect(screen.getByRole('menu')).toBeInTheDocument(); await user.keyboard('{Escape}'); @@ -295,8 +370,8 @@ it('calls showAllDocuments when "show all documents" is clicked', async () => { loadMoreTrashDocuments: mockLoadMoreTrashDocuments, }); - render(); - await user.click(screen.getByRole('button', { name: /show all/i })); + render(); + await user.click(screen.getByRole('button', { name: /Search Documents/i })); expect(mockShowAllDocuments).toHaveBeenCalledTimes(1); }); @@ -324,9 +399,9 @@ it('opens all documents panel with search and closes with back', async () => { loadMoreTrashDocuments: mockLoadMoreTrashDocuments, }); - render(); + render(); - await user.click(screen.getByRole('button', { name: /show all/i })); + await user.click(screen.getByRole('button', { name: /Search Documents/i })); const dialog = screen.getByRole('dialog', { name: /Private documents/i }); expect(dialog).toBeInTheDocument(); @@ -341,6 +416,7 @@ it('opens all documents panel with search and closes with back', async () => { it('renders skeleton rows instead of a loading badge while loading more in the documents panel', async () => { const user = userEvent.setup(); + (documentService.listRootTreeNodes as jest.Mock).mockReturnValueOnce(new Promise(() => {})); (useDocumentList as jest.Mock).mockReturnValue({ documents: mockDocs, sharedDocuments: [], @@ -367,9 +443,38 @@ it('renders skeleton rows instead of a loading badge while loading more in the d loadMoreTrashDocuments: mockLoadMoreTrashDocuments, }); - render(); + render( + , + configureStore({ + reducer: { + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + auth: authReducer, + ui: uiReducer, + }, + preloadedState: { + sidebarTree: { + nodes: mockTreeNodes, + rootIds: ['id-1', 'id-2'], + isRootLoading: true, + rootHasMore: false, + rootPage: 0, + }, + auth: { + user: null, + accessToken: 'test-token', + expiresAt: null, + lastAuthAction: null, + isLoading: false, + isInitializing: false, + error: null, + }, + }, + }) + ); - await user.click(screen.getByRole('button', { name: /show all/i })); + await user.click(screen.getByRole('button', { name: /Search Documents/i })); expect(screen.getByRole('dialog', { name: /Private documents/i })).toBeInTheDocument(); expect(screen.getByTestId('documents-panel-loading-more-skeleton')).toBeInTheDocument(); @@ -392,21 +497,19 @@ it('opens shared documents panel from shared section show all', async () => { (useDocumentList as jest.Mock).mockReturnValue({ documents: mockDocs, - sharedDocuments: [ - { - id: 'shared-collab-1', - relationship: 'collaborator', - meta: { - title: 'Collaborator Shared Doc', - updatedAt: '2024-01-01T11:00:00Z', - createdAt: '2024-01-01T10:00:00Z', - }, + sharedDocuments: Array.from({ length: 8 }, (_, i) => ({ + id: `shared-collab-${i + 1}`, + relationship: 'collaborator' as const, + meta: { + title: `Collaborator Shared Doc ${i + 1}`, + updatedAt: '2024-01-01T11:00:00Z', + createdAt: '2024-01-01T10:00:00Z', }, - ], + })), isLoading: false, isSharedLoading: false, isSharedLoadingMore: false, - sharedHasMore: true, + sharedHasMore: false, isShowingAllShared: false, isLoadingMore: false, hasMore: false, @@ -427,7 +530,7 @@ it('opens shared documents panel from shared section show all', async () => { }); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /show all/i })); @@ -461,11 +564,20 @@ it('lets collaborator leave shared document from shared panel row actions menu', createdAt: '2024-01-01T10:00:00Z', }, }, + ...Array.from({ length: 7 }, (_, i) => ({ + id: `shared-filler-${i + 1}`, + relationship: 'collaborator' as const, + meta: { + title: `Shared Filler Doc ${i + 1}`, + updatedAt: '2024-01-01T11:00:00Z', + createdAt: '2024-01-01T10:00:00Z', + }, + })), ], isLoading: false, isSharedLoading: false, isSharedLoadingMore: false, - sharedHasMore: true, + sharedHasMore: false, isShowingAllShared: false, isLoadingMore: false, hasMore: false, @@ -487,7 +599,7 @@ it('lets collaborator leave shared document from shared panel row actions menu', (documentService.leaveSharedDocument as jest.Mock).mockResolvedValue(undefined); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /show all/i })); @@ -538,7 +650,7 @@ it('shows trash option for authenticated user and opens trash panel', async () = }); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /Alice/i })); await user.click(screen.getByRole('menuitem', { name: /Trash Documents/i })); @@ -563,7 +675,7 @@ it('moves a document to trash from row actions menu', async () => { (documentService.moveCloudDocumentToTrash as jest.Mock).mockResolvedValue(undefined); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /Document actions for Doc 1/i })); await user.click(screen.getByRole('menuitem', { name: /Move to Trash/i })); @@ -617,9 +729,9 @@ it('moves a document to trash from show all documents panel row actions menu', a (documentService.moveCloudDocumentToTrash as jest.Mock).mockResolvedValue(undefined); const user = userEvent.setup(); - render(); + render(); - await user.click(screen.getByRole('button', { name: /show all/i })); + await user.click(screen.getByRole('button', { name: /Search Documents/i })); const dialog = screen.getByRole('dialog', { name: /Private documents/i }); await user.click(within(dialog).getByRole('button', { name: /Document actions for Doc 1/i })); @@ -632,6 +744,266 @@ it('moves a document to trash from show all documents panel row actions menu', a expect(mockRefreshTrash).toHaveBeenCalled(); }); +it('renders private panel with hasMore true when there are fewer than 7 private docs', async () => { + const sparseNodes = { + 'sparse-1': { + id: 'sparse-1', + title: 'Sparse Doc 1', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + 'sparse-2': { + id: 'sparse-2', + title: 'Sparse Doc 2', + parentId: null, + orderKey: 'a1', + hasChildren: false, + effectiveAccessLevel: 'OWNER' as const, + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }; + + const customStore = configureStore({ + reducer: { + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + auth: authReducer, + ui: uiReducer, + documentList: documentListReducer, + }, + preloadedState: { + sidebarTree: { + nodes: sparseNodes, + rootIds: ['sparse-1', 'sparse-2'], + isRootLoading: false, + rootHasMore: true, + rootPage: 0, + }, + }, + }); + + (useAuth as jest.Mock).mockReturnValue({ + user: { + displayName: 'Alice', + id: '1', + email: 'a@b.com', + avatarUrl: null, + emailVerified: false, + }, + isAuthenticated: true, + accessToken: 'token-1', + logout: mockLogout, + }); + (useDocumentList as jest.Mock).mockReturnValue({ + documents: [], + sharedDocuments: [], + isLoading: false, + isSharedLoading: false, + isLoadingMore: false, + hasMore: true, + isShowingAll: false, + trashedDocuments: [], + isTrashLoading: false, + isTrashLoadingMore: false, + showAllDocuments: mockShowAllDocuments, + showAllSharedDocuments: mockShowAllSharedDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreSharedDocuments: mockLoadMoreSharedDocuments, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + + (documentService.getAllDocumentsMeta as jest.Mock).mockResolvedValue([ + { + id: 'sparse-1', + meta: { + title: 'Sparse Doc 1', + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + }, + { + id: 'sparse-2', + meta: { + title: 'Sparse Doc 2', + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + ]); + + (documentService.listRootTreeNodes as jest.Mock).mockResolvedValue({ + items: [ + { + id: 'sparse-1', + title: 'Sparse Doc 1', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + { + id: 'sparse-2', + title: 'Sparse Doc 2', + parentId: null, + orderKey: 'a1', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + ], + hasMore: true, + page: 0, + }); + + const user = userEvent.setup(); + render(, customStore); + + await user.click(screen.getByRole('button', { name: /Search Documents/i })); + + const dialog = screen.getByRole('dialog', { name: /Private documents/i }); + expect(dialog).toBeInTheDocument(); + await waitFor(() => { + expect(within(dialog).getByText('Sparse Doc 1')).toBeInTheDocument(); + expect(within(dialog).getByText('Sparse Doc 2')).toBeInTheDocument(); + }); +}); + +it('moves a child document to trash in shared tree when user has edit access', async () => { + (useAuth as jest.Mock).mockReturnValue({ + user: { + displayName: 'Alice', + id: '1', + email: 'a@b.com', + avatarUrl: null, + emailVerified: false, + }, + isAuthenticated: true, + accessToken: 'token-1', + logout: mockLogout, + }); + const mockSharedDocs = [ + { + id: 'shared-root', + relationship: 'collaborator' as const, + parentId: null, + orderKey: 'a0', + meta: { + title: 'Shared Root', + updatedAt: '2024-01-01T10:00:00Z', + createdAt: '2024-01-01T10:00:00Z', + }, + }, + ]; + (useDocumentList as jest.Mock).mockReturnValue({ + documents: [], + sharedDocuments: mockSharedDocs, + isLoading: false, + isSharedLoading: false, + isSharedLoadingMore: false, + sharedHasMore: false, + isShowingAllShared: false, + isLoadingMore: false, + hasMore: false, + isShowingAll: false, + trashedDocuments: [], + isTrashLoading: false, + isTrashLoadingMore: false, + trashHasMore: false, + refresh: mockRefresh, + refreshTrash: mockRefreshTrash, + showAllDocuments: mockShowAllDocuments, + showAllSharedDocuments: mockShowAllSharedDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreSharedDocuments: mockLoadMoreSharedDocuments, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + (documentService.moveCloudDocumentToTrash as jest.Mock).mockResolvedValue(undefined); + + const customStore = configureStore({ + reducer: { + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + auth: authReducer, + ui: uiReducer, + }, + preloadedState: { + sidebar: { + isCollapsed: false, + isPrivateOpen: true, + isSharedOpen: true, + panelMode: null, + searchQuery: '', + docActionsAnchor: null, + }, + sharedTree: { + nodes: { + 'shared-root': { + id: 'shared-root', + title: 'Shared Root', + parentId: null, + orderKey: 'a0', + hasChildren: true, + effectiveAccessLevel: 'EDIT' as const, + isExpanded: true, + isLoading: false, + children: ['child-doc-1'], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + 'child-doc-1': { + id: 'child-doc-1', + title: 'Child Doc 1', + parentId: 'shared-root', + orderKey: 'b0', + hasChildren: false, + effectiveAccessLevel: 'EDIT' as const, + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + }, + rootIds: ['shared-root'], + }, + }, + }); + + const user = userEvent.setup(); + render(, customStore); + + await user.click(screen.getByRole('button', { name: /Document actions for Child Doc 1/i })); + await user.click(screen.getByRole('menuitem', { name: /Move to Trash/i })); + + await waitFor(() => { + expect(documentService.moveCloudDocumentToTrash).toHaveBeenCalledWith('child-doc-1', 'token-1'); + }); + expect(mockRefresh).toHaveBeenCalled(); + expect(mockRefreshTrash).toHaveBeenCalled(); +}); + it('restores a document from trash panel row actions', async () => { (useAuth as jest.Mock).mockReturnValue({ user: { @@ -668,7 +1040,7 @@ it('restores a document from trash panel row actions', async () => { (documentService.restoreCloudDocumentFromTrash as jest.Mock).mockResolvedValue(undefined); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /Alice/i })); await user.click(screen.getByRole('menuitem', { name: /Trash Documents/i })); @@ -717,7 +1089,7 @@ it('permanently deletes a document from trash panel after confirmation', async ( (documentService.deleteCloudDocumentPermanently as jest.Mock).mockResolvedValue(undefined); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /Alice/i })); await user.click(screen.getByRole('menuitem', { name: /Trash Documents/i })); @@ -742,6 +1114,70 @@ it('permanently deletes a document from trash panel after confirmation', async ( expect(mockRefreshTrash).toHaveBeenCalled(); }); +it('shows restore button for child document in trash when trashHasMore is true and parent is not in trash', async () => { + const trashedChildDoc = { + id: 'child-trash-id', + parentId: 'active-parent-id', + meta: { + title: 'Trashed Child Doc', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: '2024-01-02T00:00:00.000Z', + }, + }; + + (useAuth as jest.Mock).mockReturnValue({ + user: { + displayName: 'Alice', + id: '1', + email: 'a@b.com', + avatarUrl: null, + emailVerified: false, + }, + isAuthenticated: true, + accessToken: 'token-1', + logout: mockLogout, + }); + (useDocumentList as jest.Mock).mockReturnValue({ + documents: mockDocs, + sharedDocuments: [], + trashedDocuments: [trashedChildDoc], + isLoading: false, + isSharedLoading: false, + isLoadingMore: false, + hasMore: false, + isShowingAll: false, + isTrashLoading: false, + isTrashLoadingMore: false, + trashHasMore: true, + canShowAll: false, + refresh: mockRefresh, + refreshTrash: mockRefreshTrash, + showAllDocuments: mockShowAllDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + (documentService.restoreCloudDocumentFromTrash as jest.Mock).mockResolvedValue(undefined); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /Alice/i })); + await user.click(screen.getByRole('menuitem', { name: /Trash Documents/i })); + + const restoreBtn = screen.getByRole('button', { name: /Restore Trashed Child Doc/i }); + expect(restoreBtn).toBeInTheDocument(); + await user.click(restoreBtn); + + await waitFor(() => { + expect(documentService.restoreCloudDocumentFromTrash).toHaveBeenCalledWith( + 'child-trash-id', + 'token-1' + ); + }); +}); + it('moves an owner-shared document to trash from shared section row actions menu', async () => { (useAuth as jest.Mock).mockReturnValue({ user: { @@ -788,7 +1224,7 @@ it('moves an owner-shared document to trash from shared section row actions menu (documentService.moveCloudDocumentToTrash as jest.Mock).mockResolvedValue(undefined); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: /Document actions for Owner Shared Doc/i })); await user.click(screen.getByRole('menuitem', { name: /Move to Trash/i })); @@ -849,7 +1285,7 @@ it('lets collaborator leave shared document from shared section row actions menu (documentService.leaveSharedDocument as jest.Mock).mockResolvedValue(undefined); const user = userEvent.setup(); - render(); + render(); await user.click( screen.getByRole('button', { name: /Document actions for Collaborator Shared Doc/i }) @@ -909,7 +1345,7 @@ it('resolves a replacement document when leaving the active shared document', as (documentService.leaveSharedDocument as jest.Mock).mockResolvedValue(undefined); const user = userEvent.setup(); - render(); + render(); await user.click( screen.getByRole('button', { name: /Document actions for Collaborator Shared Doc/i }) @@ -927,3 +1363,590 @@ it('resolves a replacement document when leaving the active shared document', as expect(mockReplace).toHaveBeenCalledWith('/doc/resolved-root-id'); }); }); + +it('excludes shared documents from the private section', async () => { + (useAuth as jest.Mock).mockReturnValue({ + user: { + displayName: 'Alice', + id: '1', + email: 'a@b.com', + avatarUrl: null, + emailVerified: false, + }, + isAuthenticated: true, + accessToken: 'token-1', + logout: mockLogout, + }); + + (useDocumentList as jest.Mock).mockReturnValue({ + documents: mockDocs, + sharedDocuments: [ + { id: 'id-1', relationship: 'owner' as const, parentId: null, meta: mockDocs[0].meta }, + ], + trashedDocuments: [], + isLoading: false, + isSharedLoading: false, + isLoadingMore: false, + isSharedLoadingMore: false, + hasMore: false, + sharedHasMore: false, + isShowingAll: false, + isShowingAllShared: false, + isTrashLoading: false, + isTrashLoadingMore: false, + trashHasMore: false, + refresh: mockRefresh, + refreshTrash: mockRefreshTrash, + showAllDocuments: mockShowAllDocuments, + showAllSharedDocuments: mockShowAllSharedDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreSharedDocuments: mockLoadMoreSharedDocuments, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + + const documentListInitialState = documentListReducer(undefined, { type: '@@init' }); + const store = configureStore({ + reducer: { + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + ui: uiReducer, + documentList: documentListReducer, + }, + preloadedState: { + sidebarTree: { + nodes: mockTreeNodes, + rootIds: ['id-1', 'id-2'], + isRootLoading: false, + rootHasMore: false, + rootPage: 0, + }, + documentList: { + ...documentListInitialState, + ownerSharedDocuments: [ + { id: 'id-1', relationship: 'owner' as const, parentId: null, meta: mockDocs[0].meta }, + ], + }, + }, + }); + + render(, store); + + // "Doc 1" is shared by the owner, so it must only appear in the Shared section + expect(screen.getAllByRole('button', { name: 'Doc 1' })).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Untitled' })).toBeInTheDocument(); +}); + +it('excludes shared documents from the private documents panel', async () => { + const user = userEvent.setup(); + (useAuth as jest.Mock).mockReturnValue({ + user: { + displayName: 'Alice', + id: '1', + email: 'a@b.com', + avatarUrl: null, + emailVerified: false, + }, + isAuthenticated: true, + accessToken: 'token-1', + logout: mockLogout, + }); + + (useDocumentList as jest.Mock).mockReturnValue({ + documents: mockDocs, + sharedDocuments: [ + { id: 'id-1', relationship: 'owner' as const, parentId: null, meta: mockDocs[0].meta }, + ], + trashedDocuments: [], + isLoading: false, + isSharedLoading: false, + isLoadingMore: false, + isSharedLoadingMore: false, + hasMore: false, + sharedHasMore: false, + isShowingAll: false, + isShowingAllShared: false, + isTrashLoading: false, + isTrashLoadingMore: false, + trashHasMore: false, + refresh: mockRefresh, + refreshTrash: mockRefreshTrash, + showAllDocuments: mockShowAllDocuments, + showAllSharedDocuments: mockShowAllSharedDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreSharedDocuments: mockLoadMoreSharedDocuments, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + + const documentListInitialState = documentListReducer(undefined, { type: '@@init' }); + const store = configureStore({ + reducer: { + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + ui: uiReducer, + documentList: documentListReducer, + }, + preloadedState: { + sidebarTree: { + nodes: mockTreeNodes, + rootIds: ['id-1', 'id-2'], + isRootLoading: false, + rootHasMore: false, + rootPage: 0, + }, + documentList: { + ...documentListInitialState, + ownerSharedDocuments: [ + { id: 'id-1', relationship: 'owner' as const, parentId: null, meta: mockDocs[0].meta }, + ], + }, + }, + }); + + render(, store); + + await user.click(screen.getByRole('button', { name: /Search Documents/i })); + + const dialog = screen.getByRole('dialog', { name: /Private documents/i }); + // "Doc 1" is shared by the owner, so it must not appear in the private panel + expect(within(dialog).queryByRole('button', { name: 'Doc 1' })).not.toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Untitled' })).toBeInTheDocument(); +}); + +it('keeps nested owner-shared documents in the private section under their parent', async () => { + (useAuth as jest.Mock).mockReturnValue({ + user: { + displayName: 'Alice', + id: '1', + email: 'a@b.com', + avatarUrl: null, + emailVerified: false, + }, + isAuthenticated: true, + accessToken: 'token-1', + logout: mockLogout, + }); + + (useDocumentList as jest.Mock).mockReturnValue({ + documents: mockDocs, + sharedDocuments: [ + { + id: 'id-3', + relationship: 'owner' as const, + parentId: 'id-1', + meta: { + title: 'Nested Shared Doc', + updatedAt: '2024-01-01T11:00:00Z', + createdAt: '2024-01-01T10:00:00Z', + }, + }, + ], + trashedDocuments: [], + isLoading: false, + isSharedLoading: false, + isLoadingMore: false, + isSharedLoadingMore: false, + hasMore: false, + sharedHasMore: false, + isShowingAll: false, + isShowingAllShared: false, + isTrashLoading: false, + isTrashLoadingMore: false, + trashHasMore: false, + refresh: mockRefresh, + refreshTrash: mockRefreshTrash, + showAllDocuments: mockShowAllDocuments, + showAllSharedDocuments: mockShowAllSharedDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreSharedDocuments: mockLoadMoreSharedDocuments, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + + const nestedTreeNodes = { + ...mockTreeNodes, + 'id-1': { + ...mockTreeNodes['id-1'], + title: 'Parent Doc', + isExpanded: true, + hasChildren: true, + children: ['id-3'], + }, + 'id-3': { + ...mockTreeNodes['id-1'], + id: 'id-3', + title: 'Nested Shared Doc', + parentId: 'id-1', + orderKey: 'a0-1', + children: [], + hasChildren: false, + }, + }; + + const documentListInitialState = documentListReducer(undefined, { type: '@@init' }); + const store = configureStore({ + reducer: { + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + ui: uiReducer, + documentList: documentListReducer, + }, + preloadedState: { + sidebarTree: { + nodes: nestedTreeNodes, + rootIds: ['id-1', 'id-2'], + isRootLoading: false, + rootHasMore: false, + rootPage: 0, + }, + documentList: { + ...documentListInitialState, + ownerSharedDocuments: [ + { + id: 'id-3', + relationship: 'owner' as const, + parentId: 'id-1', + meta: { + title: 'Nested Shared Doc', + updatedAt: '2024-01-01T11:00:00Z', + createdAt: '2024-01-01T10:00:00Z', + }, + }, + ], + }, + }, + }); + + render(, store); + + // The nested shared document stays in the Private section under its parent + // and must NOT be synthesized in the Shared section. + expect(screen.getAllByRole('button', { name: 'Nested Shared Doc' })).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Parent Doc' })).toBeInTheDocument(); + expect(screen.getByText('No shared documents')).toBeInTheDocument(); +}); + +it('shows "Show More" in the private section only when more than 7 root documents exist', async () => { + const user = userEvent.setup(); + (useDocumentList as jest.Mock).mockReturnValue({ + documents: mockDocs, + sharedDocuments: [], + trashedDocuments: [], + isLoading: false, + isSharedLoading: false, + isLoadingMore: false, + isSharedLoadingMore: false, + hasMore: false, + sharedHasMore: false, + isShowingAll: false, + isShowingAllShared: false, + isTrashLoading: false, + isTrashLoadingMore: false, + trashHasMore: false, + refresh: mockRefresh, + refreshTrash: mockRefreshTrash, + showAllDocuments: mockShowAllDocuments, + showAllSharedDocuments: mockShowAllSharedDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreSharedDocuments: mockLoadMoreSharedDocuments, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + + const manyRootIds = Array.from({ length: 9 }, (_, i) => `root-${i + 1}`); + const manyNodes = Object.fromEntries( + manyRootIds.map((id, i) => [id, { ...mockTreeNodes['id-1'], id, title: `Doc ${i + 1}` }]) + ); + + const store = configureStore({ + reducer: { + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + ui: uiReducer, + }, + preloadedState: { + sidebarTree: { + nodes: manyNodes, + rootIds: manyRootIds, + isRootLoading: false, + rootHasMore: false, + rootPage: 0, + }, + }, + }); + + render(, store); + + const showMoreButton = await screen.findByRole('button', { name: /Show all documents/i }); + for (let i = 1; i <= 7; i += 1) { + expect(screen.getByRole('button', { name: `Doc ${i}` })).toBeInTheDocument(); + } + expect(screen.queryByRole('button', { name: 'Doc 8' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Doc 9' })).not.toBeInTheDocument(); + await user.click(showMoreButton); + expect(mockShowAllDocuments).toHaveBeenCalledTimes(1); +}); + +it('does not show "Show More" in the private section when at most 7 root documents exist', () => { + (useDocumentList as jest.Mock).mockReturnValue({ + documents: mockDocs, + sharedDocuments: [], + trashedDocuments: [], + isLoading: false, + isSharedLoading: false, + isLoadingMore: false, + isSharedLoadingMore: false, + hasMore: false, + sharedHasMore: false, + isShowingAll: false, + isShowingAllShared: false, + isTrashLoading: false, + isTrashLoadingMore: false, + trashHasMore: false, + refresh: mockRefresh, + refreshTrash: mockRefreshTrash, + showAllDocuments: mockShowAllDocuments, + showAllSharedDocuments: mockShowAllSharedDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreSharedDocuments: mockLoadMoreSharedDocuments, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + + render(); + + expect(screen.queryByRole('button', { name: /Show all documents/i })).not.toBeInTheDocument(); +}); + +it('does not show "Show More" in the shared section when at most 7 root documents exist and nothing more is available', () => { + (useAuth as jest.Mock).mockReturnValue({ + user: { + displayName: 'Alice', + id: '1', + email: 'a@b.com', + avatarUrl: null, + emailVerified: false, + }, + isAuthenticated: true, + accessToken: 'token-1', + logout: mockLogout, + }); + + (useDocumentList as jest.Mock).mockReturnValue({ + documents: mockDocs, + sharedDocuments: [ + { + id: 'shared-collab-1', + relationship: 'collaborator' as const, + meta: { + title: 'Collaborator Shared Doc', + updatedAt: '2024-01-01T11:00:00Z', + createdAt: '2024-01-01T10:00:00Z', + }, + }, + ], + trashedDocuments: [], + isLoading: false, + isSharedLoading: false, + isLoadingMore: false, + isSharedLoadingMore: false, + hasMore: false, + sharedHasMore: false, + isShowingAll: false, + isShowingAllShared: false, + isTrashLoading: false, + isTrashLoadingMore: false, + trashHasMore: false, + refresh: mockRefresh, + refreshTrash: mockRefreshTrash, + showAllDocuments: mockShowAllDocuments, + showAllSharedDocuments: mockShowAllSharedDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreSharedDocuments: mockLoadMoreSharedDocuments, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + + render(); + + expect( + screen.queryByRole('button', { name: /Show all shared documents/i }) + ).not.toBeInTheDocument(); +}); + +it('renders the "Add a document inside" button for a collaborator document with EDIT access', () => { + (useAuth as jest.Mock).mockReturnValue({ + user: { + displayName: 'Alice', + id: '1', + email: 'a@b.com', + avatarUrl: null, + emailVerified: false, + }, + isAuthenticated: true, + accessToken: 'token-1', + logout: mockLogout, + }); + (useDocumentList as jest.Mock).mockReturnValue({ + documents: [], + sharedDocuments: [ + { + id: 'collab-edit-doc', + relationship: 'collaborator' as const, + accessLevel: 'EDIT' as const, + parentId: null, + meta: { + title: 'Collab Edit Doc', + updatedAt: '2024-01-01T11:00:00Z', + createdAt: '2024-01-01T10:00:00Z', + }, + }, + ], + trashedDocuments: [], + isLoading: false, + isSharedLoading: false, + isLoadingMore: false, + isSharedLoadingMore: false, + hasMore: false, + sharedHasMore: false, + isShowingAll: false, + isShowingAllShared: false, + isTrashLoading: false, + isTrashLoadingMore: false, + trashHasMore: false, + refresh: mockRefresh, + refreshTrash: mockRefreshTrash, + showAllDocuments: mockShowAllDocuments, + showAllSharedDocuments: mockShowAllSharedDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreSharedDocuments: mockLoadMoreSharedDocuments, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + + const customStore = configureStore({ + reducer: { + auth: authReducer, + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + ui: uiReducer, + }, + preloadedState: { + sharedTree: { + nodes: { + 'collab-edit-doc': { + id: 'collab-edit-doc', + title: 'Collab Edit Doc', + parentId: null, + orderKey: 'shared:collab-edit-doc', + hasChildren: false, + effectiveAccessLevel: 'EDIT' as const, + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['collab-edit-doc'], + }, + }, + }); + + render(, customStore); + + expect(screen.getByRole('button', { name: /Add a document inside/i })).toBeInTheDocument(); +}); + +it('does not render the "Add a document inside" button for a collaborator document with VIEW access', () => { + (useAuth as jest.Mock).mockReturnValue({ + user: { + displayName: 'Alice', + id: '1', + email: 'a@b.com', + avatarUrl: null, + emailVerified: false, + }, + isAuthenticated: true, + accessToken: 'token-1', + logout: mockLogout, + }); + (useDocumentList as jest.Mock).mockReturnValue({ + documents: [], + sharedDocuments: [ + { + id: 'collab-view-doc', + relationship: 'collaborator' as const, + accessLevel: 'VIEW' as const, + parentId: null, + meta: { + title: 'Collab View Doc', + updatedAt: '2024-01-01T11:00:00Z', + createdAt: '2024-01-01T10:00:00Z', + }, + }, + ], + trashedDocuments: [], + isLoading: false, + isSharedLoading: false, + isLoadingMore: false, + isSharedLoadingMore: false, + hasMore: false, + sharedHasMore: false, + isShowingAll: false, + isShowingAllShared: false, + isTrashLoading: false, + isTrashLoadingMore: false, + trashHasMore: false, + refresh: mockRefresh, + refreshTrash: mockRefreshTrash, + showAllDocuments: mockShowAllDocuments, + showAllSharedDocuments: mockShowAllSharedDocuments, + showTrashDocuments: mockShowTrashDocuments, + loadMore: mockLoadMore, + loadMoreSharedDocuments: mockLoadMoreSharedDocuments, + loadMoreTrashDocuments: mockLoadMoreTrashDocuments, + }); + + const customStore = configureStore({ + reducer: { + auth: authReducer, + sidebar: sidebarReducer, + sidebarTree: sidebarTreeReducer, + sharedTree: sharedTreeReducer, + ui: uiReducer, + }, + preloadedState: { + sharedTree: { + nodes: { + 'collab-view-doc': { + id: 'collab-view-doc', + title: 'Collab View Doc', + parentId: null, + orderKey: 'shared:collab-view-doc', + hasChildren: false, + effectiveAccessLevel: 'VIEW' as const, + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['collab-view-doc'], + }, + }, + }); + + render(, customStore); + + expect(screen.queryByRole('button', { name: /Add a document inside/i })).not.toBeInTheDocument(); +}); diff --git a/web/tests/unit/hooks/useDocument.hook.test.tsx b/web/tests/unit/hooks/useDocument.hook.test.tsx index a47a788..cab555e 100644 --- a/web/tests/unit/hooks/useDocument.hook.test.tsx +++ b/web/tests/unit/hooks/useDocument.hook.test.tsx @@ -324,7 +324,9 @@ describe('useDocument', () => { expect(result.current.isLoading).toBe(false); }); - expect(getCloudDocumentSpy).toHaveBeenCalledWith('cloud-id', 'token-1'); + expect(getCloudDocumentSpy).toHaveBeenCalledWith('cloud-id', 'token-1', { + includeTrashed: true, + }); expect(saveDocumentSpy).toHaveBeenCalledWith('cloud-id', ydoc, meta, { touchUpdatedAt: false, }); @@ -668,7 +670,9 @@ describe('useDocument', () => { }); expect(getCloudDocumentSpy).toHaveBeenCalledTimes(1); - expect(getCloudDocumentSpy).toHaveBeenLastCalledWith('cloud-id', 'token-1'); + expect(getCloudDocumentSpy).toHaveBeenLastCalledWith('cloud-id', 'token-1', { + includeTrashed: true, + }); authState.accessToken = 'token-2'; rerender(); @@ -1057,6 +1061,58 @@ describe('useDocument', () => { expect(store.getState().document.meta?.deletedAt).toBeUndefined(); }); + it('should restore document as an EDIT collaborator and retain EDIT accessLevel', async () => { + const ydoc = new Y.Doc(); + const meta = { + title: 'Collaborator Trashed Doc', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: '2024-01-02T00:00:00.000Z', + }; + + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-restore-test', + }); + + getCloudDocumentSpy.mockResolvedValue({ ydoc, meta }); + restoreCloudDocumentFromTrashSpy.mockResolvedValue(undefined); + getMyAccessSpy.mockResolvedValue({ + documentId: 'trashed-collab-id', + allowed: true, + accessLevel: 'EDIT', + owner: false, + }); + + const store = createTestStore(); + function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + } + + const { result } = renderHook(() => useDocument('trashed-collab-id'), { wrapper: Wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isReadOnly).toBe(true); + expect(result.current.meta?.deletedAt).toBe('2024-01-02T00:00:00.000Z'); + + await act(async () => { + await result.current.restore(); + }); + + expect(restoreCloudDocumentFromTrashSpy).toHaveBeenCalledWith( + 'trashed-collab-id', + 'token-restore-test' + ); + + expect(result.current.isReadOnly).toBe(false); + expect(result.current.meta?.deletedAt).toBeUndefined(); + expect(result.current.accessLevel).toBe('EDIT'); + expect(store.getState().document.meta?.deletedAt).toBeUndefined(); + }); + it('should detect external restore (e.g. from sidebar), update local Redux state and cache access level to OWNER', async () => { const ydoc = new Y.Doc(); const trashedMeta = { @@ -1112,6 +1168,61 @@ describe('useDocument', () => { expect(store.getState().document.meta?.deletedAt).toBeUndefined(); }); + it('should detect external restore as an EDIT collaborator and retain EDIT accessLevel', async () => { + const ydoc = new Y.Doc(); + const trashedMeta = { + title: 'Trashed Document', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: '2024-01-02T00:00:00.000Z', + }; + + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-restore-test', + }); + + getCloudDocumentSpy.mockResolvedValue({ ydoc, meta: trashedMeta }); + getMyAccessSpy.mockResolvedValue({ + documentId: 'external-collab-id', + allowed: true, + accessLevel: 'EDIT', + owner: false, + }); + + const store = createTestStore(); + function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + } + + const { result } = renderHook(() => useDocument('external-collab-id'), { wrapper: Wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isReadOnly).toBe(true); + expect(result.current.meta?.deletedAt).toBe('2024-01-02T00:00:00.000Z'); + + const restoredMeta = { + ...trashedMeta, + deletedAt: undefined, + }; + loadDocumentSpy.mockResolvedValue({ ydoc, meta: restoredMeta }); + + await act(async () => { + window.dispatchEvent(new CustomEvent('local-documents-changed')); + }); + + await waitFor(() => { + expect(result.current.isReadOnly).toBe(false); + }); + + expect(result.current.meta?.deletedAt).toBeUndefined(); + expect(result.current.accessLevel).toBe('EDIT'); + expect(store.getState().document.meta?.deletedAt).toBeUndefined(); + }); + describe('realtime access revalidation and close handling', () => { const validUuid = '12345678-1234-1234-1234-123456789012'; @@ -1323,6 +1434,13 @@ describe('useDocument', () => { }); getCloudDocumentSpy.mockResolvedValue({ ydoc, meta: trashedMeta }); + getMyAccessSpy.mockResolvedValue({ + documentId: validUuid, + allowed: true, + accessLevel: 'OWNER', + owner: true, + trashed: true, + }); const { result } = renderHook(() => useDocument(validUuid), { wrapper: createWrapper() }); @@ -1330,12 +1448,14 @@ describe('useDocument', () => { expect(result.current.isLoading).toBe(false); }); - // The trashed document loads for the owner without an access check and stays open. + // The trashed document loads read-only for the owner with their real trash-scope + // access level (drives the manage-style trash banner). expect(result.current.meta?.deletedAt).toBe('2024-01-02T00:00:00.000Z'); + expect(result.current.accessLevel).toBe('OWNER'); expect(result.current.isReadOnly).toBe(true); expect(result.current.errorState).toBeNull(); expect(result.current.ydoc).toBe(ydoc); - expect(getMyAccessSpy).not.toHaveBeenCalled(); + expect(getMyAccessSpy).toHaveBeenCalled(); // No realtime provider should be created for a trashed document: the realtime // server strictly rejects access checks for trashed docs (1008 close), which would @@ -1363,14 +1483,14 @@ describe('useDocument', () => { isInitializing: false, }); - // getCloudDocument: initial load returns the active doc; the access-revocation - // re-check returns the trashed copy (owner can still view it via REST). + // getCloudDocument: initial load returns the active doc; the realtime close-handler + // re-check returns the trashed copy (the user can still view it via REST). getCloudDocumentSpy .mockResolvedValueOnce({ ydoc, meta: activeMeta }) .mockResolvedValueOnce({ ydoc, meta: trashedMeta }); - // getMyAccess: the loadDoc check and the immediate revalidation check are allowed; - // the realtime close-handler recheck reports access revoked for the trashed doc. + // getMyAccess: the loadDoc check sees an active document; the realtime close-handler + // recheck reports the document is now trashed with pre-trash access preserved. getMyAccessSpy .mockResolvedValueOnce({ documentId: validUuid, @@ -1383,12 +1503,7 @@ describe('useDocument', () => { allowed: true, accessLevel: 'EDIT', owner: false, - }) - .mockResolvedValueOnce({ - documentId: validUuid, - allowed: false, - accessLevel: null, - owner: false, + trashed: true, }); const { result } = renderHook(() => useDocument(validUuid), { wrapper: createWrapper() }); @@ -1416,15 +1531,179 @@ describe('useDocument', () => { registeredCloseHandler!({ code: 1008 }); }); - // The owner keeps seeing the document as a read-only trash view instead of a - // spurious "access restricted" error. + // The user keeps seeing the document as a read-only trash view instead of a + // spurious "access restricted" error, with their real pre-trash access level. await waitFor(() => { expect(result.current.errorState).toBeNull(); }); expect(result.current.meta?.deletedAt).toBe('2024-01-02T00:00:00.000Z'); - expect(result.current.accessLevel).toBe('VIEW'); + expect(result.current.accessLevel).toBe('EDIT'); expect(result.current.isReadOnly).toBe(true); expect(result.current.ydoc).toBe(ydoc); }); + + it('should fallback to VIEW accessLevel when getMyAccess transiently fails for a trashed doc', async () => { + const ydoc = new Y.Doc(); + const trashedMeta = { + title: 'Trashed Doc', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: '2024-01-02T00:00:00.000Z', + }; + + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-1', + }); + + getCloudDocumentSpy.mockResolvedValue({ ydoc, meta: trashedMeta }); + getMyAccessSpy.mockRejectedValueOnce(new TypeError('Network error')); + + const { result } = renderHook(() => useDocument('trashed-doc-id'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.accessLevel).toBe('VIEW'); + expect(result.current.isReadOnly).toBe(true); + }); + + it('should not enter restricted state if a document was restored between getMyAccess and getCloudDocument on close', async () => { + const ydoc = new Y.Doc(); + const activeMeta = { + title: 'Restored Document', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + const validUuid = '550e8400-e29b-41d4-a716-446655440000'; + + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-race-test', + }); + + // Initial load: returns active doc + // Close recheck: returns active restored doc (deletedAt is undefined) + getCloudDocumentSpy.mockResolvedValue({ ydoc, meta: activeMeta }); + + // getMyAccess in close handler saw trashed: true + getMyAccessSpy + .mockResolvedValueOnce({ + documentId: validUuid, + allowed: true, + accessLevel: 'EDIT', + owner: false, + }) + .mockResolvedValueOnce({ + documentId: validUuid, + allowed: true, + accessLevel: 'EDIT', + owner: false, + trashed: true, + }); + + const { result } = renderHook(() => useDocument(validUuid), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(registeredCloseHandler).not.toBeNull(); + + // Trigger 1008 close + act(() => { + registeredCloseHandler!({ code: 1008 }); + }); + + // Document was restored, so access should NOT be revoked (no restricted error) + await act(async () => { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + }); + + expect(result.current.errorState).toBeNull(); + expect(result.current.meta?.deletedAt).toBeUndefined(); + }); + + it('should initialize isReadOnly to true for shared documents before getMyAccess resolves', async () => { + const ydoc = new Y.Doc(); + const meta = { + title: 'Shared Document', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-shared', + }); + + let resolveAccess!: (val: unknown) => void; + const accessPromise = new Promise((res) => { + resolveAccess = res; + }); + getMyAccessSpy.mockReturnValue(accessPromise); + getCloudDocumentSpy.mockResolvedValue({ ydoc, meta }); + + const { result } = renderHook( + () => useDocument('shared-doc-id', { isSharedDocument: true }), + { wrapper: createWrapper() } + ); + + // Initially, isReadOnly must be true (safe default for shared docs) + expect(result.current.isReadOnly).toBe(true); + + // Now resolve access to EDIT + await act(async () => { + resolveAccess({ + documentId: 'shared-doc-id', + allowed: true, + accessLevel: 'EDIT', + owner: false, + }); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.accessLevel).toBe('EDIT'); + expect(result.current.isReadOnly).toBe(false); + }); + + it('should preserve cached accessLevel on trashed document when network error occurs', async () => { + const ydoc = new Y.Doc(); + const trashedMeta = { + title: 'Trashed Doc', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: '2024-01-02T00:00:00.000Z', + }; + + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-1', + }); + + writeCachedDocumentAccessLevel('cached-trash-id', 'EDIT'); + getCloudDocumentSpy.mockResolvedValue({ ydoc, meta: trashedMeta }); + getMyAccessSpy.mockRejectedValueOnce(new TypeError('Network error')); + + const { result } = renderHook(() => useDocument('cached-trash-id'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.accessLevel).toBe('EDIT'); + expect(result.current.isReadOnly).toBe(true); + }); }); }); diff --git a/web/tests/unit/hooks/useDocumentList.hook.test.tsx b/web/tests/unit/hooks/useDocumentList.hook.test.tsx index 69e121b..fe95e14 100644 --- a/web/tests/unit/hooks/useDocumentList.hook.test.tsx +++ b/web/tests/unit/hooks/useDocumentList.hook.test.tsx @@ -36,7 +36,15 @@ const renderHook = ( const preloadedState = { auth: { - user: authMock?.isAuthenticated ? { id: 'mock-user-id', email: 'mock@example.com' } : null, + user: authMock?.isAuthenticated + ? { + id: 'mock-user-id', + email: 'mock@example.com', + displayName: 'Mock User', + avatarUrl: null, + emailVerified: true, + } + : null, accessToken: authMock?.accessToken || null, expiresAt: authMock?.isAuthenticated ? Date.now() + 3600 * 1000 : null, lastAuthAction: null, @@ -136,12 +144,12 @@ describe('useDocumentList', () => { saveDocumentSpy.mockRestore(); }); - it('loads only first 7 local documents initially', async () => { - const docs = Array.from({ length: 10 }, (_, i) => ({ + it('loads only first 50 local documents initially', async () => { + const docs = Array.from({ length: 60 }, (_, i) => ({ id: `doc-${i + 1}`, meta: { title: `Doc ${i + 1}`, - updatedAt: `2024-01-${String(10 + i).padStart(2, '0')}T10:00:00Z`, + updatedAt: `2024-01-${String(10 + (i % 20)).padStart(2, '0')}T10:00:00Z`, createdAt: '2024-01-01T10:00:00Z', }, })); @@ -152,7 +160,7 @@ describe('useDocumentList', () => { await waitForInitialLoad(result, { includeShared: true }); - expect(result.current.documents).toHaveLength(7); + expect(result.current.documents).toHaveLength(50); expect(result.current.canShowAll).toBe(true); expect(result.current.isShowingAll).toBe(false); }); @@ -185,7 +193,7 @@ describe('useDocumentList', () => { expect(result.current.documents.length).toBeGreaterThan(7); }); - it('loads first cloud page with size 7 when authenticated', async () => { + it('loads first cloud page with size 50 when authenticated', async () => { (useAuth as jest.Mock).mockReturnValue({ isAuthenticated: true, accessToken: 'token-1', @@ -203,19 +211,19 @@ describe('useDocumentList', () => { }, ], page: 0, - size: 7, + size: 50, totalElements: 25, - totalPages: 4, - hasMore: true, + totalPages: 1, + hasMore: false, }); const { result } = renderHook(() => useDocumentList()); await waitForInitialLoad(result, { includeShared: true }); - expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 7); + expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 50); expect(result.current.documents[0].id).toBe('cloud-1'); - expect(result.current.canShowAll).toBe(true); + expect(result.current.canShowAll).toBe(false); }); it('falls back to local documents when cloud list is unreachable', async () => { @@ -348,7 +356,7 @@ describe('useDocumentList', () => { }); await waitFor(() => { - expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 20); + expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 50); }); await waitFor(() => { @@ -360,9 +368,7 @@ describe('useDocumentList', () => { await result.current.loadMore(); }); - expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 7); - expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 20); - expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 1, 20); + expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 1, 50); expect(result.current.documents.length).toBe(30); expect(result.current.hasMore).toBe(false); }); @@ -426,6 +432,7 @@ describe('useDocumentList', () => { items: [ { id: 'owner-private-doc', + parentId: null, meta: { title: 'Owner Private', updatedAt: '2024-01-01T12:00:00Z', @@ -434,6 +441,8 @@ describe('useDocumentList', () => { }, { id: 'owner-shared-doc', + parentId: null, + hasCollaborators: true, meta: { title: 'Owner Shared', updatedAt: '2024-01-01T13:00:00Z', @@ -487,6 +496,80 @@ describe('useDocumentList', () => { expect(result.current.sharedDocuments.map((doc) => doc.id)).toEqual(['owner-shared-doc']); }); + it('keeps nested owner documents with collaborators in the private list', async () => { + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-1', + }); + + listCloudDocumentsSpy.mockResolvedValue({ + items: [ + { + id: 'nested-shared-doc', + parentId: 'private-parent-doc', + hasCollaborators: true, + meta: { + title: 'Nested Shared', + updatedAt: '2024-01-01T12:00:00Z', + createdAt: '2024-01-01T10:00:00Z', + }, + }, + { + id: 'root-shared-doc', + parentId: null, + hasCollaborators: true, + meta: { + title: 'Root Shared', + updatedAt: '2024-01-01T13:00:00Z', + createdAt: '2024-01-01T10:00:00Z', + }, + }, + ], + page: 0, + size: 7, + totalElements: 2, + totalPages: 1, + hasMore: false, + }); + + listCollaboratorsSpy.mockImplementation(async (documentId: string) => { + if (documentId === 'nested-shared-doc' || documentId === 'root-shared-doc') { + return [ + { + userId: 'owner-1', + email: 'owner@example.com', + displayName: 'Owner', + accessLevel: 'OWNER', + addedAt: '2024-01-01T10:00:00Z', + }, + { + userId: 'collab-1', + email: 'collab@example.com', + displayName: 'Collaborator', + accessLevel: 'EDIT', + addedAt: '2024-01-01T11:00:00Z', + }, + ]; + } + + return []; + }); + + const { result } = renderHook(() => useDocumentList()); + + await waitForInitialLoad(result, { includeShared: true }); + + // The nested document stays in the private list under its real parent; + // only the root-level shared document moves to the shared section. + expect(result.current.documents.map((doc) => doc.id)).toEqual(['nested-shared-doc']); + expect(result.current.sharedDocuments.map((doc) => doc.id)).toEqual(['root-shared-doc']); + expect(result.current.sharedDocuments[0]).toMatchObject({ + id: 'root-shared-doc', + relationship: 'owner', + parentId: null, + }); + }); + it('shows all shared documents and paginates shared-with-me list', async () => { (useAuth as jest.Mock).mockReturnValue({ isAuthenticated: true, @@ -558,7 +641,7 @@ describe('useDocumentList', () => { }); await waitFor(() => { - expect(listSharedDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 20); + expect(listSharedDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 50); expect(result.current.isShowingAllShared).toBe(true); }); @@ -571,9 +654,7 @@ describe('useDocumentList', () => { await result.current.loadMoreSharedDocuments(); }); - expect(listSharedDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 7); - expect(listSharedDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 20); - expect(listSharedDocumentsSpy).toHaveBeenCalledWith('token-1', 1, 20); + expect(listSharedDocumentsSpy).toHaveBeenCalledWith('token-1', 1, 50); expect(result.current.sharedDocuments.length).toBe(30); expect(result.current.sharedHasMore).toBe(false); }); @@ -662,7 +743,7 @@ describe('useDocumentList', () => { expect(result.current.sharedHasMore).toBe(false); }); - it('reconciles owner-shared split before exposing hasMore', async () => { + it('exposes owner-shared docs from the initial page and reports truthful hasMore', async () => { (useAuth as jest.Mock).mockReturnValue({ isAuthenticated: true, accessToken: 'token-1', @@ -679,18 +760,16 @@ describe('useDocumentList', () => { })), { id: 'owner-shared-1', + hasCollaborators: true, meta: { title: 'Owner Shared 1', updatedAt: '2024-01-01T13:00:00Z', createdAt: '2024-01-01T10:00:00Z', }, }, - ]; - - const expandedSeedPage = [ - ...firstPage, { id: 'owner-shared-2', + hasCollaborators: true, meta: { title: 'Owner Shared 2', updatedAt: '2024-01-01T12:30:00Z', @@ -699,23 +778,14 @@ describe('useDocumentList', () => { }, ]; - listCloudDocumentsSpy - .mockResolvedValueOnce({ - items: firstPage, - page: 0, - size: 7, - totalElements: 8, - totalPages: 2, - hasMore: true, - }) - .mockResolvedValueOnce({ - items: expandedSeedPage, - page: 0, - size: 20, - totalElements: 8, - totalPages: 1, - hasMore: false, - }); + listCloudDocumentsSpy.mockResolvedValueOnce({ + items: firstPage, + page: 0, + size: 50, + totalElements: 30, + totalPages: 1, + hasMore: true, + }); listSharedDocumentsSpy.mockResolvedValue({ items: [], @@ -755,16 +825,15 @@ describe('useDocumentList', () => { await waitForInitialLoad(result, { includeShared: true }); - expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 7); - expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 20); + expect(listCloudDocumentsSpy).toHaveBeenCalledWith('token-1', 0, 50); + expect(listCloudDocumentsSpy).toHaveBeenCalledTimes(1); expect(result.current.documents).toHaveLength(6); expect(result.current.sharedDocuments.map((doc) => doc.id)).toEqual([ 'owner-shared-1', 'owner-shared-2', ]); - expect(result.current.hasMore).toBe(false); - expect(result.current.sharedHasMore).toBe(false); - expect(result.current.canShowAll).toBe(false); + expect(result.current.hasMore).toBe(true); + expect(result.current.canShowAll).toBe(true); }); it('reports sharedHasMore when owner-shared pagination still has more', async () => { @@ -777,6 +846,7 @@ describe('useDocumentList', () => { items: [ { id: 'owner-shared-doc', + hasCollaborators: true, meta: { title: 'Owner Shared', updatedAt: '2024-01-01T13:00:00Z', @@ -1042,6 +1112,7 @@ describe('useDocumentList', () => { items: [ { id: 'owner-shared-1', + hasCollaborators: true, meta: { title: 'Owner Shared 1', updatedAt: '2024-01-02T13:00:00Z', @@ -1050,6 +1121,7 @@ describe('useDocumentList', () => { }, { id: 'owner-shared-2', + hasCollaborators: true, meta: { title: 'Owner Shared 2', updatedAt: '2024-01-02T12:00:00Z', @@ -1091,11 +1163,11 @@ describe('useDocumentList', () => { ]); getAllDocumentsMetaSpy.mockResolvedValue( - Array.from({ length: 10 }, (_, index) => ({ + Array.from({ length: 60 }, (_, index) => ({ id: `local-private-${index + 1}`, meta: { title: `Local Private ${index + 1}`, - updatedAt: `2024-01-${String(10 + index).padStart(2, '0')}T10:00:00Z`, + updatedAt: `2024-01-${String(10 + (index % 20)).padStart(2, '0')}T10:00:00Z`, createdAt: '2024-01-01T10:00:00Z', }, })) @@ -1120,7 +1192,7 @@ describe('useDocumentList', () => { }); await waitFor(() => { - expect(result.current.documents).toHaveLength(7); + expect(result.current.documents).toHaveLength(50); expect(result.current.hasMore).toBe(true); expect(result.current.sharedDocuments.map((doc) => doc.id)).toEqual([ 'owner-shared-1', @@ -1230,6 +1302,7 @@ describe('useDocumentList', () => { }, { id: 'owner-shared-1', + hasCollaborators: true, meta: { title: 'Owner Shared 1', updatedAt: '2024-01-01T12:00:00Z', diff --git a/web/tests/unit/lib/sidebar-drop-rules.test.ts b/web/tests/unit/lib/sidebar-drop-rules.test.ts new file mode 100644 index 0000000..0422a8e --- /dev/null +++ b/web/tests/unit/lib/sidebar-drop-rules.test.ts @@ -0,0 +1,110 @@ +import { isSidebarDropAllowed, resolveSidebarMoveRoute } from '../../../lib/sidebar-drop-rules'; + +const privateToShared = { + draggedId: 'doc-1', + draggedIsShared: false, + targetIsShared: true, + draggedParentId: null, + targetParentId: null, +}; + +const sharedDrag = (overrides?: Partial[1]>) => ({ + draggedId: 'shared-1', + draggedIsShared: true, + targetIsShared: true, + draggedParentId: null as string | null, + targetParentId: null as string | null, + ...overrides, +}); + +describe('isSidebarDropAllowed', () => { + it('offers only nesting drops for private -> shared', () => { + expect(isSidebarDropAllowed('mid', privateToShared)).toBe(true); + expect(isSidebarDropAllowed('empty', privateToShared)).toBe(true); + expect(isSidebarDropAllowed('top', privateToShared)).toBe(false); + expect(isSidebarDropAllowed('bottom', privateToShared)).toBe(false); + }); + + it('blocks reparenting inside Shared but allows same-parent sibling reorder', () => { + // Root-level shared docs reorder among themselves. + expect(isSidebarDropAllowed('top', sharedDrag())).toBe(true); + expect(isSidebarDropAllowed('bottom', sharedDrag())).toBe(true); + + // Nesting under another shared doc is blocked until FULL_ACCESS exists. + expect(isSidebarDropAllowed('mid', sharedDrag())).toBe(false); + expect(isSidebarDropAllowed('empty', sharedDrag())).toBe(false); + + // Reordering inside a different parent is a reparent - blocked. + expect( + isSidebarDropAllowed( + 'top', + sharedDrag({ draggedParentId: 'parent-a', targetParentId: 'parent-b' }) + ) + ).toBe(false); + expect( + isSidebarDropAllowed( + 'bottom', + sharedDrag({ draggedParentId: 'parent-a', targetParentId: 'parent-a' }) + ) + ).toBe(true); + }); + + it('keeps legacy behavior for private intra-tree moves', () => { + const ctx = { + draggedId: 'doc-1', + draggedIsShared: false, + targetIsShared: false, + draggedParentId: null, + targetParentId: null, + }; + expect(isSidebarDropAllowed('top', ctx)).toBe(true); + expect(isSidebarDropAllowed('mid', ctx)).toBe(true); + expect(isSidebarDropAllowed('empty', ctx)).toBe(true); + }); + + it('blocks shared -> private entirely', () => { + const ctx = { + draggedId: 'shared-1', + draggedIsShared: true, + targetIsShared: false, + draggedParentId: null, + targetParentId: null, + }; + expect(isSidebarDropAllowed('mid', ctx)).toBe(false); + expect(isSidebarDropAllowed('top', ctx)).toBe(false); + }); +}); + +describe('resolveSidebarMoveRoute', () => { + it('routes private -> shared nesting to adopt with the host parent', () => { + const route = resolveSidebarMoveRoute( + { documentId: 'doc-1', newParentId: 'shared-9' }, + { draggedIsShared: false, targetParentIdIsShared: true } + ); + expect(route).toEqual({ kind: 'shared-nest-adopt', hostParentId: 'shared-9' }); + }); + + it('routes plain private moves to the private tree', () => { + const route = resolveSidebarMoveRoute( + { documentId: 'doc-1', newParentId: null }, + { draggedIsShared: false, targetParentIdIsShared: false } + ); + expect(route).toEqual({ kind: 'private' }); + }); + + it('routes shared sibling reorders to the shared store', () => { + const route = resolveSidebarMoveRoute( + { documentId: 'shared-1', newParentId: null }, + { draggedIsShared: true, targetParentIdIsShared: false } + ); + expect(route).toEqual({ kind: 'shared-reorder' }); + }); + + it('blocks shared moves that would change parentage', () => { + const route = resolveSidebarMoveRoute( + { documentId: 'shared-1', newParentId: 'some-other-parent' }, + { draggedIsShared: true, targetParentIdIsShared: false } + ); + expect(route).toEqual({ kind: 'blocked' }); + }); +}); diff --git a/web/tests/unit/services/document.service.test.ts b/web/tests/unit/services/document.service.test.ts index 292ebb6..c917b27 100644 --- a/web/tests/unit/services/document.service.test.ts +++ b/web/tests/unit/services/document.service.test.ts @@ -396,4 +396,160 @@ describe('document.service', () => { window.removeEventListener('local-documents-changed', listener); }); }); + + describe('getMyAccess', () => { + it('should fetch and return document access including trashed status', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + data: { + documentId: 'doc-123', + allowed: true, + accessLevel: 'EDIT', + owner: false, + trashed: true, + }, + error: null, + }), + } as Response); + (globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as typeof fetch; + + const access = await documentService.getMyAccess('doc-123', 'access-token'); + + expect(access).toEqual({ + documentId: 'doc-123', + allowed: true, + accessLevel: 'EDIT', + owner: false, + trashed: true, + }); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/documents/doc-123/my-access'), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + }), + }) + ); + }); + }); + + describe('getCloudDocument', () => { + it('should not append includeTrashed query param by default', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + data: { + id: 'doc-123', + title: 'Doc', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + error: null, + }), + } as Response); + (globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as typeof fetch; + + await documentService.getCloudDocument('doc-123', 'access-token'); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/documents/doc-123', + expect.anything() + ); + }); + + it('should append includeTrashed=true when explicitly requested', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + data: { + id: 'doc-123', + title: 'Doc', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + error: null, + }), + } as Response); + (globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as typeof fetch; + + await documentService.getCloudDocument('doc-123', 'access-token', { + includeTrashed: true, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/documents/doc-123?includeTrashed=true', + expect.anything() + ); + }); + + it('should support legacy boolean param for includeTrashed', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + data: { + id: 'doc-123', + title: 'Doc', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + error: null, + }), + } as Response); + (globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as typeof fetch; + + await documentService.getCloudDocument( + 'doc-123', + 'access-token', + true as unknown as { includeTrashed: boolean } + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/documents/doc-123?includeTrashed=true', + expect.anything() + ); + }); + }); + + describe('fetchApi error message handling', () => { + it('should prioritize message over error when both are provided', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + success: false, + data: null, + error: 'Validation failed', + message: 'parentId cannot be combined with trashed=true', + }), + } as Response); + (globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as typeof fetch; + + await expect(documentService.getCloudDocument('doc-123', 'access-token')).rejects.toThrow( + 'parentId cannot be combined with trashed=true' + ); + }); + + it('should fall back to error when message is omitted', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + success: false, + data: null, + error: 'Validation failed', + }), + } as Response); + (globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as typeof fetch; + + await expect(documentService.getCloudDocument('doc-123', 'access-token')).rejects.toThrow( + 'Validation failed' + ); + }); + }); }); diff --git a/web/tests/unit/stores/sharedTree/sharedTree.slice.test.ts b/web/tests/unit/stores/sharedTree/sharedTree.slice.test.ts new file mode 100644 index 0000000..bd18e04 --- /dev/null +++ b/web/tests/unit/stores/sharedTree/sharedTree.slice.test.ts @@ -0,0 +1,678 @@ +import type { DocumentAccessLevel } from '@/services/document.service'; +import sharedTreeReducer, { + syncSharedRoots, + type SharedTreeState, +} from '@/stores/sharedTree/sharedTree.slice'; +import type { SharedDocumentEntry } from '@/stores/documentList/documentList.types'; + +const entry = ( + id: string, + relationship: 'owner' | 'collaborator', + parentId: string | null, + title = id, + accessLevel?: DocumentAccessLevel | null +): SharedDocumentEntry => ({ + id, + relationship, + parentId, + accessLevel, + meta: { title, updatedAt: '2024-01-01T11:00:00Z', createdAt: '2024-01-01T10:00:00Z' }, +}); + +describe('sharedTree.slice syncSharedRoots', () => { + const initialState: SharedTreeState = { nodes: {}, rootIds: [] }; + + it('sets effectiveAccessLevel to EDIT for collaborator with edit access', () => { + const state = sharedTreeReducer( + initialState, + syncSharedRoots([entry('shared-edit', 'collaborator', null, 'Shared Edit', 'EDIT')]) + ); + + expect(state.rootIds).toEqual(['shared-edit']); + expect(state.nodes['shared-edit']).toMatchObject({ + id: 'shared-edit', + parentId: null, + effectiveAccessLevel: 'EDIT', + }); + }); + + it('sets effectiveAccessLevel to VIEW for collaborator with view access', () => { + const state = sharedTreeReducer( + initialState, + syncSharedRoots([entry('shared-view', 'collaborator', null, 'Shared View', 'VIEW')]) + ); + + expect(state.rootIds).toEqual(['shared-view']); + expect(state.nodes['shared-view']).toMatchObject({ + id: 'shared-view', + parentId: null, + effectiveAccessLevel: 'VIEW', + }); + }); + + it('synthesizes root-level owner-shared documents as roots', () => { + const state = sharedTreeReducer( + initialState, + syncSharedRoots([entry('root-shared', 'owner', null)]) + ); + + expect(state.rootIds).toEqual(['root-shared']); + expect(state.nodes['root-shared']).toMatchObject({ + id: 'root-shared', + parentId: null, + effectiveAccessLevel: 'OWNER', + }); + }); + + it('does NOT synthesize nested owner-shared documents (they stay in the private tree)', () => { + const state = sharedTreeReducer( + initialState, + syncSharedRoots([entry('nested-shared', 'owner', 'private-parent')]) + ); + + expect(state.rootIds).toEqual([]); + expect(state.nodes['nested-shared']).toBeUndefined(); + }); + + it('prunes a nested owner-shared document that was previously synthesized as a root', () => { + const previous: SharedTreeState = { + nodes: { + 'nested-shared': { + id: 'nested-shared', + title: 'nested-shared', + parentId: null, + orderKey: 'shared:nested-shared', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['nested-shared'], + }; + + const state = sharedTreeReducer( + previous, + syncSharedRoots([entry('nested-shared', 'owner', 'private-parent')]) + ); + + expect(state.rootIds).toEqual([]); + expect(state.nodes['nested-shared']).toBeUndefined(); + }); + + it('nests a shared-with-me document under its parent when the parent is also shared with me', () => { + const state = sharedTreeReducer( + initialState, + syncSharedRoots([ + entry('shared-parent', 'collaborator', null), + entry('shared-child', 'collaborator', 'shared-parent'), + ]) + ); + + expect(state.rootIds).toEqual(['shared-parent']); + expect(state.nodes['shared-parent']).toMatchObject({ parentId: null }); + expect(state.nodes['shared-child']).toMatchObject({ parentId: 'shared-parent' }); + }); + + it('floats a shared-with-me document at the root when its parent is not shared with me', () => { + const state = sharedTreeReducer( + initialState, + syncSharedRoots([entry('orphan-shared', 'collaborator', 'inaccessible-parent')]) + ); + + expect(state.rootIds).toEqual(['orphan-shared']); + expect(state.nodes['orphan-shared']).toMatchObject({ parentId: null }); + }); + + it('prunes documents that are no longer shared', () => { + const previous: SharedTreeState = { + nodes: { + 'unshared-1': { + id: 'unshared-1', + title: 'unshared-1', + parentId: null, + orderKey: 'shared:unshared-1', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['unshared-1'], + }; + + const state = sharedTreeReducer(previous, syncSharedRoots([])); + + expect(state.rootIds).toEqual([]); + expect(state.nodes).toEqual({}); + }); + + it('orders roots by the server navigation order key', () => { + const withKey = ( + id: string, + relationship: 'owner' | 'collaborator', + parentId: string | null, + orderKey: string + ): SharedDocumentEntry => ({ + id, + relationship, + parentId, + orderKey, + meta: { title: id, updatedAt: '2024-01-01T11:00:00Z', createdAt: '2024-01-01T10:00:00Z' }, + }); + + const state = sharedTreeReducer( + initialState, + syncSharedRoots([ + withKey('z-last', 'collaborator', null, 'a2'), + withKey('a-first', 'owner', null, 'a0'), + withKey('m-mid', 'collaborator', null, 'a1'), + ]) + ); + + expect(state.rootIds).toEqual(['a-first', 'm-mid', 'z-last']); + expect(state.nodes['a-first']).toMatchObject({ orderKey: 'a0' }); + expect(state.nodes['m-mid']).toMatchObject({ orderKey: 'a1' }); + expect(state.nodes['z-last']).toMatchObject({ orderKey: 'a2' }); + }); + + it('updates order key and resort roots when server navigation order changes', () => { + const withKey = ( + id: string, + relationship: 'owner' | 'collaborator', + parentId: string | null, + orderKey: string + ): SharedDocumentEntry => ({ + id, + relationship, + parentId, + orderKey, + meta: { title: id, updatedAt: '2024-01-01T11:00:00Z', createdAt: '2024-01-01T10:00:00Z' }, + }); + + const previous: SharedTreeState = { + nodes: { + 'doc-a': { + id: 'doc-a', + title: 'Doc A', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'doc-b': { + id: 'doc-b', + title: 'Doc B', + parentId: null, + orderKey: 'a1', + hasChildren: false, + effectiveAccessLevel: 'VIEW', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['doc-a', 'doc-b'], + }; + + // Server sends doc-b with orderKey 'Zz' (now before doc-a) + const state = sharedTreeReducer( + previous, + syncSharedRoots([ + withKey('doc-a', 'owner', null, 'a0'), + withKey('doc-b', 'collaborator', null, 'Zz'), + ]) + ); + + expect(state.rootIds).toEqual(['doc-b', 'doc-a']); + expect(state.nodes['doc-b'].orderKey).toBe('Zz'); + expect(state.nodes['doc-a'].orderKey).toBe('a0'); + }); +}); + +describe('sharedTree.slice resetTree', () => { + it('resets nodes and rootIds', () => { + const previous: SharedTreeState = { + nodes: { + 'doc-1': { + id: 'doc-1', + title: 'Doc 1', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['doc-1'], + }; + + const state = sharedTreeReducer(previous, { type: 'sharedTree/resetTree' }); + + expect(state.nodes).toEqual({}); + expect(state.rootIds).toEqual([]); + }); +}); + +describe('sharedTree.slice removeNode', () => { + it('removes a root node from rootIds and nodes', () => { + const initialState: SharedTreeState = { + nodes: { + 'root-1': { + id: 'root-1', + title: 'Root 1', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['root-1'], + }; + + const state = sharedTreeReducer(initialState, { + type: 'sharedTree/removeNode', + payload: 'root-1', + }); + + expect(state.rootIds).toEqual([]); + expect(state.nodes['root-1']).toBeUndefined(); + }); + + it('removes a child node from its parent children list and nodes', () => { + const initialState: SharedTreeState = { + nodes: { + 'parent-1': { + id: 'parent-1', + title: 'Parent 1', + parentId: null, + orderKey: 'a0', + hasChildren: true, + effectiveAccessLevel: 'OWNER', + isExpanded: true, + isLoading: false, + children: ['child-1'], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'child-1': { + id: 'child-1', + title: 'Child 1', + parentId: 'parent-1', + orderKey: 'b0', + hasChildren: false, + effectiveAccessLevel: 'EDIT', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['parent-1'], + }; + + const state = sharedTreeReducer(initialState, { + type: 'sharedTree/removeNode', + payload: 'child-1', + }); + + expect(state.nodes['child-1']).toBeUndefined(); + expect(state.nodes['parent-1'].children).toEqual([]); + expect(state.nodes['parent-1'].hasChildren).toBe(false); + }); + + it('recursively deletes all descendants when a parent node is removed', () => { + const initialState: SharedTreeState = { + nodes: { + 'root-1': { + id: 'root-1', + title: 'Root 1', + parentId: null, + orderKey: 'a0', + hasChildren: true, + effectiveAccessLevel: 'OWNER', + isExpanded: true, + isLoading: false, + children: ['child-1'], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'child-1': { + id: 'child-1', + title: 'Child 1', + parentId: 'root-1', + orderKey: 'b0', + hasChildren: true, + effectiveAccessLevel: 'EDIT', + isExpanded: true, + isLoading: false, + children: ['grandchild-1'], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'grandchild-1': { + id: 'grandchild-1', + title: 'Grandchild 1', + parentId: 'child-1', + orderKey: 'c0', + hasChildren: false, + effectiveAccessLevel: 'EDIT', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['root-1'], + }; + + const state = sharedTreeReducer(initialState, { + type: 'sharedTree/removeNode', + payload: 'root-1', + }); + + expect(state.rootIds).toEqual([]); + expect(state.nodes['root-1']).toBeUndefined(); + expect(state.nodes['child-1']).toBeUndefined(); + expect(state.nodes['grandchild-1']).toBeUndefined(); + expect(state.nodes).toEqual({}); + }); +}); + +describe('sharedTree.slice moveDocumentThunk.fulfilled', () => { + it('reorders rootIds by comparing personal orderKeys when moving a root document', () => { + const initialState: SharedTreeState = { + nodes: { + 'doc-s1': { + id: 'doc-s1', + title: 'Shared 1', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'doc-s2': { + id: 'doc-s2', + title: 'Shared 2', + parentId: null, + orderKey: 'a2', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'doc-stm': { + id: 'doc-stm', + title: 'Shared To Me', + parentId: null, + orderKey: 'a3', + hasChildren: false, + effectiveAccessLevel: 'VIEW', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['doc-s1', 'doc-s2', 'doc-stm'], + }; + + // User moved doc-stm between doc-s1 and doc-s2, backend assigned orderKey 'a1' + const state = sharedTreeReducer(initialState, { + type: 'sharedTree/moveDocument/fulfilled', + payload: { + updatedNode: { + id: 'doc-stm', + title: 'Shared To Me', + parentId: null, + orderKey: 'a1', + hasChildren: false, + effectiveAccessLevel: 'VIEW', + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + prevSiblingId: 'doc-s1', + nextSiblingId: 'doc-s2', + }, + }); + + expect(state.rootIds).toEqual(['doc-s1', 'doc-stm', 'doc-s2']); + expect(state.nodes['doc-stm'].orderKey).toBe('a1'); + }); + + it('correctly floats and reorders a nested shared document whose parent is not in the shared tree', () => { + const initialState: SharedTreeState = { + nodes: { + 'doc-s1': { + id: 'doc-s1', + title: 'Shared 1', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'doc-s2': { + id: 'doc-s2', + title: 'Shared 2', + parentId: null, + orderKey: 'a2', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'doc-floated': { + id: 'doc-floated', + title: 'Floated Doc', + parentId: null, + orderKey: 'a3', + hasChildren: false, + effectiveAccessLevel: 'VIEW', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['doc-s1', 'doc-s2', 'doc-floated'], + }; + + // Backend returns parentId: 'private-parent-uuid' (owner's private parent), but it's not in shared tree + const state = sharedTreeReducer(initialState, { + type: 'sharedTree/moveDocument/fulfilled', + payload: { + updatedNode: { + id: 'doc-floated', + title: 'Floated Doc', + parentId: 'private-parent-uuid', + orderKey: 'a1', + hasChildren: false, + effectiveAccessLevel: 'VIEW', + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + prevSiblingId: 'doc-s1', + nextSiblingId: 'doc-s2', + }, + }); + + expect(state.rootIds).toEqual(['doc-s1', 'doc-floated', 'doc-s2']); + expect(state.nodes['doc-floated'].parentId).toBeNull(); + expect(state.nodes['doc-floated'].orderKey).toBe('a1'); + }); + + it('correctly nests and reorders a child document whose parent exists in the shared tree', () => { + const initialState: SharedTreeState = { + nodes: { + 'shared-parent': { + id: 'shared-parent', + title: 'Shared Parent', + parentId: null, + orderKey: 'a0', + hasChildren: true, + effectiveAccessLevel: 'OWNER', + isExpanded: true, + isLoading: false, + children: ['child-1', 'child-2'], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'child-1': { + id: 'child-1', + title: 'Child 1', + parentId: 'shared-parent', + orderKey: 'c0', + hasChildren: false, + effectiveAccessLevel: 'EDIT', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'child-2': { + id: 'child-2', + title: 'Child 2', + parentId: 'shared-parent', + orderKey: 'c2', + hasChildren: false, + effectiveAccessLevel: 'EDIT', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + 'child-3': { + id: 'child-3', + title: 'Child 3', + parentId: 'shared-parent', + orderKey: 'c3', + hasChildren: false, + effectiveAccessLevel: 'EDIT', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['shared-parent'], + }; + + // Child 3 moved between child 1 and child 2 + const state = sharedTreeReducer(initialState, { + type: 'sharedTree/moveDocument/fulfilled', + payload: { + updatedNode: { + id: 'child-3', + title: 'Child 3', + parentId: 'shared-parent', + orderKey: 'c1', + hasChildren: false, + effectiveAccessLevel: 'EDIT', + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + prevSiblingId: 'child-1', + nextSiblingId: 'child-2', + }, + }); + + expect(state.nodes['shared-parent'].children).toEqual(['child-1', 'child-3', 'child-2']); + expect(state.nodes['child-3'].parentId).toBe('shared-parent'); + expect(state.nodes['child-3'].orderKey).toBe('c1'); + }); + + it('preserves existing non-fallback orderKey when syncSharedRoots receives entry without orderKey', () => { + const previous: SharedTreeState = { + nodes: { + 'collab-1': { + id: 'collab-1', + title: 'collab-1', + parentId: null, + orderKey: 'a1', + hasChildren: false, + effectiveAccessLevel: 'VIEW', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T11:00:00Z', + }, + }, + rootIds: ['collab-1'], + }; + + const state = sharedTreeReducer( + previous, + syncSharedRoots([entry('collab-1', 'collaborator', null)]) + ); + + expect(state.nodes['collab-1'].orderKey).toBe('a1'); + }); +}); diff --git a/web/tests/unit/stores/sidebarTree/sidebarTree.slice.test.ts b/web/tests/unit/stores/sidebarTree/sidebarTree.slice.test.ts new file mode 100644 index 0000000..90b19f5 --- /dev/null +++ b/web/tests/unit/stores/sidebarTree/sidebarTree.slice.test.ts @@ -0,0 +1,248 @@ +import sidebarTreeReducer, { + addNode, + removeNode, + resetTree, + setNodeExpanded, + toggleExpanded, + updateNodeMeta, + compareOrderKeys, + type SidebarTreeState, +} from '@/stores/sidebarTree/sidebarTree.slice'; +import type { TreeNode } from '@/types/tree.types'; + +describe('sidebarTree.slice', () => { + const initialState: SidebarTreeState = { + nodes: {}, + rootIds: [], + isRootLoading: false, + rootHasMore: false, + rootPage: 0, + }; + + describe('compareOrderKeys', () => { + it('sorts keys correctly in lexicographical order', () => { + expect(compareOrderKeys('a0', 'a1')).toBe(-1); + expect(compareOrderKeys('a1', 'a0')).toBe(1); + expect(compareOrderKeys('a0', 'a0')).toBe(0); + }); + }); + + describe('reducers', () => { + it('toggles and sets expanded state', () => { + const stateWithNode: SidebarTreeState = { + ...initialState, + nodes: { + 'doc-1': { + id: 'doc-1', + title: 'Doc 1', + parentId: null, + orderKey: 'a0', + hasChildren: true, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + }, + rootIds: ['doc-1'], + }; + + const toggled = sidebarTreeReducer(stateWithNode, toggleExpanded('doc-1')); + expect(toggled.nodes['doc-1'].isExpanded).toBe(true); + + const setExplicit = sidebarTreeReducer( + toggled, + setNodeExpanded({ id: 'doc-1', expanded: false }) + ); + expect(setExplicit.nodes['doc-1'].isExpanded).toBe(false); + }); + + it('updates node metadata title', () => { + const stateWithNode: SidebarTreeState = { + ...initialState, + nodes: { + 'doc-1': { + id: 'doc-1', + title: 'Old Title', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + }, + rootIds: ['doc-1'], + }; + + const updated = sidebarTreeReducer( + stateWithNode, + updateNodeMeta({ id: 'doc-1', title: 'New Title' }) + ); + expect(updated.nodes['doc-1'].title).toBe('New Title'); + }); + + it('adds root nodes and child nodes with proper ordering', () => { + const rootNode1: TreeNode = { + id: 'doc-1', + title: 'Doc 1', + parentId: null, + orderKey: 'a1', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }; + const rootNode0: TreeNode = { + id: 'doc-0', + title: 'Doc 0', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }; + + let state = sidebarTreeReducer(initialState, addNode(rootNode1)); + expect(state.rootIds).toEqual(['doc-1']); + + state = sidebarTreeReducer(state, addNode(rootNode0)); + expect(state.rootIds).toEqual(['doc-0', 'doc-1']); + + const childNode: TreeNode = { + id: 'child-1', + title: 'Child 1', + parentId: 'doc-0', + orderKey: 'b0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }; + + state = sidebarTreeReducer(state, addNode(childNode)); + expect(state.nodes['doc-0'].hasChildren).toBe(true); + expect(state.nodes['doc-0'].children).toEqual(['child-1']); + expect(state.nodes['doc-0'].isExpanded).toBe(true); + }); + + it('resets tree state', () => { + const populatedState: SidebarTreeState = { + nodes: { + 'doc-1': { + id: 'doc-1', + title: 'Doc 1', + parentId: null, + orderKey: 'a0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + }, + rootIds: ['doc-1'], + isRootLoading: true, + rootHasMore: true, + rootPage: 2, + }; + + const reset = sidebarTreeReducer(populatedState, resetTree()); + expect(reset.nodes).toEqual({}); + expect(reset.rootIds).toEqual([]); + expect(reset.isRootLoading).toBe(false); + expect(reset.rootHasMore).toBe(false); + expect(reset.rootPage).toBe(0); + }); + + it('recursively removes target node and all nested descendants on removeNode', () => { + const stateWithHierarchy: SidebarTreeState = { + ...initialState, + nodes: { + 'root-1': { + id: 'root-1', + title: 'Root 1', + parentId: null, + orderKey: 'a0', + hasChildren: true, + effectiveAccessLevel: 'OWNER', + isExpanded: true, + isLoading: false, + children: ['child-1', 'child-2'], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + 'child-1': { + id: 'child-1', + title: 'Child 1', + parentId: 'root-1', + orderKey: 'b0', + hasChildren: true, + effectiveAccessLevel: 'OWNER', + isExpanded: true, + isLoading: false, + children: ['grandchild-1'], + childrenLoaded: true, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + 'child-2': { + id: 'child-2', + title: 'Child 2', + parentId: 'root-1', + orderKey: 'b1', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + 'grandchild-1': { + id: 'grandchild-1', + title: 'Grandchild 1', + parentId: 'child-1', + orderKey: 'c0', + hasChildren: false, + effectiveAccessLevel: 'OWNER', + isExpanded: false, + isLoading: false, + children: [], + childrenLoaded: false, + createdAt: '2024-01-01T10:00:00Z', + updatedAt: '2024-01-01T10:00:00Z', + }, + }, + rootIds: ['root-1'], + }; + + // Removing child-1 should remove child-1 and grandchild-1, leaving root-1 and child-2 + let state = sidebarTreeReducer(stateWithHierarchy, removeNode('child-1')); + expect(state.nodes['child-1']).toBeUndefined(); + expect(state.nodes['grandchild-1']).toBeUndefined(); + expect(state.nodes['root-1'].children).toEqual(['child-2']); + expect(state.nodes['child-2']).toBeDefined(); + + // Removing root-1 should remove root-1 and child-2 + state = sidebarTreeReducer(state, removeNode('root-1')); + expect(state.rootIds).toEqual([]); + expect(state.nodes['root-1']).toBeUndefined(); + expect(state.nodes['child-2']).toBeUndefined(); + expect(state.nodes).toEqual({}); + }); + }); +}); diff --git a/web/types/tree.types.ts b/web/types/tree.types.ts new file mode 100644 index 0000000..4abb580 --- /dev/null +++ b/web/types/tree.types.ts @@ -0,0 +1,42 @@ +import type { DocumentAccessLevel } from '@/services/document.service'; + +export interface TreeNode { + id: string; + title: string; + parentId: string | null; + orderKey: string; + hasChildren: boolean; + effectiveAccessLevel: DocumentAccessLevel | null; + createdAt: string; + updatedAt: string; +} + +export interface TreeNodePage { + items: TreeNode[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasMore: boolean; +} + +export interface MoveDocumentRequest { + newParentId: string | null; + prevSiblingId: string | null; + nextSiblingId: string | null; +} + +export interface SidebarTreeNode { + id: string; + title: string; + parentId: string | null; + orderKey: string; + hasChildren: boolean; + effectiveAccessLevel: DocumentAccessLevel | null; + isExpanded: boolean; + isLoading: boolean; + children: string[]; + childrenLoaded: boolean; + createdAt: string; + updatedAt: string; +}