From f5c52561552236b2f402d4371f0eb9e8ab8b12da Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Mon, 27 Jul 2026 14:18:32 +0530 Subject: [PATCH 01/20] api/util: Implement FractionalIndex utility for lexicographical ordering. Ordering items in a database using sequential integers requires updating all subsequent rows whenever an item is inserted or moved. To allow O(1) inserts and moves at arbitrary positions without table-wide rewrites, we implement a Base62 fractional indexing utility that generates lexicographically sortable string keys. The generator computes midpoint keys between any two valid bounds using a variable-length integer prefix followed by fractional digits. In addition to single-key generation, it provides batch generation helpers with configurable gaps to distribute keys evenly when re-indexing densely packed sibling lists. Order keys are validated against character set and length invariants to ensure correct lexicographical sorting across database collations. --- .../api/document/util/FractionalIndex.java | 277 ++++++++++++++++++ .../document/util/FractionalIndexTest.java | 117 ++++++++ 2 files changed, 394 insertions(+) create mode 100644 api/src/main/java/com/nextdocs/api/document/util/FractionalIndex.java create mode 100644 api/src/test/java/com/nextdocs/api/document/util/FractionalIndexTest.java 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/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")); + } +} From fa254c990485aa65d3ffa0f43fe88f637941fd0a Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Wed, 29 Jul 2026 10:45:11 +0530 Subject: [PATCH 02/20] api/schema: Add document nesting schema migrations and entity mappings. To support arbitrary document hierarchies, we extend the documents table with a self-referencing parent_id foreign key and a sibling_order_key column. A check constraint enforces that any nested document (parent_id IS NOT NULL) must have a sibling_order_key, while root documents leave sibling_order_key null. A partial index on (parent_id, sibling_order_key) optimizes child retrieval under any parent node. In the JPA model, Document is mapped with a lazy self-referencing parent relation and siblingOrderKey property. DocumentRepository and DocumentCollaboratorRepository are extended with query methods for querying direct non-trashed children, retrieving sibling keys for boundary calculations, and cleaning up collaborator records during document deletion. --- .../api/document/entity/Document.java | 10 ++- .../DocumentCollaboratorRepository.java | 5 ++ .../db/migration/V6__document_nesting.sql | 61 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 api/src/main/resources/db/migration/V6__document_nesting.sql 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/repository/DocumentCollaboratorRepository.java b/api/src/main/java/com/nextdocs/api/document/repository/DocumentCollaboratorRepository.java index 3bbf1c9..a1decf0 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 @@ -17,4 +17,9 @@ public interface DocumentCollaboratorRepository extends JpaRepository Date: Fri, 31 Jul 2026 16:32:05 +0530 Subject: [PATCH 03/20] api/schema: Introduce user_document_orders table and repository. In Nextdocs, root documents appear in two sidebar sections: Private (owned) and Shared (collaborating). Collaborators must be able to organize shared documents in their personal sidebar navigation without mutating the document entity or affecting how other collaborators view the list. We introduce the user_document_orders table to decouple personal sidebar ordering from document content. Each row maps a user and document to an order_key. Flyway migrations V8 and V9 re-index existing timestamp keys into valid Base62 fractional keys and enforce unique constraints on (user_id, order_key) and (parent_id, sibling_order_key) so that concurrent reorders cannot produce duplicate keys within a user's navigation or parent child list. UserDocumentOrderRepositoryTest tests persistence, unique constraint enforcement, and neighbor key lookups. --- .../document/entity/UserDocumentOrder.java | 55 +++++++++ .../UserDocumentOrderRepository.java | 65 +++++++++++ .../migration/V8__fix_order_key_backfill.sql | 57 +++++++++ .../migration/V9__reindex_all_order_keys.sql | 69 +++++++++++ .../UserDocumentOrderRepositoryTest.java | 108 ++++++++++++++++++ 5 files changed, 354 insertions(+) create mode 100644 api/src/main/java/com/nextdocs/api/document/entity/UserDocumentOrder.java create mode 100644 api/src/main/java/com/nextdocs/api/document/repository/UserDocumentOrderRepository.java create mode 100644 api/src/main/resources/db/migration/V8__fix_order_key_backfill.sql create mode 100644 api/src/main/resources/db/migration/V9__reindex_all_order_keys.sql create mode 100644 api/src/test/java/com/nextdocs/api/document/repository/UserDocumentOrderRepositoryTest.java 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/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/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/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); + } +} From 4e89d15b1e2fab68f0791060128b13960c4cb224 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Mon, 3 Aug 2026 11:20:47 +0530 Subject: [PATCH 04/20] api/permission: Implement recursive ancestor access resolution. Previously, access checks inspected only the target document's direct owner or collaborator rows. In a nested hierarchy, permissions granted on an ancestor page must inherit down to all descendants using a closest-ancestor-wins rule. We introduce the resolve_effective_access PostgreSQL recursive function, which walks the parent chain up to 100 levels to find the nearest explicit grant. For trash management, resolve_trash_access identifies the root of the contiguous trashed subtree (the trash bundle) and resolves access against that root, ensuring that items grafted into another user's tree follow the host tree's lifecycle. Migration V11 normalizes nested document ownership so child.user_id always matches the root owner (location authority). PermissionService centralizes all authorization checks across the application, providing strict methods for read, edit, direct ownership, and trash scope access. PermissionServiceTest verifies inheritance rules, link access resolution, and trash boundary enforcement. --- .../dto/response/DocumentAccessResponse.java | 13 +- .../repository/DocumentRepository.java | 86 ++++- .../document/service/PermissionService.java | 163 +++++++++ .../V10__trash_permission_resolution.sql | 85 +++++ .../V11__normalize_document_ownership.sql | 25 ++ .../V7__ancestor_permission_resolution.sql | 54 +++ .../service/PermissionServiceTest.java | 325 ++++++++++++++++++ 7 files changed, 746 insertions(+), 5 deletions(-) create mode 100644 api/src/main/java/com/nextdocs/api/document/service/PermissionService.java create mode 100644 api/src/main/resources/db/migration/V10__trash_permission_resolution.sql create mode 100644 api/src/main/resources/db/migration/V11__normalize_document_ownership.sql create mode 100644 api/src/main/resources/db/migration/V7__ancestor_permission_resolution.sql create mode 100644 api/src/test/java/com/nextdocs/api/document/service/PermissionServiceTest.java 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/repository/DocumentRepository.java b/api/src/main/java/com/nextdocs/api/document/repository/DocumentRepository.java index d13cff3..d30a24b 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,14 +19,10 @@ 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 " @@ -33,7 +31,87 @@ public interface DocumentRepository extends JpaRepository { + "ORDER BY d.updatedAt DESC, d.createdAt DESC, d.id ASC") 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 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 findPrivateRootDocuments(@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); + + // 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/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/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/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/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()); + } +} From 883b266ffecf6bacf4bf80307d6d37e85df06afe Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Thu, 6 Aug 2026 15:54:19 +0530 Subject: [PATCH 05/20] api/document: Support nested document creation and cascade deletion. Extends DocumentService to support creating documents at specific positions within a parent's hierarchy or at the root level. When creating a nested document, the service enforces EDIT permission on the target parent, adopts the parent's owner under location authority, and calculates initial fractional index ordering keys. Deleting a document cascades soft-deletion across all of its descendants, and permanent purge removes the entire subtree along with associated collaborator and ordering records. Restoring a trashed document restores its descendant subtree, verifies that its parent is not trashed (or falls back to root level), and generates a fresh ordering key if the original key collides with active siblings. --- .../dto/request/DocumentCreateRequest.java | 11 +- .../dto/response/DocumentResponse.java | 5 + .../api/document/service/DocumentService.java | 511 ++++++-- .../document/service/DocumentServiceTest.java | 1079 ++++++++++++++++- 4 files changed, 1456 insertions(+), 150 deletions(-) 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/response/DocumentResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentResponse.java index b68c08d..d4de09a 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 @@ -12,6 +12,11 @@ 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 = "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/service/DocumentService.java b/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java index 0dfe484..a5f869c 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 @@ -12,11 +12,19 @@ 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.HashMap; +import java.util.List; +import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; @@ -35,61 +43,112 @@ 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; @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) @@ -108,10 +167,12 @@ public Page list(UUID userId, Pageable pageable, boolean trash } Page page = trashedOnly - ? documentRepository.findAllByUser_IdAndDeletedAtIsNotNull(userId, effectivePageable) + ? documentRepository.findAccessibleTrashedDocuments(userId, effectivePageable) : documentRepository.findAllByUser_IdAndDeletedAtIsNull(userId, effectivePageable); - return page.map(document -> toResponse(document, false)); + Map rootOrderKeys = trashedOnly ? Map.of() : fetchRootOrderKeys(userId, page.getContent()); + + return page.map(document -> toResponse(document, false, rootOrderKeys)); } @Transactional(readOnly = true) @@ -121,23 +182,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,7 +207,7 @@ 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); } @@ -156,23 +217,23 @@ public DocumentResponse getPublic(UUID documentId) { @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 +248,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 @@ -257,11 +429,38 @@ private static byte[] decodeBase64State(String yjsState) { } private DocumentResponse toResponse(Document document, boolean includeState) { + return toResponse(document, includeState, (Map) null); + } + + private DocumentResponse toResponse(Document document, boolean includeState, UUID callerUserId) { + return toResponse(document, includeState, callerUserId, null); + } + + private DocumentResponse toResponse(Document document, boolean includeState, Map rootOrderKeys) { + return toResponse(document, includeState, null, rootOrderKeys); + } + + private DocumentResponse toResponse( + Document document, boolean includeState, UUID callerUserId, Map rootOrderKeys) { OffsetDateTime deletedAt = document.getDeletedAt(); OffsetDateTime purgeAt = null; if (deletedAt != null) { purgeAt = deletedAt.plusDays(documentProperties.getTrashRetentionDays()); } + + String orderKey = document.getParent() != null + ? document.getSiblingOrderKey() + : rootOrderKeys != null + ? rootOrderKeys.get(document.getId()) + : callerUserId != null + ? userDocumentOrderRepository + .findOrderKeyByUserIdAndDocumentId(callerUserId, document.getId()) + .orElse(null) + : userDocumentOrderRepository + .findOrderKeyByUserIdAndDocumentId( + document.getUser().getId(), document.getId()) + .orElse(null); + return new DocumentResponse( document.getId(), document.getTitle(), @@ -270,6 +469,8 @@ private DocumentResponse toResponse(Document document, boolean includeState) { ? Base64.getEncoder().encodeToString(document.getYjsState()) : null) : null, + document.getParent() != null ? document.getParent().getId() : null, + orderKey, document.getCreatedBy(), document.getCreatedAt(), document.getUpdatedAt(), @@ -277,46 +478,174 @@ 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 Map fetchRootOrderKeys(UUID userId, List docs) { + List rootIds = docs.stream() + .filter(document -> document.getParent() == null) + .map(Document::getId) + .toList(); + if (rootIds.isEmpty()) { + return Map.of(); + } + Map orderKeys = new HashMap<>(); + for (Object[] row : userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(userId, rootIds)) { + orderKeys.put((UUID) row[0], (String) row[1]); + } + return orderKeys; + } - if (document.getUser().getId().equals(userId)) { - return document; + 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; + } - DocumentAccessLevel effectiveAccess = resolveEffectiveNonOwnerAccess(userId, document); - if (effectiveAccess == null) { - throw new ApiException(ErrorCode.NOT_FOUND); + 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); + } - if (requireEdit && !effectiveAccess.allowsEdit()) { - throw new ApiException(ErrorCode.FORBIDDEN); + 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; } - return document; - } + 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; + } - private DocumentAccessLevel resolveEffectiveNonOwnerAccess(UUID userId, Document document) { - DocumentAccessLevel collaboratorAccess = collaboratorRepository - .findByDocument_IdAndUser_Id(document.getId(), userId) - .map(DocumentCollaborator::getAccessLevel) - .orElse(null); + 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."); + } + } - // Explicit collaborator access takes precedence over general link access. - if (collaboratorAccess != null) { - return collaboratorAccess; + if (prevKey == null && nextKey == null) { + String minKey = documentRepository + .findMinSiblingOrderKey(parentId, selfId) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + return FractionalIndex.keyBetween(null, minKey); } - return resolveGeneralAccessLevel(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 resolveGeneralAccessLevel(Document document) { - if (document.getGeneralAccessMode() != DocumentGeneralAccessMode.ANYONE_WITH_LINK) { - return 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; + } + + 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 document.getLinkAccessLevel(); + 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 = userDocumentOrderRepository + .findMinOrderKeyByUserId(userId, selfId) + .filter(FractionalIndex::isValidOrderKey) + .orElse(null); + return FractionalIndex.keyBetween(null, minKey); + } + + 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/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java b/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java index dacc901..50ef373 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,16 @@ 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.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 +21,19 @@ 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.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 +42,10 @@ 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; +import org.springframework.data.domain.Pageable; @ExtendWith(MockitoExtension.class) class DocumentServiceTest { @@ -45,6 +59,12 @@ class DocumentServiceTest { @Mock private DocumentCollaboratorRepository collaboratorRepository; + @Mock + private UserDocumentOrderRepository userDocumentOrderRepository; + + @Mock + private PermissionService permissionService; + private DocumentProperties documentProperties; private DocumentService documentService; @@ -53,8 +73,13 @@ 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); } @Test @@ -79,18 +104,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 +165,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 +194,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 +206,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); @@ -172,15 +288,15 @@ void update_allowsEditWhenGeneralAccessIsEdit() { 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).resolveAccess(requesterId, documentId); } @Test @@ -189,9 +305,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,82 +318,930 @@ void update_returnsForbiddenWhenGeneralAccessIsReadOnly() { } @Test - void update_prefersCollaboratorReadOnlyOverGeneralEditAccess() { + void list_usesBatchOrderKeyLookupForRootDocuments() { + UUID userId = UUID.randomUUID(); + User user = User.builder() + .id(userId) + .email("alice@example.com") + .displayName("Alice") + .build(); + + 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(); + + Page page = new PageImpl<>(List.of(root1, root2, child)); + when(documentRepository.findAllByUser_IdAndDeletedAtIsNull(eq(userId), any(Pageable.class))) + .thenReturn(page); + when(userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds( + eq(userId), eq(List.of(root1.getId(), root2.getId())))) + .thenReturn(List.of(new Object[] {root1.getId(), "a1"}, new Object[] {root2.getId(), "a2"})); + + Page result = documentService.list(userId, null, false); + + assertEquals(3, result.getContent().size()); + assertEquals("a1", result.getContent().get(0).orderKey()); + assertEquals("a2", result.getContent().get(1).orderKey()); + assertEquals("b5", result.getContent().get(2).orderKey()); + verify(userDocumentOrderRepository, never()).findOrderKeyByUserIdAndDocumentId(any(), any()); + } + + @Test + void delete_softDelete_withEditAccess_setsDeletedAtAndSaves() { UUID requesterId = UUID.randomUUID(); 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) + Document document = Document.builder() + .id(documentId) + .title("Child doc") + .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)); + when(permissionService.requireEditAccess(requesterId, documentId)).thenReturn(document); + when(documentRepository.save(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); - ApiException exception = assertThrows( - ApiException.class, - () -> documentService.update( - requesterId, documentId, new DocumentUpdateRequest("Updated title", null, null))); + documentService.delete(requesterId, documentId, false); - assertEquals(ErrorCode.FORBIDDEN, exception.getErrorCode()); - verify(documentRepository, never()).save(any(Document.class)); + 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_allowsCollaboratorAccessWhenIncludeTrashedIsTrue() { + void delete_softDelete_withViewOnlyAccess_throwsForbidden() { UUID requesterId = UUID.randomUUID(); UUID documentId = UUID.randomUUID(); - Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); - when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); - when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) - .thenReturn(Optional.empty()); // link access allows VIEW + doThrow(new ApiException(ErrorCode.FORBIDDEN)) + .when(permissionService) + .requireEditAccess(requesterId, documentId); - var response = documentService.get(requesterId, documentId, true); + ApiException exception = + assertThrows(ApiException.class, () -> documentService.delete(requesterId, documentId, false)); - assertEquals(documentId, response.id()); - assertEquals("Shared doc", response.title()); + assertEquals(ErrorCode.FORBIDDEN, exception.getErrorCode()); + verify(documentRepository, never()).save(any()); } @Test - void get_rejectsCollaboratorAccessWhenDocumentIsTrashedAndIncludeTrashedIsTrue() { + void delete_softDelete_withNoAccess_throwsNotFound() { 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.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 get_rejectsAccessWhenCollaboratorHasNoAccessAndIncludeTrashedIsTrue() { - UUID requesterId = UUID.randomUUID(); + void delete_permanentDelete_inTrash_deletesDocument() { + UUID ownerId = UUID.randomUUID(); UUID documentId = UUID.randomUUID(); - Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); - document.setGeneralAccessMode(DocumentGeneralAccessMode.RESTRICTED); + 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(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); - when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + 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.get(requesterId, documentId, true)); + 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(); + + when(documentRepository.findAccessibleTrashedDocuments(eq(userId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(trashed))); + + Page result = documentService.list(userId, null, true); + + assertEquals(1, result.getContent().size()); + assertNotNull(result.getContent().get(0).deletedAt()); + assertNull(result.getContent().get(0).orderKey()); + } + private static Document createSharedDocument(UUID documentId, DocumentAccessLevel linkAccessLevel) { User owner = User.builder() .id(UUID.randomUUID()) From efe4a572c92a36b6bcd99967d09cfd345d1731be Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Sun, 9 Aug 2026 18:07:33 +0530 Subject: [PATCH 06/20] api/document: Support collaborator navigation ordering and trash access. When a user is added as a collaborator to a document, they need an entry in user_document_orders so the document appears in their Shared sidebar section. DocumentSharingService is updated to create this navigation row prepended to the user's list, with automatic retries if concurrent additions generate colliding fractional keys. Removing a collaborator cleans up their navigation ordering row. The service also integrates with PermissionService to evaluate access through ancestor resolution. getMyAccess now returns pre-trash access information for trashed documents, allowing the frontend to render a read-only trash preview while keeping realtime websocket connections strictly restricted. --- .../service/DocumentSharingService.java | 171 ++++++---- .../DocumentSharingControllerTest.java | 11 +- .../service/DocumentSharingServiceTest.java | 305 ++++++++++++++++-- 3 files changed, 411 insertions(+), 76 deletions(-) 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..9fd2a10 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 @@ -15,11 +15,19 @@ 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.HashMap; import java.util.List; +import java.util.Map; 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.Pageable; import org.springframework.stereotype.Service; @@ -29,13 +37,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 +73,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 +123,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 +138,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 +161,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 +173,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 +190,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 +204,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) { @@ -177,12 +224,28 @@ public SharingSettingsResponse updateSharingSettings( @Transactional(readOnly = true) public Page listSharedWithMe(UUID userId, Pageable pageable) { - return documentRepository.findSharedWithUserId(userId, pageable).map(this::toDocumentSummaryResponse); + Page page = documentRepository.findSharedWithUserId(userId, pageable); + List ids = page.getContent().stream().map(Document::getId).toList(); + Map navOrderKeys = fetchUserNavOrderKeys(userId, ids); + return page.map(doc -> toDocumentSummaryResponse(doc, navOrderKeys.get(doc.getId()))); } @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 +253,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(); - } - - private Document requireOwnedActiveDocument(UUID ownerId, UUID documentId) { - return documentRepository - .findByIdAndUser_IdAndDeletedAtIsNull(documentId, ownerId) - .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + return computeActiveAccess(userId, documentId, doc); } - 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) { @@ -268,11 +311,25 @@ private static DocumentAccessLevel normalizeLinkAccess(DocumentAccessLevel acces return accessLevel; } - private DocumentResponse toDocumentSummaryResponse(Document document) { + private Map fetchUserNavOrderKeys(UUID userId, List documentIds) { + if (documentIds.isEmpty()) { + return Map.of(); + } + Map orderKeys = new HashMap<>(); + for (Object[] row : userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(userId, documentIds)) { + orderKeys.put((UUID) row[0], (String) row[1]); + } + return orderKeys; + } + + private DocumentResponse toDocumentSummaryResponse(Document document, String navOrderKey) { + String orderKey = document.getParent() == null ? navOrderKey : document.getSiblingOrderKey(); return new DocumentResponse( document.getId(), document.getTitle(), null, + document.getParent() != null ? document.getParent().getId() : null, + orderKey, document.getCreatedBy(), document.getCreatedAt(), document.getUpdatedAt(), 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..e741e5c 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 @@ -235,7 +235,16 @@ void updateSharingSettings_restricted_withLinkAccessLevel_returns400() throws Ex @Test void listSharedWithMe_success_returns200() throws Exception { DocumentResponse doc = new DocumentResponse( - documentId, "Shared Doc", null, "Owner", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + documentId, + "Shared Doc", + null, + null, + 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); 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..166306d 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,8 @@ 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.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -13,15 +15,19 @@ import com.nextdocs.api.document.dto.request.CollaboratorUpsertRequest; 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.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; @@ -30,6 +36,11 @@ 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; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; @ExtendWith(MockitoExtension.class) class DocumentSharingServiceTest { @@ -40,14 +51,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 +79,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 +94,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 +105,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 +226,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 +256,171 @@ 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); + } + + @Test + void listSharedWithMe_returnsUserNavOrderKeyForRootDocuments() { + UUID userId = UUID.randomUUID(); + User owner = User.builder().id(UUID.randomUUID()).build(); + + Document rootDoc = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Shared root") + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + Document parent = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Parent") + .build(); + Document nestedDoc = Document.builder() + .id(UUID.randomUUID()) + .user(owner) + .title("Shared nested") + .parent(parent) + .siblingOrderKey("c0") + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + Pageable pageable = PageRequest.of(0, 50); + Page page = new PageImpl<>(List.of(rootDoc, nestedDoc)); + + when(documentRepository.findSharedWithUserId(userId, pageable)).thenReturn(page); + when(userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(eq(userId), any())) + .thenReturn(List.of(new Object[] {rootDoc.getId(), "a0"})); + + Page result = sharingService.listSharedWithMe(userId, pageable); + + assertEquals("a0", result.getContent().get(0).orderKey()); + assertNull(result.getContent().get(0).parentId()); + assertEquals("c0", result.getContent().get(1).orderKey()); + assertEquals(parent.getId(), result.getContent().get(1).parentId()); + } + private static Document createSharedDocument(UUID documentId, DocumentAccessLevel linkAccessLevel) { User owner = User.builder() .id(UUID.randomUUID()) From 612a0146ceefa7fb65b4e8e85de76b213add74c8 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Thu, 13 Aug 2026 17:41:26 +0530 Subject: [PATCH 07/20] api/document: Implement document tree navigation and move endpoints. Implements DocumentTreeService and controller endpoints to power the sidebar tree UI and handle drag-and-drop document movements. Root documents in Private and Shared sections, as well as direct children of any parent node, are fetched with batch child counts for expandable tree chevrons and batch effective access resolution. The move operation handles reparenting to a new parent node or reordering within root navigation. It prevents circular references by walking the target ancestor chain up to 100 levels, transfers ownership of the subtree to the host parent tree, and automatically re-indexes siblings or user orders if key intervals are exhausted or colliding. --- .../controller/DocumentController.java | 122 +- .../dto/request/DocumentMoveRequest.java | 15 + .../response/DocumentTreeNodeResponse.java | 25 + .../document/service/DocumentTreeService.java | 561 ++++++++ .../controller/DocumentControllerTest.java | 61 +- .../DocumentTreeControllerTest.java | 186 +++ .../service/DocumentTreeServiceTest.java | 1157 +++++++++++++++++ 7 files changed, 2114 insertions(+), 13 deletions(-) create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/request/DocumentMoveRequest.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/response/DocumentTreeNodeResponse.java create mode 100644 api/src/main/java/com/nextdocs/api/document/service/DocumentTreeService.java create mode 100644 api/src/test/java/com/nextdocs/api/document/controller/DocumentTreeControllerTest.java create mode 100644 api/src/test/java/com/nextdocs/api/document/service/DocumentTreeServiceTest.java 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..6a4001b 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,9 +4,12 @@ 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.DocumentResponse; +import com.nextdocs.api.document.dto.response.DocumentTreeNodeResponse; 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; @@ -30,6 +33,7 @@ public class DocumentController { private final DocumentService documentService; + private final DocumentTreeService documentTreeService; @Operation( summary = "Create a document", @@ -59,9 +63,10 @@ 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).", + description = "Returns a paged list of documents. By default only active documents owned by " + + "the authenticated user are returned (ordered by last update). Use trashed=true to list " + + "documents in trash (ordered by time moved to trash): those the user owns plus shared " + + "documents on which they have at least EDIT access.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", @@ -82,9 +87,10 @@ public ResponseEntity>> list( @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", @@ -153,8 +159,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 +188,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 +208,100 @@ public ResponseEntity> restore( DocumentResponse response = documentService.restore(principal.getId(), id); return ResponseEntity.ok(ApiResponse.ok(response, "Document restored.")); } + + @Operation( + summary = "List root-level documents for the sidebar (paginated)", + description = "Returns root-level (no parent) non-trashed documents owned by " + + "the authenticated user, ordered by order_key. Paginated.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Root documents returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required") + }) + @GetMapping("/tree/root") + public ResponseEntity>> getRootDocuments( + @AuthenticationPrincipal UserPrincipal principal, @PageableDefault(size = 50) Pageable pageable) { + return ResponseEntity.ok( + ApiResponse.ok(PagedResponse.from(documentTreeService.getRootDocuments(principal.getId(), pageable)))); + } + + @Operation( + summary = "List shared documents for the sidebar (paginated)", + description = "Returns root-level documents in the authenticated user's Shared section " + + "(both owner-shared and shared-with-me), ordered by personal order_key. Paginated.", + 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("/tree/shared") + public ResponseEntity>> getSharedDocuments( + @AuthenticationPrincipal UserPrincipal principal, @PageableDefault(size = 50) Pageable pageable) { + return ResponseEntity.ok(ApiResponse.ok( + PagedResponse.from(documentTreeService.getSharedDocuments(principal.getId(), pageable)))); + } + + @Operation( + summary = "List direct children of a document (paginated)", + description = "Returns the direct non-trashed children of the given document, " + + "ordered by order_key. Paginated. The authenticated user must be the owner " + + "or have at least VIEW access.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Children returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Parent document not found") + }) + @GetMapping("/{id}/children") + public ResponseEntity>> getChildren( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID id, + @PageableDefault(size = 50) Pageable pageable) { + return ResponseEntity.ok( + ApiResponse.ok(PagedResponse.from(documentTreeService.getChildren(principal.getId(), id, pageable)))); + } + + @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/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/DocumentTreeNodeResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentTreeNodeResponse.java new file mode 100644 index 0000000..10cc553 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentTreeNodeResponse.java @@ -0,0 +1,25 @@ +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; + +@Schema(description = "Sidebar tree node for a document") +public record DocumentTreeNodeResponse( + @Schema(description = "Document ID") UUID id, + @Schema(description = "Document title") String title, + + @Schema(description = "Parent document ID, null for root-level") + UUID parentId, + + @Schema(description = "Fractional ordering key") String orderKey, + + @Schema(description = "Whether this node has children (for lazy-load chevron)") + boolean hasChildren, + + @Schema(description = "Effective access level of requesting user") + DocumentAccessLevel effectiveAccessLevel, + + @Schema(description = "Creation timestamp") OffsetDateTime createdAt, + @Schema(description = "Last update timestamp") OffsetDateTime updatedAt) {} 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..6bb618d --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentTreeService.java @@ -0,0 +1,561 @@ +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.DocumentTreeNodeResponse; +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +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.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.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; + + @Transactional(readOnly = true) + public Page getRootDocuments(UUID userId, Pageable pageable) { + Page rows = documentRepository.findPrivateRootDocuments(userId, pageable); + List docs = + rows.getContent().stream().map(r -> (Document) r[0]).toList(); + if (docs.isEmpty()) { + return Page.empty(pageable); + } + + List docIds = docs.stream().map(Document::getId).toList(); + Map childCounts = fetchChildCounts(docIds); + + List nodes = 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 DocumentTreeNodeResponse( + doc.getId(), + doc.getTitle(), + null, + orderKey, + hasChildren, + DocumentAccessLevel.OWNER, + doc.getCreatedAt(), + doc.getUpdatedAt()); + }) + .toList(); + + return new PageImpl<>(nodes, pageable, rows.getTotalElements()); + } + + @Transactional(readOnly = true) + public Page getSharedDocuments(UUID userId, Pageable pageable) { + Page rows = documentRepository.findSharedRootDocuments(userId, pageable); + List docs = + rows.getContent().stream().map(r -> (Document) r[0]).toList(); + if (docs.isEmpty()) { + return Page.empty(pageable); + } + + List docIds = docs.stream().map(Document::getId).toList(); + Map childCounts = fetchChildCounts(docIds); + Map accessLevels = fetchAccessLevels(userId, docIds); + + List nodes = rows.getContent().stream() + .map(r -> { + Document doc = (Document) r[0]; + String orderKey = (String) r[1]; + boolean hasChildren = childCounts.getOrDefault(doc.getId(), 0L) > 0; + DocumentAccessLevel access = doc.getUser().getId().equals(userId) + ? DocumentAccessLevel.OWNER + : accessLevels.getOrDefault(doc.getId(), null); + UUID parentId = doc.getParent() != null ? doc.getParent().getId() : null; + return new DocumentTreeNodeResponse( + doc.getId(), + doc.getTitle(), + parentId, + orderKey, + hasChildren, + access, + doc.getCreatedAt(), + doc.getUpdatedAt()); + }) + .toList(); + + return new PageImpl<>(nodes, pageable, rows.getTotalElements()); + } + + @Transactional(readOnly = true) + public Page getChildren(UUID userId, UUID parentId, Pageable pageable) { + 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 ids = page.getContent().stream().map(Document::getId).toList(); + if (ids.isEmpty()) { + return Page.empty(pageable); + } + + Map childCounts = fetchChildCounts(ids); + Map accessLevels = fetchAccessLevels(userId, ids); + + return page.map(doc -> new DocumentTreeNodeResponse( + doc.getId(), + doc.getTitle(), + parentId, + doc.getSiblingOrderKey(), + childCounts.getOrDefault(doc.getId(), 0L) > 0, + accessLevels.getOrDefault(doc.getId(), null), + doc.getCreatedAt(), + doc.getUpdatedAt())); + } + + public DocumentTreeNodeResponse 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 DocumentTreeNodeResponse 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); + DocumentAccessLevel access = permissionService.resolveAccess(userId, documentId); + return new DocumentTreeNodeResponse( + saved.getId(), + saved.getTitle(), + newParent.getId(), + newSiblingOrderKey, + hasChildren, + access, + saved.getCreatedAt(), + saved.getUpdatedAt()); + } 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); + DocumentAccessLevel access = doc.getUser().getId().equals(userId) + ? DocumentAccessLevel.OWNER + : permissionService.resolveAccess(userId, documentId); + + UUID resultParentId = doc.getParent() != null ? doc.getParent().getId() : null; + + return new DocumentTreeNodeResponse( + doc.getId(), + doc.getTitle(), + resultParentId, + newUserOrderKey, + hasChildren, + access, + doc.getCreatedAt(), + doc.getUpdatedAt()); + } + } + + /** + * 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++; + } + } + + private Map fetchChildCounts(List docIds) { + 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, List docIds) { + 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; + } +} 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..a08d4ce 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 @@ -16,6 +16,7 @@ import com.nextdocs.api.common.exception.ErrorCode; import com.nextdocs.api.document.dto.response.DocumentResponse; 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 +46,9 @@ class DocumentControllerTest { @MockitoBean private DocumentService documentService; + @MockitoBean + private DocumentTreeService documentTreeService; + @MockitoBean private JwtTokenProvider jwtTokenProvider; @@ -71,7 +75,16 @@ 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, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); when(documentService.create(eq(userId), any())) .thenReturn(new DocumentService.CreateDocumentResult(response, true)); @@ -94,7 +107,16 @@ 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, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); when(documentService.create(eq(userId), any())) .thenReturn(new DocumentService.CreateDocumentResult(response, false)); @@ -118,7 +140,16 @@ void create_existingClientDocument_returns200() throws Exception { @Test void list_success_returns200() throws Exception { DocumentResponse response = new DocumentResponse( - documentId, "My Doc", null, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + documentId, + "My Doc", + null, + null, + null, + "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); @@ -142,7 +173,16 @@ 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, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); when(documentService.update(eq(userId), eq(documentId), any())).thenReturn(response); @@ -174,6 +214,8 @@ void list_trashed_success_returns200() throws Exception { documentId, "Trashed", null, + null, + null, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), @@ -192,7 +234,16 @@ void list_trashed_success_returns200() throws Exception { @Test void restore_success_returns200() throws Exception { DocumentResponse response = new DocumentResponse( - documentId, "Restored", null, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + documentId, + "Restored", + null, + null, + null, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + null); when(documentService.restore(eq(userId), eq(documentId))).thenReturn(response); diff --git a/api/src/test/java/com/nextdocs/api/document/controller/DocumentTreeControllerTest.java b/api/src/test/java/com/nextdocs/api/document/controller/DocumentTreeControllerTest.java new file mode 100644 index 0000000..46c61aa --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/controller/DocumentTreeControllerTest.java @@ -0,0 +1,186 @@ +package com.nextdocs.api.document.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.auth.repository.UserRepository; +import com.nextdocs.api.auth.security.JwtTokenProvider; +import com.nextdocs.api.auth.security.UserPrincipal; +import com.nextdocs.api.document.dto.request.DocumentMoveRequest; +import com.nextdocs.api.document.dto.response.DocumentTreeNodeResponse; +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; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(DocumentController.class) +@Import({ + com.nextdocs.api.auth.security.SecurityConfig.class, + com.nextdocs.api.common.cache.CaffeineCacheStore.class, + com.nextdocs.api.auth.security.ratelimit.InMemoryRateLimiter.class +}) +class DocumentTreeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DocumentTreeService documentTreeService; + + @MockitoBean + private DocumentService documentService; + + @MockitoBean + private JwtTokenProvider jwtTokenProvider; + + @MockitoBean + private UserRepository userRepository; + + private UserPrincipal principal; + private UUID userId; + + @BeforeEach + void setUp() { + User user = User.builder() + .email("alice@example.com") + .displayName("Alice") + .passwordHash("$2a$12$hash") + .build(); + userId = UUID.randomUUID(); + user.setId(userId); + principal = UserPrincipal.from(user); + } + + @Test + void getRootDocuments_returns200() throws Exception { + DocumentTreeNodeResponse node = new DocumentTreeNodeResponse( + UUID.randomUUID(), + "Root", + null, + "a0", + false, + DocumentAccessLevel.OWNER, + OffsetDateTime.now(), + OffsetDateTime.now()); + + Page page = new PageImpl<>(List.of(node)); + when(documentTreeService.getRootDocuments(eq(userId), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/documents/tree/root").with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].id").value(node.id().toString())) + .andExpect(jsonPath("$.data.content[0].orderKey").value("a0")) + .andExpect(jsonPath("$.data.content[0].hasChildren").value(false)); + } + + @Test + void getSharedDocuments_returns200() throws Exception { + DocumentTreeNodeResponse node = new DocumentTreeNodeResponse( + UUID.randomUUID(), + "Shared", + null, + "a0", + false, + DocumentAccessLevel.EDIT, + OffsetDateTime.now(), + OffsetDateTime.now()); + + Page page = new PageImpl<>(List.of(node)); + when(documentTreeService.getSharedDocuments(eq(userId), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/documents/tree/shared").with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].id").value(node.id().toString())) + .andExpect(jsonPath("$.data.content[0].orderKey").value("a0")) + .andExpect(jsonPath("$.data.content[0].hasChildren").value(false)); + } + + @Test + void getChildren_returns200() throws Exception { + UUID parentId = UUID.randomUUID(); + DocumentTreeNodeResponse child = new DocumentTreeNodeResponse( + UUID.randomUUID(), + "Child", + parentId, + "a0", + true, + DocumentAccessLevel.OWNER, + OffsetDateTime.now(), + OffsetDateTime.now()); + + Page page = new PageImpl<>(List.of(child)); + when(documentTreeService.getChildren(eq(userId), eq(parentId), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/documents/{id}/children", parentId).with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].parentId").value(parentId.toString())) + .andExpect(jsonPath("$.data.content[0].hasChildren").value(true)); + } + + @Test + void move_returns200() throws Exception { + UUID docId = UUID.randomUUID(); + DocumentTreeNodeResponse moved = new DocumentTreeNodeResponse( + docId, + "Moved", + null, + "a1", + false, + DocumentAccessLevel.OWNER, + OffsetDateTime.now(), + OffsetDateTime.now()); + + when(documentTreeService.move(eq(userId), eq(docId), any(DocumentMoveRequest.class))) + .thenReturn(moved); + + mockMvc.perform(post("/api/v1/documents/{id}/move", docId) + .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.id").value(docId.toString())); + } + + @Test + void endpoints_withoutAuthentication_return401() throws Exception { + mockMvc.perform(get("/api/v1/documents/tree/root")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/api/v1/documents/tree/shared")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/api/v1/documents/{id}/children", UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + mockMvc.perform(post("/api/v1/documents/{id}/move", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } +} 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..757b76a --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentTreeServiceTest.java @@ -0,0 +1,1157 @@ +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.DocumentTreeNodeResponse; +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; +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 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 getRootDocuments_returnsPrivateRootOrderedList() { + Document root1 = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Root 1") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document root2 = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Root 2") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + PageRequest pageable = PageRequest.of(0, 50); + Page queryPage = new PageImpl<>(List.of(new Object[] {root1, "a0"}, new Object[] {root2, "a1"})); + + when(documentRepository.findPrivateRootDocuments(userId, pageable)).thenReturn(queryPage); + when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); + + Page result = documentTreeService.getRootDocuments(userId, pageable); + + assertEquals(2, result.getContent().size()); + assertEquals("Root 1", result.getContent().get(0).title()); + assertEquals("a0", result.getContent().get(0).orderKey()); + assertEquals(DocumentAccessLevel.OWNER, result.getContent().get(0).effectiveAccessLevel()); + assertEquals("Root 2", result.getContent().get(1).title()); + assertEquals("a1", result.getContent().get(1).orderKey()); + } + + @Test + void getSharedDocuments_returnsSharedRootOrderedList() { + 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(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) + .thenReturn(List.of(new Object[] {sharedWithMe.getId(), "EDIT"})); + + Page result = documentTreeService.getSharedDocuments(userId, pageable); + + assertEquals(2, result.getContent().size()); + assertEquals("Shared with me", result.getContent().get(0).title()); + assertEquals("a0", result.getContent().get(0).orderKey()); + assertEquals(DocumentAccessLevel.EDIT, result.getContent().get(0).effectiveAccessLevel()); + assertEquals("Shared by me", result.getContent().get(1).title()); + assertEquals("a1", result.getContent().get(1).orderKey()); + assertEquals(DocumentAccessLevel.OWNER, result.getContent().get(1).effectiveAccessLevel()); + } + + @Test + void getChildren_returnsOrderedList() { + UUID parentId = UUID.randomUUID(); + Document parent = + Document.builder().id(parentId).user(user).title("Parent").build(); + + Document child1 = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Child1") + .parent(parent) + .siblingOrderKey("a0") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + Document child2 = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Child2") + .parent(parent) + .siblingOrderKey("a1") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + PageRequest pageable = PageRequest.of(0, 50); + Page childrenPage = new PageImpl<>(List.of(child1, child2)); + + when(permissionService.requireReadAccess(userId, parentId)).thenReturn(parent); + when(documentRepository.findAllByParent_IdAndDeletedAtIsNull(eq(parentId), any(Pageable.class))) + .thenReturn(childrenPage); + when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); + when(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) + .thenReturn(List.of()); + + Page children = documentTreeService.getChildren(userId, parentId, pageable); + + assertEquals(2, children.getContent().size()); + assertEquals("Child1", children.getContent().get(0).title()); + assertEquals("a0", children.getContent().get(0).orderKey()); + assertEquals("Child2", children.getContent().get(1).title()); + assertEquals("a1", children.getContent().get(1).orderKey()); + } + + @Test + void getChildren_withCustomSort_preservesSort() { + UUID parentId = UUID.randomUUID(); + Document parent = + Document.builder().id(parentId).user(user).title("Parent").build(); + Document child = Document.builder() + .id(UUID.randomUUID()) + .user(user) + .title("Child") + .parent(parent) + .siblingOrderKey("a0") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + PageRequest pageable = PageRequest.of( + 0, 10, org.springframework.data.domain.Sort.by("title").descending()); + Page childrenPage = new PageImpl<>(List.of(child)); + + when(permissionService.requireReadAccess(userId, parentId)).thenReturn(parent); + org.mockito.ArgumentCaptor pageableCaptor = org.mockito.ArgumentCaptor.forClass(Pageable.class); + when(documentRepository.findAllByParent_IdAndDeletedAtIsNull(eq(parentId), pageableCaptor.capture())) + .thenReturn(childrenPage); + when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); + when(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) + .thenReturn(List.of()); + + Page children = documentTreeService.getChildren(userId, parentId, pageable); + + assertEquals(1, children.getContent().size()); + assertEquals(pageable.getSort(), pageableCaptor.getValue().getSort()); + } + + @Test + void getChildren_noAccess_throwsNotFound() { + UUID parentId = UUID.randomUUID(); + + when(permissionService.requireReadAccess(userId, parentId)).thenThrow(new ApiException(ErrorCode.NOT_FOUND)); + + assertThrows( + ApiException.class, () -> documentTreeService.getChildren(userId, parentId, PageRequest.of(0, 50))); + } + + @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); + + DocumentTreeNodeResponse 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); + + DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + + assertNull(result.parentId()); + assertNotNull(result.orderKey()); + assertTrue(result.orderKey().compareTo("a0") > 0); + assertEquals(DocumentAccessLevel.VIEW, result.effectiveAccessLevel()); + + // 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); + + DocumentTreeNodeResponse 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); + + DocumentTreeNodeResponse 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); + + DocumentTreeNodeResponse 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.effectiveAccessLevel()); + + // 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); + + DocumentTreeNodeResponse 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); + + DocumentTreeNodeResponse 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); + + DocumentTreeNodeResponse 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); + + DocumentTreeNodeResponse 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); + + DocumentTreeNodeResponse 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); + + DocumentTreeNodeResponse 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); + + DocumentTreeNodeResponse 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); + + DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + + assertNotNull(result.orderKey()); + assertTrue(result.orderKey().compareTo("a0") > 0); + assertEquals(DocumentAccessLevel.VIEW, result.effectiveAccessLevel()); + } + + @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()); + } + + @Test + void getSharedDocuments_returnsSharedRootAndFloatedNestedDocuments() { + User otherOwner = User.builder().id(UUID.randomUUID()).build(); + Document parent = Document.builder() + .id(UUID.randomUUID()) + .user(otherOwner) + .title("Company Wiki") + .build(); + Document nestedFloated = Document.builder() + .id(UUID.randomUUID()) + .user(otherOwner) + .title("Design System") + .parent(parent) + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + PageRequest pageable = PageRequest.of(0, 50); + List rows = List.of(new Object[] {nestedFloated, "a0"}); + Page queryPage = new PageImpl<>(rows); + + when(documentRepository.findSharedRootDocuments(userId, pageable)).thenReturn(queryPage); + when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); + when(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) + .thenReturn(List.of(new Object[] {nestedFloated.getId(), "EDIT"})); + + Page result = documentTreeService.getSharedDocuments(userId, pageable); + + assertEquals(1, result.getContent().size()); + assertEquals("Design System", result.getContent().get(0).title()); + assertEquals(parent.getId(), result.getContent().get(0).parentId()); + assertEquals("a0", result.getContent().get(0).orderKey()); + assertEquals(DocumentAccessLevel.EDIT, result.getContent().get(0).effectiveAccessLevel()); + } + + @Test + void getChildren_batchQueriesWithDifferentDriverTypes_handlesCastingSafely() { + UUID parentId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Document childDoc = Document.builder() + .id(childId) + .user(user) + .title("Child Doc") + .siblingOrderKey("a0") + .createdAt(OffsetDateTime.now()) + .updatedAt(OffsetDateTime.now()) + .build(); + + when(permissionService.requireReadAccess(userId, parentId)).thenReturn(childDoc); + when(documentRepository.findAllByParent_IdAndDeletedAtIsNull(eq(parentId), any())) + .thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(childDoc))); + + // Simulate native query returning String docId and Integer/BigInteger count + when(documentRepository.countNonTrashedChildrenByParentIds(any())) + .thenReturn(List.of(new Object[] {childId.toString(), Integer.valueOf(3)})); + when(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) + .thenReturn(List.of(new Object[] {childId.toString(), "EDIT"})); + + var page = documentTreeService.getChildren( + userId, parentId, org.springframework.data.domain.PageRequest.of(0, 10)); + + assertEquals(1, page.getContent().size()); + DocumentTreeNodeResponse node = page.getContent().get(0); + assertEquals(childId, node.id()); + assertTrue(node.hasChildren()); + assertEquals(DocumentAccessLevel.EDIT, node.effectiveAccessLevel()); + } +} From e6ae04d7694cef07265e0ddf902116dd02356e23 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Sun, 16 Aug 2026 14:22:15 +0530 Subject: [PATCH 08/20] realtime: Migrate test suite to ESM runner. Following the migration to NodeNext module resolution in cf2d001abf1a7f0de8ef723245093f69de5ce43e, the test suite remained configured for CommonJS execution, causing Jest to fail when importing ECMAScript modules with explicit file extensions. We update jest.config.js and tsconfig.test.json to inherit the NodeNext module settings and configure the test script with NODE_OPTIONS=--experimental-vm-modules. Unit and integration tests are updated to use jest.unstable_mockModule and top-level dynamic imports for mocked modules, ensuring all mocks resolve correctly in a native ESM runtime. Lifecycle integration tests resolve the tsx CLI binary dynamically from node_modules rather than depending on hardcoded path structures. --- realtime/jest.config.js | 4 +- realtime/package.json | 4 +- realtime/tests/integration/lifecycle.test.ts | 6 + realtime/tests/unit/config.test.ts | 34 ++--- realtime/tests/unit/logger.test.ts | 18 +-- realtime/tests/unit/server.test.ts | 138 +++++++++---------- realtime/tests/unit/yjs-utils.test.ts | 15 +- realtime/tsconfig.test.json | 1 - 8 files changed, 112 insertions(+), 108 deletions(-) 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": "." }, From ff19fbdf4aa170653f0db61961e970087c545d26 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Tue, 18 Aug 2026 11:35:48 +0530 Subject: [PATCH 09/20] api/document: Consolidate document listing and tree queries. Previously, document retrieval was fragmented across distinct endpoints for root trees, shared documents, child nodes, and flat lists, requiring callers to handle divergent response schemas and multiple roundtrips. We introduce DocumentListQueryHelper to centralize all document queries under GET /api/v1/documents with parentId, scope, and trashed query parameters. DocumentResponse is enriched with hasChildren, hasCollaborators, and effective accessLevel fields computed in single-roundtrip batch database queries, eliminating N+1 lookups on the client. DocumentTreeNodeResponse and redundant tree endpoints are removed in favor of this single unified contract. GlobalExceptionHandler is updated to map NoResourceFoundException to standard 404 responses and suppress redundant default message echoing. --- .../exception/GlobalExceptionHandler.java | 15 +- .../controller/DocumentController.java | 84 +--- .../controller/DocumentSharingController.java | 23 - .../dto/response/DocumentResponse.java | 10 + .../response/DocumentTreeNodeResponse.java | 25 - .../DocumentCollaboratorRepository.java | 7 + .../repository/DocumentRepository.java | 16 +- .../service/DocumentListQueryHelper.java | 449 ++++++++++++++++++ .../api/document/service/DocumentService.java | 47 +- .../service/DocumentSharingService.java | 39 -- .../document/service/DocumentTreeService.java | 161 +------ .../controller/DocumentControllerTest.java | 205 +++++++- .../DocumentSharingControllerTest.java | 27 -- .../DocumentTreeControllerTest.java | 186 -------- .../service/DocumentListQueryHelperTest.java | 227 +++++++++ .../document/service/DocumentServiceTest.java | 48 +- .../service/DocumentSharingServiceTest.java | 49 -- .../service/DocumentTreeServiceTest.java | 267 +---------- 18 files changed, 1005 insertions(+), 880 deletions(-) delete mode 100644 api/src/main/java/com/nextdocs/api/document/dto/response/DocumentTreeNodeResponse.java create mode 100644 api/src/main/java/com/nextdocs/api/document/service/DocumentListQueryHelper.java delete mode 100644 api/src/test/java/com/nextdocs/api/document/controller/DocumentTreeControllerTest.java create mode 100644 api/src/test/java/com/nextdocs/api/document/service/DocumentListQueryHelperTest.java 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 6a4001b..755d699 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 @@ -7,7 +7,6 @@ import com.nextdocs.api.document.dto.request.DocumentMoveRequest; import com.nextdocs.api.document.dto.request.DocumentUpdateRequest; import com.nextdocs.api.document.dto.response.DocumentResponse; -import com.nextdocs.api.document.dto.response.DocumentTreeNodeResponse; import com.nextdocs.api.document.service.DocumentService; import com.nextdocs.api.document.service.DocumentTreeService; import io.swagger.v3.oas.annotations.Operation; @@ -62,15 +61,18 @@ public ResponseEntity> create( } @Operation( - summary = "List current user's documents", - description = "Returns a paged list of documents. By default only active documents owned by " - + "the authenticated user are returned (ordered by last update). Use trashed=true to list " - + "documents in trash (ordered by time moved to trash): those the user owns plus shared " - + "documents on which they have at least EDIT access.", + 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") @@ -78,10 +80,11 @@ 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))); } @@ -209,69 +212,6 @@ public ResponseEntity> restore( return ResponseEntity.ok(ApiResponse.ok(response, "Document restored.")); } - @Operation( - summary = "List root-level documents for the sidebar (paginated)", - description = "Returns root-level (no parent) non-trashed documents owned by " - + "the authenticated user, ordered by order_key. Paginated.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "Root documents returned"), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "Authentication required") - }) - @GetMapping("/tree/root") - public ResponseEntity>> getRootDocuments( - @AuthenticationPrincipal UserPrincipal principal, @PageableDefault(size = 50) Pageable pageable) { - return ResponseEntity.ok( - ApiResponse.ok(PagedResponse.from(documentTreeService.getRootDocuments(principal.getId(), pageable)))); - } - - @Operation( - summary = "List shared documents for the sidebar (paginated)", - description = "Returns root-level documents in the authenticated user's Shared section " - + "(both owner-shared and shared-with-me), ordered by personal order_key. Paginated.", - 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("/tree/shared") - public ResponseEntity>> getSharedDocuments( - @AuthenticationPrincipal UserPrincipal principal, @PageableDefault(size = 50) Pageable pageable) { - return ResponseEntity.ok(ApiResponse.ok( - PagedResponse.from(documentTreeService.getSharedDocuments(principal.getId(), pageable)))); - } - - @Operation( - summary = "List direct children of a document (paginated)", - description = "Returns the direct non-trashed children of the given document, " - + "ordered by order_key. Paginated. The authenticated user must be the owner " - + "or have at least VIEW access.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "Children returned"), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "Authentication required"), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "Parent document not found") - }) - @GetMapping("/{id}/children") - public ResponseEntity>> getChildren( - @AuthenticationPrincipal UserPrincipal principal, - @PathVariable UUID id, - @PageableDefault(size = 50) Pageable pageable) { - return ResponseEntity.ok( - ApiResponse.ok(PagedResponse.from(documentTreeService.getChildren(principal.getId(), id, pageable)))); - } - @Operation( summary = "Move a document to a new parent / position", description = @@ -298,7 +238,7 @@ public ResponseEntity>> getC description = "Document or sibling not found") }) @PostMapping("/{id}/move") - public ResponseEntity> move( + public ResponseEntity> move( @AuthenticationPrincipal UserPrincipal principal, @PathVariable UUID id, @Valid @RequestBody DocumentMoveRequest 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/response/DocumentResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentResponse.java index d4de09a..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; @@ -17,6 +18,15 @@ public record DocumentResponse( @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/dto/response/DocumentTreeNodeResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentTreeNodeResponse.java deleted file mode 100644 index 10cc553..0000000 --- a/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentTreeNodeResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -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; - -@Schema(description = "Sidebar tree node for a document") -public record DocumentTreeNodeResponse( - @Schema(description = "Document ID") UUID id, - @Schema(description = "Document title") String title, - - @Schema(description = "Parent document ID, null for root-level") - UUID parentId, - - @Schema(description = "Fractional ordering key") String orderKey, - - @Schema(description = "Whether this node has children (for lazy-load chevron)") - boolean hasChildren, - - @Schema(description = "Effective access level of requesting user") - DocumentAccessLevel effectiveAccessLevel, - - @Schema(description = "Creation timestamp") OffsetDateTime createdAt, - @Schema(description = "Last update timestamp") 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 a1decf0..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,10 +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 d30a24b..d63b92a 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 @@ -37,13 +37,21 @@ public interface DocumentRepository extends JpaRepository { // All direct children for a collection of parents, including trashed List findAllByParent_IdIn(Collection parentIds); - // Private root documents owned by userId with personal navigation order + // 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 " @@ -107,6 +115,12 @@ public interface DocumentRepository extends JpaRepository { @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. 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..dac5c55 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentListQueryHelper.java @@ -0,0 +1,449 @@ +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.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); + Map rootOrderKeys = fetchRootOrderKeys(userId, docs); + + 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); + 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, + 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 fetchRootOrderKeys(UUID userId, List docs) { + List rootIds = docs.stream() + .filter(document -> document.getParent() == null) + .map(Document::getId) + .toList(); + if (rootIds.isEmpty()) { + return Map.of(); + } + Map orderKeys = new HashMap<>(); + for (Object[] row : userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(userId, rootIds)) { + orderKeys.put((UUID) row[0], (String) row[1]); + } + return orderKeys; + } +} 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 a5f869c..2b2444e 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 @@ -31,9 +31,7 @@ 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; @@ -55,6 +53,7 @@ public record CreateDocumentResult(DocumentResponse document, boolean created) { private final UserRepository userRepository; private final DocumentProperties documentProperties; private final PermissionService permissionService; + private final DocumentListQueryHelper queryHelper; @Autowired @Lazy @@ -152,27 +151,8 @@ public CreateDocumentResult insertDocument(UUID userId, DocumentCreateRequest re } @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.findAccessibleTrashedDocuments(userId, effectivePageable) - : documentRepository.findAllByUser_IdAndDeletedAtIsNull(userId, effectivePageable); - - Map rootOrderKeys = trashedOnly ? Map.of() : fetchRootOrderKeys(userId, page.getContent()); - - return page.map(document -> toResponse(document, false, rootOrderKeys)); + public Page list(UUID userId, String parentId, String scope, Boolean trashed, Pageable pageable) { + return queryHelper.list(userId, parentId, scope, trashed, pageable); } @Transactional(readOnly = true) @@ -456,10 +436,20 @@ private DocumentResponse toResponse( ? userDocumentOrderRepository .findOrderKeyByUserIdAndDocumentId(callerUserId, document.getId()) .orElse(null) - : userDocumentOrderRepository - .findOrderKeyByUserIdAndDocumentId( - document.getUser().getId(), document.getId()) - .orElse(null); + : 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(), @@ -471,6 +461,9 @@ private DocumentResponse toResponse( : null, document.getParent() != null ? document.getParent().getId() : null, orderKey, + hasChildren, + hasCollaborators, + accessLevel, document.getCreatedBy(), document.getCreatedAt(), document.getUpdatedAt(), 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 9fd2a10..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,7 +9,6 @@ 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; @@ -20,16 +19,12 @@ import com.nextdocs.api.document.repository.DocumentRepository; import com.nextdocs.api.document.repository.UserDocumentOrderRepository; import com.nextdocs.api.document.util.FractionalIndex; -import java.util.HashMap; import java.util.List; -import java.util.Map; 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.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -222,14 +217,6 @@ public SharingSettingsResponse updateSharingSettings( return new SharingSettingsResponse(doc.getGeneralAccessMode(), doc.getLinkAccessLevel(), hasActiveLink); } - @Transactional(readOnly = true) - public Page listSharedWithMe(UUID userId, Pageable pageable) { - Page page = documentRepository.findSharedWithUserId(userId, pageable); - List ids = page.getContent().stream().map(Document::getId).toList(); - Map navOrderKeys = fetchUserNavOrderKeys(userId, ids); - return page.map(doc -> toDocumentSummaryResponse(doc, navOrderKeys.get(doc.getId()))); - } - @Transactional(readOnly = true) public DocumentAccessResponse getMyAccess(UUID userId, UUID documentId) { Document active = @@ -310,30 +297,4 @@ private static DocumentAccessLevel normalizeLinkAccess(DocumentAccessLevel acces } return accessLevel; } - - private Map fetchUserNavOrderKeys(UUID userId, List documentIds) { - if (documentIds.isEmpty()) { - return Map.of(); - } - Map orderKeys = new HashMap<>(); - for (Object[] row : userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(userId, documentIds)) { - orderKeys.put((UUID) row[0], (String) row[1]); - } - return orderKeys; - } - - private DocumentResponse toDocumentSummaryResponse(Document document, String navOrderKey) { - String orderKey = document.getParent() == null ? navOrderKey : document.getSiblingOrderKey(); - return new DocumentResponse( - document.getId(), - document.getTitle(), - null, - document.getParent() != null ? document.getParent().getId() : null, - orderKey, - 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 index 6bb618d..7e7f321 100644 --- a/api/src/main/java/com/nextdocs/api/document/service/DocumentTreeService.java +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentTreeService.java @@ -5,7 +5,7 @@ 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.DocumentTreeNodeResponse; +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; @@ -14,21 +14,13 @@ import com.nextdocs.api.document.repository.DocumentRepository; import com.nextdocs.api.document.repository.UserDocumentOrderRepository; import com.nextdocs.api.document.util.FractionalIndex; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.UUID; -import java.util.stream.Collectors; 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.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.Service; import org.springframework.transaction.annotation.Transactional; @@ -53,110 +45,7 @@ public class DocumentTreeService { @Lazy private DocumentTreeService selfProxy; - @Transactional(readOnly = true) - public Page getRootDocuments(UUID userId, Pageable pageable) { - Page rows = documentRepository.findPrivateRootDocuments(userId, pageable); - List docs = - rows.getContent().stream().map(r -> (Document) r[0]).toList(); - if (docs.isEmpty()) { - return Page.empty(pageable); - } - - List docIds = docs.stream().map(Document::getId).toList(); - Map childCounts = fetchChildCounts(docIds); - - List nodes = 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 DocumentTreeNodeResponse( - doc.getId(), - doc.getTitle(), - null, - orderKey, - hasChildren, - DocumentAccessLevel.OWNER, - doc.getCreatedAt(), - doc.getUpdatedAt()); - }) - .toList(); - - return new PageImpl<>(nodes, pageable, rows.getTotalElements()); - } - - @Transactional(readOnly = true) - public Page getSharedDocuments(UUID userId, Pageable pageable) { - Page rows = documentRepository.findSharedRootDocuments(userId, pageable); - List docs = - rows.getContent().stream().map(r -> (Document) r[0]).toList(); - if (docs.isEmpty()) { - return Page.empty(pageable); - } - - List docIds = docs.stream().map(Document::getId).toList(); - Map childCounts = fetchChildCounts(docIds); - Map accessLevels = fetchAccessLevels(userId, docIds); - - List nodes = rows.getContent().stream() - .map(r -> { - Document doc = (Document) r[0]; - String orderKey = (String) r[1]; - boolean hasChildren = childCounts.getOrDefault(doc.getId(), 0L) > 0; - DocumentAccessLevel access = doc.getUser().getId().equals(userId) - ? DocumentAccessLevel.OWNER - : accessLevels.getOrDefault(doc.getId(), null); - UUID parentId = doc.getParent() != null ? doc.getParent().getId() : null; - return new DocumentTreeNodeResponse( - doc.getId(), - doc.getTitle(), - parentId, - orderKey, - hasChildren, - access, - doc.getCreatedAt(), - doc.getUpdatedAt()); - }) - .toList(); - - return new PageImpl<>(nodes, pageable, rows.getTotalElements()); - } - - @Transactional(readOnly = true) - public Page getChildren(UUID userId, UUID parentId, Pageable pageable) { - 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 ids = page.getContent().stream().map(Document::getId).toList(); - if (ids.isEmpty()) { - return Page.empty(pageable); - } - - Map childCounts = fetchChildCounts(ids); - Map accessLevels = fetchAccessLevels(userId, ids); - - return page.map(doc -> new DocumentTreeNodeResponse( - doc.getId(), - doc.getTitle(), - parentId, - doc.getSiblingOrderKey(), - childCounts.getOrDefault(doc.getId(), 0L) > 0, - accessLevels.getOrDefault(doc.getId(), null), - doc.getCreatedAt(), - doc.getUpdatedAt())); - } - - public DocumentTreeNodeResponse move(UUID userId, UUID documentId, DocumentMoveRequest request) { + public DocumentResponse move(UUID userId, UUID documentId, DocumentMoveRequest request) { int attempt = 0; while (true) { try { @@ -179,7 +68,7 @@ public DocumentTreeNodeResponse move(UUID userId, UUID documentId, DocumentMoveR } @Transactional - public DocumentTreeNodeResponse moveAndPersist( + public DocumentResponse moveAndPersist( UUID userId, UUID documentId, DocumentMoveRequest request, boolean rebuildFirst) { Document doc; if (request.newParentId() != null) { @@ -250,16 +139,22 @@ public DocumentTreeNodeResponse moveAndPersist( } boolean hasChildren = documentRepository.existsNonTrashedChildrenByParentId(documentId); + boolean hasCollaborators = collaboratorRepository.existsByDocument_Id(documentId); DocumentAccessLevel access = permissionService.resolveAccess(userId, documentId); - return new DocumentTreeNodeResponse( + return new DocumentResponse( saved.getId(), saved.getTitle(), + null, newParent.getId(), newSiblingOrderKey, hasChildren, + hasCollaborators, access, + saved.getCreatedBy(), saved.getCreatedAt(), - saved.getUpdatedAt()); + saved.getUpdatedAt(), + null, + null); } else { // Root-level move or personal Shared section reordering Document targetDoc = documentRepository @@ -363,21 +258,27 @@ public DocumentTreeNodeResponse moveAndPersist( } 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 DocumentTreeNodeResponse( + return new DocumentResponse( doc.getId(), doc.getTitle(), + null, resultParentId, newUserOrderKey, hasChildren, + hasCollaborators, access, + doc.getCreatedBy(), doc.getCreatedAt(), - doc.getUpdatedAt()); + doc.getUpdatedAt(), + null, + null); } } @@ -534,28 +435,4 @@ private void validateNoCycle(UUID documentId, UUID newParentId) { depth++; } } - - private Map fetchChildCounts(List docIds) { - 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, List docIds) { - 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; - } } 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 a08d4ce..d7f4e8e 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,7 +14,9 @@ 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.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; @@ -80,6 +82,9 @@ void create_success_returns201() throws Exception { "AQID", null, null, + false, + false, + DocumentAccessLevel.OWNER, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), @@ -112,6 +117,9 @@ void create_existingClientDocument_returns200() throws Exception { "AQID", null, null, + false, + false, + DocumentAccessLevel.OWNER, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), @@ -138,13 +146,16 @@ 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, null, - null, + "a0", + false, + false, + DocumentAccessLevel.OWNER, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), @@ -152,12 +163,137 @@ void list_success_returns200() throws Exception { 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 @@ -178,6 +314,9 @@ void update_success_returns200() throws Exception { "AQID", null, null, + false, + false, + DocumentAccessLevel.OWNER, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), @@ -208,49 +347,75 @@ 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( + void move_success_returns200() throws Exception { + DocumentResponse moved = new DocumentResponse( documentId, - "Restored", - null, + "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 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 e741e5c..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,29 +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, - null, - 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/controller/DocumentTreeControllerTest.java b/api/src/test/java/com/nextdocs/api/document/controller/DocumentTreeControllerTest.java deleted file mode 100644 index 46c61aa..0000000 --- a/api/src/test/java/com/nextdocs/api/document/controller/DocumentTreeControllerTest.java +++ /dev/null @@ -1,186 +0,0 @@ -package com.nextdocs.api.document.controller; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import com.nextdocs.api.auth.entity.User; -import com.nextdocs.api.auth.repository.UserRepository; -import com.nextdocs.api.auth.security.JwtTokenProvider; -import com.nextdocs.api.auth.security.UserPrincipal; -import com.nextdocs.api.document.dto.request.DocumentMoveRequest; -import com.nextdocs.api.document.dto.response.DocumentTreeNodeResponse; -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; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -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.Pageable; -import org.springframework.http.MediaType; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@WebMvcTest(DocumentController.class) -@Import({ - com.nextdocs.api.auth.security.SecurityConfig.class, - com.nextdocs.api.common.cache.CaffeineCacheStore.class, - com.nextdocs.api.auth.security.ratelimit.InMemoryRateLimiter.class -}) -class DocumentTreeControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private DocumentTreeService documentTreeService; - - @MockitoBean - private DocumentService documentService; - - @MockitoBean - private JwtTokenProvider jwtTokenProvider; - - @MockitoBean - private UserRepository userRepository; - - private UserPrincipal principal; - private UUID userId; - - @BeforeEach - void setUp() { - User user = User.builder() - .email("alice@example.com") - .displayName("Alice") - .passwordHash("$2a$12$hash") - .build(); - userId = UUID.randomUUID(); - user.setId(userId); - principal = UserPrincipal.from(user); - } - - @Test - void getRootDocuments_returns200() throws Exception { - DocumentTreeNodeResponse node = new DocumentTreeNodeResponse( - UUID.randomUUID(), - "Root", - null, - "a0", - false, - DocumentAccessLevel.OWNER, - OffsetDateTime.now(), - OffsetDateTime.now()); - - Page page = new PageImpl<>(List.of(node)); - when(documentTreeService.getRootDocuments(eq(userId), any(Pageable.class))) - .thenReturn(page); - - mockMvc.perform(get("/api/v1/documents/tree/root").with(user(principal))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.content[0].id").value(node.id().toString())) - .andExpect(jsonPath("$.data.content[0].orderKey").value("a0")) - .andExpect(jsonPath("$.data.content[0].hasChildren").value(false)); - } - - @Test - void getSharedDocuments_returns200() throws Exception { - DocumentTreeNodeResponse node = new DocumentTreeNodeResponse( - UUID.randomUUID(), - "Shared", - null, - "a0", - false, - DocumentAccessLevel.EDIT, - OffsetDateTime.now(), - OffsetDateTime.now()); - - Page page = new PageImpl<>(List.of(node)); - when(documentTreeService.getSharedDocuments(eq(userId), any(Pageable.class))) - .thenReturn(page); - - mockMvc.perform(get("/api/v1/documents/tree/shared").with(user(principal))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.content[0].id").value(node.id().toString())) - .andExpect(jsonPath("$.data.content[0].orderKey").value("a0")) - .andExpect(jsonPath("$.data.content[0].hasChildren").value(false)); - } - - @Test - void getChildren_returns200() throws Exception { - UUID parentId = UUID.randomUUID(); - DocumentTreeNodeResponse child = new DocumentTreeNodeResponse( - UUID.randomUUID(), - "Child", - parentId, - "a0", - true, - DocumentAccessLevel.OWNER, - OffsetDateTime.now(), - OffsetDateTime.now()); - - Page page = new PageImpl<>(List.of(child)); - when(documentTreeService.getChildren(eq(userId), eq(parentId), any(Pageable.class))) - .thenReturn(page); - - mockMvc.perform(get("/api/v1/documents/{id}/children", parentId).with(user(principal))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.content[0].parentId").value(parentId.toString())) - .andExpect(jsonPath("$.data.content[0].hasChildren").value(true)); - } - - @Test - void move_returns200() throws Exception { - UUID docId = UUID.randomUUID(); - DocumentTreeNodeResponse moved = new DocumentTreeNodeResponse( - docId, - "Moved", - null, - "a1", - false, - DocumentAccessLevel.OWNER, - OffsetDateTime.now(), - OffsetDateTime.now()); - - when(documentTreeService.move(eq(userId), eq(docId), any(DocumentMoveRequest.class))) - .thenReturn(moved); - - mockMvc.perform(post("/api/v1/documents/{id}/move", docId) - .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.id").value(docId.toString())); - } - - @Test - void endpoints_withoutAuthentication_return401() throws Exception { - mockMvc.perform(get("/api/v1/documents/tree/root")).andExpect(status().isUnauthorized()); - mockMvc.perform(get("/api/v1/documents/tree/shared")).andExpect(status().isUnauthorized()); - mockMvc.perform(get("/api/v1/documents/{id}/children", UUID.randomUUID())) - .andExpect(status().isUnauthorized()); - mockMvc.perform(post("/api/v1/documents/{id}/move", UUID.randomUUID()) - .contentType(MediaType.APPLICATION_JSON) - .content("{}")) - .andExpect(status().isUnauthorized()); - } -} 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..30d3670 --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentListQueryHelperTest.java @@ -0,0 +1,227 @@ +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_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 50ef373..c7bdb6d 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 @@ -8,6 +8,7 @@ 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; @@ -45,7 +46,6 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; @ExtendWith(MockitoExtension.class) class DocumentServiceTest { @@ -65,6 +65,9 @@ class DocumentServiceTest { @Mock private PermissionService permissionService; + @Mock + private DocumentListQueryHelper queryHelper; + private DocumentProperties documentProperties; private DocumentService documentService; @@ -79,7 +82,8 @@ void setUp() { userDocumentOrderRepository, userRepository, documentProperties, - permissionService); + permissionService, + queryHelper); } @Test @@ -296,7 +300,7 @@ void update_allowsEditWhenGeneralAccessIsEdit() { documentService.update(requesterId, documentId, new DocumentUpdateRequest("Updated title", null, null)); assertEquals("Updated title", response.title()); - verify(permissionService).resolveAccess(requesterId, documentId); + verify(permissionService, atLeastOnce()).resolveAccess(requesterId, documentId); } @Test @@ -355,20 +359,14 @@ void list_usesBatchOrderKeyLookupForRootDocuments() { .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) .build(); - Page page = new PageImpl<>(List.of(root1, root2, child)); - when(documentRepository.findAllByUser_IdAndDeletedAtIsNull(eq(userId), any(Pageable.class))) + Page page = new PageImpl<>(List.of()); + when(queryHelper.list(eq(userId), eq(null), eq("all"), eq(false), any())) .thenReturn(page); - when(userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds( - eq(userId), eq(List.of(root1.getId(), root2.getId())))) - .thenReturn(List.of(new Object[] {root1.getId(), "a1"}, new Object[] {root2.getId(), "a2"})); - Page result = documentService.list(userId, null, false); + Page result = documentService.list(userId, null, "all", false, null); - assertEquals(3, result.getContent().size()); - assertEquals("a1", result.getContent().get(0).orderKey()); - assertEquals("a2", result.getContent().get(1).orderKey()); - assertEquals("b5", result.getContent().get(2).orderKey()); - verify(userDocumentOrderRepository, never()).findOrderKeyByUserIdAndDocumentId(any(), any()); + assertEquals(0, result.getContent().size()); + verify(queryHelper).list(eq(userId), eq(null), eq("all"), eq(false), any()); } @Test @@ -1232,10 +1230,24 @@ void list_trashedOnly_returnsAccessibleTrashedDocuments() { .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) .build(); - when(documentRepository.findAccessibleTrashedDocuments(eq(userId), any(Pageable.class))) - .thenReturn(new PageImpl<>(List.of(trashed))); - - Page result = documentService.list(userId, null, true); + 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()); 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 166306d..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,7 +5,6 @@ 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.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -15,7 +14,6 @@ import com.nextdocs.api.document.dto.request.CollaboratorUpsertRequest; 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.entity.Document; import com.nextdocs.api.document.entity.DocumentAccessLevel; import com.nextdocs.api.document.entity.DocumentCollaborator; @@ -37,10 +35,6 @@ 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; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; @ExtendWith(MockitoExtension.class) class DocumentSharingServiceTest { @@ -378,49 +372,6 @@ void upsertCollaborator_createsUserDocumentOrderForNestedDocument() { assertTrue(orderCaptor.getValue().getOrderKey().compareTo("a5") < 0); } - @Test - void listSharedWithMe_returnsUserNavOrderKeyForRootDocuments() { - UUID userId = UUID.randomUUID(); - User owner = User.builder().id(UUID.randomUUID()).build(); - - Document rootDoc = Document.builder() - .id(UUID.randomUUID()) - .user(owner) - .title("Shared root") - .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) - .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) - .build(); - - Document parent = Document.builder() - .id(UUID.randomUUID()) - .user(owner) - .title("Parent") - .build(); - Document nestedDoc = Document.builder() - .id(UUID.randomUUID()) - .user(owner) - .title("Shared nested") - .parent(parent) - .siblingOrderKey("c0") - .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) - .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) - .build(); - - Pageable pageable = PageRequest.of(0, 50); - Page page = new PageImpl<>(List.of(rootDoc, nestedDoc)); - - when(documentRepository.findSharedWithUserId(userId, pageable)).thenReturn(page); - when(userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(eq(userId), any())) - .thenReturn(List.of(new Object[] {rootDoc.getId(), "a0"})); - - Page result = sharingService.listSharedWithMe(userId, pageable); - - assertEquals("a0", result.getContent().get(0).orderKey()); - assertNull(result.getContent().get(0).parentId()); - assertEquals("c0", result.getContent().get(1).orderKey()); - assertEquals(parent.getId(), result.getContent().get(1).parentId()); - } - 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 index 757b76a..32829fa 100644 --- a/api/src/test/java/com/nextdocs/api/document/service/DocumentTreeServiceTest.java +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentTreeServiceTest.java @@ -15,7 +15,7 @@ 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.DocumentTreeNodeResponse; +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; @@ -34,10 +34,6 @@ 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; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; @ExtendWith(MockitoExtension.class) class DocumentTreeServiceTest { @@ -78,167 +74,6 @@ void setUp() { permissionService); } - @Test - void getRootDocuments_returnsPrivateRootOrderedList() { - Document root1 = Document.builder() - .id(UUID.randomUUID()) - .user(user) - .title("Root 1") - .createdAt(OffsetDateTime.now()) - .updatedAt(OffsetDateTime.now()) - .build(); - - Document root2 = Document.builder() - .id(UUID.randomUUID()) - .user(user) - .title("Root 2") - .createdAt(OffsetDateTime.now()) - .updatedAt(OffsetDateTime.now()) - .build(); - - PageRequest pageable = PageRequest.of(0, 50); - Page queryPage = new PageImpl<>(List.of(new Object[] {root1, "a0"}, new Object[] {root2, "a1"})); - - when(documentRepository.findPrivateRootDocuments(userId, pageable)).thenReturn(queryPage); - when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); - - Page result = documentTreeService.getRootDocuments(userId, pageable); - - assertEquals(2, result.getContent().size()); - assertEquals("Root 1", result.getContent().get(0).title()); - assertEquals("a0", result.getContent().get(0).orderKey()); - assertEquals(DocumentAccessLevel.OWNER, result.getContent().get(0).effectiveAccessLevel()); - assertEquals("Root 2", result.getContent().get(1).title()); - assertEquals("a1", result.getContent().get(1).orderKey()); - } - - @Test - void getSharedDocuments_returnsSharedRootOrderedList() { - 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(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) - .thenReturn(List.of(new Object[] {sharedWithMe.getId(), "EDIT"})); - - Page result = documentTreeService.getSharedDocuments(userId, pageable); - - assertEquals(2, result.getContent().size()); - assertEquals("Shared with me", result.getContent().get(0).title()); - assertEquals("a0", result.getContent().get(0).orderKey()); - assertEquals(DocumentAccessLevel.EDIT, result.getContent().get(0).effectiveAccessLevel()); - assertEquals("Shared by me", result.getContent().get(1).title()); - assertEquals("a1", result.getContent().get(1).orderKey()); - assertEquals(DocumentAccessLevel.OWNER, result.getContent().get(1).effectiveAccessLevel()); - } - - @Test - void getChildren_returnsOrderedList() { - UUID parentId = UUID.randomUUID(); - Document parent = - Document.builder().id(parentId).user(user).title("Parent").build(); - - Document child1 = Document.builder() - .id(UUID.randomUUID()) - .user(user) - .title("Child1") - .parent(parent) - .siblingOrderKey("a0") - .createdAt(OffsetDateTime.now()) - .updatedAt(OffsetDateTime.now()) - .build(); - - Document child2 = Document.builder() - .id(UUID.randomUUID()) - .user(user) - .title("Child2") - .parent(parent) - .siblingOrderKey("a1") - .createdAt(OffsetDateTime.now()) - .updatedAt(OffsetDateTime.now()) - .build(); - - PageRequest pageable = PageRequest.of(0, 50); - Page childrenPage = new PageImpl<>(List.of(child1, child2)); - - when(permissionService.requireReadAccess(userId, parentId)).thenReturn(parent); - when(documentRepository.findAllByParent_IdAndDeletedAtIsNull(eq(parentId), any(Pageable.class))) - .thenReturn(childrenPage); - when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); - when(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) - .thenReturn(List.of()); - - Page children = documentTreeService.getChildren(userId, parentId, pageable); - - assertEquals(2, children.getContent().size()); - assertEquals("Child1", children.getContent().get(0).title()); - assertEquals("a0", children.getContent().get(0).orderKey()); - assertEquals("Child2", children.getContent().get(1).title()); - assertEquals("a1", children.getContent().get(1).orderKey()); - } - - @Test - void getChildren_withCustomSort_preservesSort() { - UUID parentId = UUID.randomUUID(); - Document parent = - Document.builder().id(parentId).user(user).title("Parent").build(); - Document child = Document.builder() - .id(UUID.randomUUID()) - .user(user) - .title("Child") - .parent(parent) - .siblingOrderKey("a0") - .createdAt(OffsetDateTime.now()) - .updatedAt(OffsetDateTime.now()) - .build(); - - PageRequest pageable = PageRequest.of( - 0, 10, org.springframework.data.domain.Sort.by("title").descending()); - Page childrenPage = new PageImpl<>(List.of(child)); - - when(permissionService.requireReadAccess(userId, parentId)).thenReturn(parent); - org.mockito.ArgumentCaptor pageableCaptor = org.mockito.ArgumentCaptor.forClass(Pageable.class); - when(documentRepository.findAllByParent_IdAndDeletedAtIsNull(eq(parentId), pageableCaptor.capture())) - .thenReturn(childrenPage); - when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); - when(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) - .thenReturn(List.of()); - - Page children = documentTreeService.getChildren(userId, parentId, pageable); - - assertEquals(1, children.getContent().size()); - assertEquals(pageable.getSort(), pageableCaptor.getValue().getSort()); - } - - @Test - void getChildren_noAccess_throwsNotFound() { - UUID parentId = UUID.randomUUID(); - - when(permissionService.requireReadAccess(userId, parentId)).thenThrow(new ApiException(ErrorCode.NOT_FOUND)); - - assertThrows( - ApiException.class, () -> documentTreeService.getChildren(userId, parentId, PageRequest.of(0, 50))); - } - @Test void move_reparent_deletesOwnerUserDocumentOrder_andPreservesCollaboratorOrders() { UUID docId = UUID.randomUUID(); @@ -277,7 +112,7 @@ void move_reparent_deletesOwnerUserDocumentOrder_andPreservesCollaboratorOrders( when(documentRepository.saveAndFlush(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertNotNull(result.orderKey()); assertTrue(result.orderKey().compareTo("a0") > 0); @@ -325,12 +160,12 @@ void move_reorderSharedNavigationByCollaborator_updatesOnlyCallerUserDocumentOrd .thenAnswer(invocation -> invocation.getArgument(0)); when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertNull(result.parentId()); assertNotNull(result.orderKey()); assertTrue(result.orderKey().compareTo("a0") > 0); - assertEquals(DocumentAccessLevel.VIEW, result.effectiveAccessLevel()); + assertEquals(DocumentAccessLevel.VIEW, result.accessLevel()); // Verify ONLY userDocumentOrder was saved, and Document was NOT modified/saved! verify(userDocumentOrderRepository).saveAndFlush(any(UserDocumentOrder.class)); @@ -378,7 +213,7 @@ void move_collaboratorReordersFloatedNestedDocument_withoutReparenting() { when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.EDIT); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertEquals(parentId, result.parentId()); assertNotNull(result.orderKey()); @@ -430,7 +265,7 @@ void move_collaboratorReorderWithFloatedSibling_lazilyCreatesSiblingOrder() { when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertNotNull(result.orderKey()); // One row for the lazily-created sibling order, one for the moved doc. @@ -478,14 +313,14 @@ void move_collaboratorReordersSharedToMeDocBetweenOwnerSharedDocs_updatesOnlyCal when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + 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.effectiveAccessLevel()); + 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); @@ -666,7 +501,7 @@ void move_concurrentCollision_reindexesAndRetries() { .thenAnswer(invocation -> invocation.getArgument(0)); when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); verify(documentRepository, times(2)).saveAndFlush(any(Document.class)); assertNotNull(result.orderKey()); @@ -715,7 +550,7 @@ void move_nestedToRoot_recreatesOrderForCollaborators() { .thenAnswer(invocation -> invocation.getArgument(0)); when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertNull(result.parentId()); assertNotNull(result.orderKey()); @@ -787,7 +622,7 @@ void move_rootReorderCollision_reindexesUserOrdersAndRetries() { when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertNotNull(result.orderKey()); verify(userDocumentOrderRepository, times(2)).saveAndFlush(any(UserDocumentOrder.class)); @@ -838,7 +673,7 @@ void move_rootReorderWithInterleavedKeys_placesDocumentInFreeSlotWithoutCollisio when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertNotNull(result.orderKey()); assertTrue(result.orderKey().compareTo("a4") > 0); @@ -889,7 +724,7 @@ void move_rootReorderWithInvertedNeighborKeys_placesDocumentBetweenThem() { when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertNotNull(result.orderKey()); assertTrue(result.orderKey().compareTo("a4zt") > 0); @@ -932,7 +767,7 @@ void move_rootReorderFrontOfInterleavedList_placesDocumentBeforeFirstSibling() { when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertNotNull(result.orderKey()); assertTrue(result.orderKey().compareTo("a4") > 0); @@ -988,7 +823,7 @@ void move_frontReorderCollision_retryLandsInReindexedGapWithoutConflict() { when(documentRepository.existsNonTrashedChildrenByParentId(docId)).thenReturn(false); when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertNotNull(result.orderKey()); assertEquals("a8", siblingOrder.getOrderKey()); @@ -1031,11 +866,11 @@ void move_collaboratorWithAncestorShareNoDirectRow_reordersSuccessfully() { .thenAnswer(invocation -> invocation.getArgument(0)); when(permissionService.resolveAccess(userId, docId)).thenReturn(DocumentAccessLevel.VIEW); - DocumentTreeNodeResponse result = documentTreeService.move(userId, docId, request); + DocumentResponse result = documentTreeService.move(userId, docId, request); assertNotNull(result.orderKey()); assertTrue(result.orderKey().compareTo("a0") > 0); - assertEquals(DocumentAccessLevel.VIEW, result.effectiveAccessLevel()); + assertEquals(DocumentAccessLevel.VIEW, result.accessLevel()); } @Test @@ -1086,72 +921,4 @@ void move_nestedSiblingReferenceForRootMove_throwsValidationFailed() { assertEquals(ErrorCode.VALIDATION_FAILED, ex.getErrorCode()); assertEquals("sibling does not belong to root navigation", ex.getMessage()); } - - @Test - void getSharedDocuments_returnsSharedRootAndFloatedNestedDocuments() { - User otherOwner = User.builder().id(UUID.randomUUID()).build(); - Document parent = Document.builder() - .id(UUID.randomUUID()) - .user(otherOwner) - .title("Company Wiki") - .build(); - Document nestedFloated = Document.builder() - .id(UUID.randomUUID()) - .user(otherOwner) - .title("Design System") - .parent(parent) - .createdAt(OffsetDateTime.now()) - .updatedAt(OffsetDateTime.now()) - .build(); - - PageRequest pageable = PageRequest.of(0, 50); - List rows = List.of(new Object[] {nestedFloated, "a0"}); - Page queryPage = new PageImpl<>(rows); - - when(documentRepository.findSharedRootDocuments(userId, pageable)).thenReturn(queryPage); - when(documentRepository.countNonTrashedChildrenByParentIds(any())).thenReturn(List.of()); - when(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) - .thenReturn(List.of(new Object[] {nestedFloated.getId(), "EDIT"})); - - Page result = documentTreeService.getSharedDocuments(userId, pageable); - - assertEquals(1, result.getContent().size()); - assertEquals("Design System", result.getContent().get(0).title()); - assertEquals(parent.getId(), result.getContent().get(0).parentId()); - assertEquals("a0", result.getContent().get(0).orderKey()); - assertEquals(DocumentAccessLevel.EDIT, result.getContent().get(0).effectiveAccessLevel()); - } - - @Test - void getChildren_batchQueriesWithDifferentDriverTypes_handlesCastingSafely() { - UUID parentId = UUID.randomUUID(); - UUID childId = UUID.randomUUID(); - Document childDoc = Document.builder() - .id(childId) - .user(user) - .title("Child Doc") - .siblingOrderKey("a0") - .createdAt(OffsetDateTime.now()) - .updatedAt(OffsetDateTime.now()) - .build(); - - when(permissionService.requireReadAccess(userId, parentId)).thenReturn(childDoc); - when(documentRepository.findAllByParent_IdAndDeletedAtIsNull(eq(parentId), any())) - .thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(childDoc))); - - // Simulate native query returning String docId and Integer/BigInteger count - when(documentRepository.countNonTrashedChildrenByParentIds(any())) - .thenReturn(List.of(new Object[] {childId.toString(), Integer.valueOf(3)})); - when(documentRepository.resolveEffectiveAccessBatch(eq(userId), anyString())) - .thenReturn(List.of(new Object[] {childId.toString(), "EDIT"})); - - var page = documentTreeService.getChildren( - userId, parentId, org.springframework.data.domain.PageRequest.of(0, 10)); - - assertEquals(1, page.getContent().size()); - DocumentTreeNodeResponse node = page.getContent().get(0); - assertEquals(childId, node.id()); - assertTrue(node.hasChildren()); - assertEquals(DocumentAccessLevel.EDIT, node.effectiveAccessLevel()); - } } From 7570a99453aef55301b405a3d896f729108beb7f Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Thu, 20 Aug 2026 16:47:03 +0530 Subject: [PATCH 10/20] web/service: Update document client for unified tree queries. Previously, classifying owned documents between private and shared sections required dispatching concurrent listCollaborators HTTP requests for every document on initial load, causing significant latency and unnecessary API load. With the backend providing hasCollaborators and parentId directly on document responses, classifyOwnedDocuments is converted into a synchronous in-memory filter. We update DocumentService to consume the consolidated GET /api/v1/documents endpoint, add helpers for tree node pagination and document moves, and define shared tree data contracts in tree.types.ts. In the editor toolbar, trash notice rendering is refined so only users with EDIT permissions or ownership see the restore action, while viewers and commenters receive an informative read-only banner. --- web/components/DocToolbar.tsx | 57 ++- web/components/editor/Editor.tsx | 7 +- web/hooks/useDocument.hook.ts | 338 +++++++++++------- web/hooks/useDocumentList.hook.ts | 13 +- web/services/document.service.ts | 165 +++++++-- .../documentList/documentList.selectors.ts | 12 + web/stores/documentList/documentList.slice.ts | 60 +--- web/stores/documentList/documentList.types.ts | 9 + web/stores/documentList/documentList.utils.ts | 50 ++- web/tests/unit/components/DocToolbar.test.tsx | 69 ++++ .../unit/hooks/useDocument.hook.test.tsx | 313 +++++++++++++++- .../unit/hooks/useDocumentList.hook.test.tsx | 171 ++++++--- .../unit/services/document.service.test.ts | 156 ++++++++ web/types/tree.types.ts | 42 +++ 14 files changed, 1148 insertions(+), 314 deletions(-) create mode 100644 web/stores/documentList/documentList.selectors.ts create mode 100644 web/tests/unit/components/DocToolbar.test.tsx create mode 100644 web/types/tree.types.ts diff --git a/web/components/DocToolbar.tsx b/web/components/DocToolbar.tsx index de86627..c78932c 100644 --- a/web/components/DocToolbar.tsx +++ b/web/components/DocToolbar.tsx @@ -53,6 +53,8 @@ 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; } @@ -70,6 +72,7 @@ export function DocToolbar({ showGuestNotice = false, onGuestNoticeCtaClick, showTrashNotice = false, + canManageTrash = false, onRestore, }: DocToolbarProps) { const [isShareOpen, setIsShareOpen] = useState(false); @@ -158,7 +161,7 @@ export function DocToolbar({ {/* ── 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. +
)} diff --git a/web/components/editor/Editor.tsx b/web/components/editor/Editor.tsx index b440b8a..065bbae 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, @@ -194,7 +198,8 @@ export default function Editor() { pendingEdits={pendingEdits} showGuestNotice={isGuestSharedView} onGuestNoticeCtaClick={openAuthModal} - showTrashNotice={!!meta?.deletedAt} + showTrashNotice={isTrashedDocument} + canManageTrash={canManageTrash} onRestore={handleRestore} showCommentsButton={showCommentsButton} isCommentsSidebarOpen={isCommentsSidebarOpen} 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/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/services/document.service.ts b/web/services/document.service.ts index 3646164..3517d6d 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 { @@ -202,16 +219,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 +252,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 +272,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' : ''}`, { @@ -288,6 +410,7 @@ class DocumentService { allowed: body.allowed, accessLevel: body.accessLevel, owner: body.owner, + trashed: body.trashed, }; } @@ -296,29 +419,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 +571,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 +582,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 +852,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/tests/unit/components/DocToolbar.test.tsx b/web/tests/unit/components/DocToolbar.test.tsx new file mode 100644 index 0000000..b575e7d --- /dev/null +++ b/web/tests/unit/components/DocToolbar.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { DocToolbar } from '../../../components/DocToolbar'; + +jest.mock('../../../components/SharePanel', () => ({ + SharePanel: () =>
, +})); + +describe('DocToolbar trash notice', () => { + it('shows the restore action for users who can manage the trashed document', () => { + const onRestore = jest.fn(); + + render( + + ); + + 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', () => { + render( + + ); + + 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', () => { + render( + + ); + + 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', () => { + render(); + + expect(screen.queryByText(/This document is in the/)).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/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/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; +} From 9cead2e673a095e9ee5ac4a02bb969cb0b7a4ce3 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Sat, 22 Aug 2026 18:14:29 +0530 Subject: [PATCH 11/20] web/store: Introduce sidebarTree and sharedTree Redux slices. Managing multi-level document trees requires tracking recursive expansion states, lazy loading of child branches, and optimistic position updates across distinct Private and Shared namespaces. We implement the sidebarTree and sharedTree Redux slices to manage tree node registries, lazy child fetching thunks, expansion toggles, and base62 order key sorting. The sharedTree slice implements syncSharedRoots to maintain proper parent-child relationships for shared-with-me documents while guaranteeing that nested owned documents remain strictly under their private parent hierarchy. We introduce sidebar-drop-rules.ts to encapsulate pure validation policy for tree drag operations, enforcing permissions boundaries between private hierarchies and personal shared navigation orders. --- web/lib/sidebar-drop-rules.ts | 81 +++ web/stores/sharedTree/sharedTree.slice.ts | 365 ++++++++++++ web/stores/sidebarTree/sidebarTree.slice.ts | 382 +++++++++++++ web/stores/store.ts | 4 + web/tests/unit/lib/sidebar-drop-rules.test.ts | 110 ++++ .../sharedTree/sharedTree.slice.test.ts | 518 ++++++++++++++++++ .../sidebarTree/sidebarTree.slice.test.ts | 248 +++++++++ 7 files changed, 1708 insertions(+) create mode 100644 web/lib/sidebar-drop-rules.ts create mode 100644 web/stores/sharedTree/sharedTree.slice.ts create mode 100644 web/stores/sidebarTree/sidebarTree.slice.ts create mode 100644 web/tests/unit/lib/sidebar-drop-rules.test.ts create mode 100644 web/tests/unit/stores/sharedTree/sharedTree.slice.test.ts create mode 100644 web/tests/unit/stores/sidebarTree/sidebarTree.slice.test.ts 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/stores/sharedTree/sharedTree.slice.ts b/web/stores/sharedTree/sharedTree.slice.ts new file mode 100644 index 0000000..1f5b635 --- /dev/null +++ b/web/stores/sharedTree/sharedTree.slice.ts @@ -0,0 +1,365 @@ +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 + 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; + } + + const newParentId = updatedNode.parentId; + 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) { + // Root-level move: place the node 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/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/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/stores/sharedTree/sharedTree.slice.test.ts b/web/tests/unit/stores/sharedTree/sharedTree.slice.test.ts new file mode 100644 index 0000000..e063152 --- /dev/null +++ b/web/tests/unit/stores/sharedTree/sharedTree.slice.test.ts @@ -0,0 +1,518 @@ +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('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({}); + }); + }); +}); From c611be60bb19498f40fd02c0235f85f63461247a Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Mon, 24 Aug 2026 20:05:12 +0530 Subject: [PATCH 12/20] web/sidebar: Implement hierarchical sidebar tree navigation. Replaces the flat sidebar document list with interactive, nested tree components supporting deep hierarchies, recursive expansion, and drag-and-drop reordering. We integrate @dnd-kit to provide accessible drag-and-drop mechanics with visual insertion indicators and drop highlighting that match the editor theme. The sidebar UI is decomposed into SidebarTree, SharedTree, SidebarTreeItem, and SidebarSection components, featuring inline child document creation, chevron toggles, and collaborator badges. DocumentsPanel and Sidebar are refactored to support tree navigation within modal panels, search filtering, and state resets on logout. --- .gitignore | 1 + package-lock.json | 57 + web/components/sidebar/DocumentsPanel.tsx | 204 ++- web/components/sidebar/SharedTree.tsx | 130 ++ web/components/sidebar/Sidebar.tsx | 488 ++++++-- .../sidebar/SidebarDocumentSection.tsx | 140 --- web/components/sidebar/SidebarSection.tsx | 128 ++ web/components/sidebar/SidebarTree.tsx | 193 +++ .../sidebar/SidebarTreeDndContext.tsx | 368 ++++++ web/components/sidebar/SidebarTreeItem.tsx | 283 +++++ web/icons/Plus.tsx | 7 + web/icons/index.ts | 1 + web/jest.setup.js | 10 +- web/package.json | 3 + web/styles/globals.css | 28 + web/tests/unit/components/Sidebar.test.tsx | 1107 ++++++++++++++++- 16 files changed, 2863 insertions(+), 285 deletions(-) create mode 100644 web/components/sidebar/SharedTree.tsx delete mode 100644 web/components/sidebar/SidebarDocumentSection.tsx create mode 100644 web/components/sidebar/SidebarSection.tsx create mode 100644 web/components/sidebar/SidebarTree.tsx create mode 100644 web/components/sidebar/SidebarTreeDndContext.tsx create mode 100644 web/components/sidebar/SidebarTreeItem.tsx create mode 100644 web/icons/Plus.tsx 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/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/web/components/sidebar/DocumentsPanel.tsx b/web/components/sidebar/DocumentsPanel.tsx index 41ee310..15128c0 100644 --- a/web/components/sidebar/DocumentsPanel.tsx +++ b/web/components/sidebar/DocumentsPanel.tsx @@ -1,7 +1,10 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { ChevronRight, Search, DocumentText, Restore, Trash } from '@/icons'; import { DocumentsPanelSkeleton } from './DocumentsPanelSkeleton'; import { DocumentActionsButton } from './DocumentActionsButton'; +import { SidebarTreeItem } from './SidebarTreeItem'; +import { SidebarTreeDndContext } from './SidebarTreeDndContext'; +import type { TreeApi } from './SidebarTreeDndContext'; import type { DocumentsPanelMode, SidebarSectionDocument, @@ -17,7 +20,22 @@ export type DocumentsPanelProps = { setSearchQuery: (query: string) => 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/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/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/styles/globals.css b/web/styles/globals.css index f9983a1..05ab1a7 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -193,6 +193,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; 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(); +}); From d053bb201000c030cec6c0307b99a113d205d8fb Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Tue, 25 Aug 2026 10:14:22 +0530 Subject: [PATCH 13/20] sidebar: Track sidebar width in Redux and expose CSS custom property. When resizing the sidebar, adjacent fixed UI elements (such as top breadcrumb navigation) need to know the active sidebar width to smoothly offset their positions without layout jitter. This commit persists the sidebar width in the Redux store and updates the '--nd-sidebar-width' CSS variable in lockstep. --- web/components/sidebar/useSidebarResize.ts | 10 ++++++++++ web/stores/sidebar/sidebar.slice.ts | 6 ++++++ web/styles/globals.css | 5 +++++ 3 files changed, 21 insertions(+) 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/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/styles/globals.css b/web/styles/globals.css index 05ab1a7..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; @@ -1090,6 +1091,10 @@ body.nd-is-dragging * { 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( From b5389f56d22a0ed491ad9407fb15bb328b7dbc14 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Wed, 26 Aug 2026 14:38:05 +0530 Subject: [PATCH 14/20] api: Add document breadcrumb hierarchy endpoints. Deeply nested document structures require ancestor path resolution for Notion-style breadcrumbs and navigation. The breadcrumb path traversal climbs the document tree up to the root while enforcing permission boundaries: if a collaborator or public link viewer only has access to a subtree, ancestors above the highest accessible node are omitted to prevent leaking private workspace information. --- .../api/auth/security/SecurityConfig.java | 1 + .../controller/DocumentController.java | 39 +++ .../response/DocumentBreadcrumbResponse.java | 14 + .../api/document/service/DocumentService.java | 91 ++++++ .../controller/DocumentControllerTest.java | 31 ++ .../document/service/DocumentServiceTest.java | 296 ++++++++++++++++++ 6 files changed, 472 insertions(+) create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/response/DocumentBreadcrumbResponse.java 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/document/controller/DocumentController.java b/api/src/main/java/com/nextdocs/api/document/controller/DocumentController.java index 755d699..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 @@ -6,6 +6,7 @@ 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; @@ -14,6 +15,7 @@ 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; @@ -131,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. " 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/service/DocumentService.java b/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java index 2b2444e..b3a45b2 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,6 +7,7 @@ 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; @@ -194,6 +195,92 @@ public DocumentResponse getPublic(UUID documentId) { 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 = @@ -396,6 +483,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; 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 d7f4e8e..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 @@ -15,6 +15,7 @@ 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; @@ -407,6 +408,36 @@ void move_success_returns200() throws Exception { .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))) 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 c7bdb6d..030f81c 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 @@ -22,6 +22,7 @@ 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; @@ -1254,6 +1255,301 @@ void list_trashedOnly_returnsAccessibleTrashedDocuments() { 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) { User owner = User.builder() .id(UUID.randomUUID()) From fc309982d52a171da05706e6525a192c95a4288b Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Thu, 27 Aug 2026 11:22:47 +0530 Subject: [PATCH 15/20] document service: Add client method for fetching document breadcrumbs. Exposes getDocumentBreadcrumbs on the frontend document service to fetch document hierarchy paths, routing through authenticated or public endpoints based on current session credentials. --- web/services/document.service.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/web/services/document.service.ts b/web/services/document.service.ts index 3517d6d..7f9de63 100644 --- a/web/services/document.service.ts +++ b/web/services/document.service.ts @@ -112,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; @@ -396,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`, From 343150385f558d1e26eda644b83603227706e393 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Fri, 28 Aug 2026 16:45:19 +0530 Subject: [PATCH 16/20] document hook: Add useDocumentBreadcrumbs hook. Resolving breadcrumbs purely from server roundtrips causes noticeable layout shifts when switching between documents. The hook combines instantaneous optimistic breadcrumb construction from the local sidebar tree Redux state with background server fetching to load full ancestor paths for unexpanded or deep trees, while keeping active title changes reactive. --- web/hooks/useDocumentBreadcrumbs.hook.ts | 152 +++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 web/hooks/useDocumentBreadcrumbs.hook.ts 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, + }; +} From ed1ec86b73d7adcf6886ed90b68483fc95d55906 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Sat, 29 Aug 2026 19:10:33 +0530 Subject: [PATCH 17/20] toolbar: Render breadcrumb hierarchy and collapsed parent navigation. Integrates breadcrumbs into the document top toolbar, rendering ancestor links with title truncation and Notion-style slash dividers. For deeply nested hierarchies beyond three levels, intermediate ancestors are collapsed into an ellipsis dropdown menu to preserve space for editing actions. Navigation supports both online router transitions and offline custom event dispatching. --- web/components/DocToolbar.tsx | 407 ++++++++++++--- web/components/editor/Editor.tsx | 2 + web/tests/unit/components/DocToolbar.test.tsx | 472 +++++++++++++++++- web/tests/unit/components/Editor.test.tsx | 1 + 4 files changed, 816 insertions(+), 66 deletions(-) diff --git a/web/components/DocToolbar.tsx b/web/components/DocToolbar.tsx index c78932c..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 */ @@ -61,6 +72,9 @@ interface DocToolbarProps { export function DocToolbar({ documentId, + documentTitle, + documentIcon, + onNavigateDocument, isShareEnabled, updatedAt, isOffline, @@ -75,14 +89,71 @@ export function DocToolbar({ 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 = @@ -97,76 +168,290 @@ 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 && canManageTrash && (
    diff --git a/web/components/editor/Editor.tsx b/web/components/editor/Editor.tsx index 065bbae..fde3c45 100644 --- a/web/components/editor/Editor.tsx +++ b/web/components/editor/Editor.tsx @@ -192,6 +192,8 @@ export default function Editor() { <> ({ + 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(); - render( + renderWithStore( { }); it('shows the read-only notice without a restore action for viewers and commenters', () => { - render( + renderWithStore( { }); it('shows the trash notice without a restore action when canManageTrash is true but onRestore is undefined', () => { - render( + renderWithStore( { }); it('shows no trash notice when the document is not trashed', () => { - render(); + 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([]), }, })); From 299289b0e17a95305678d5381894ebac214fcd75 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Mon, 31 Aug 2026 23:40:06 +0530 Subject: [PATCH 18/20] api/document: Use personal order keys for floated shared documents. When a document shared with a collaborator has a parent document that is not accessible to that collaborator, the document floats at the root of the collaborator's Shared section. Previously, document queries returned the owner's siblingOrderKey for any document with a non-null parent. For floated documents whose parents are inaccessible to the caller, this leaked the sibling ordering of an unshared private hierarchy and prevented the collaborator's personal positioning in user_document_orders from taking effect. We update DocumentListQueryHelper to batch-resolve parent access when listing shared documents and return the caller's personal user_document_orders key for any floated document whose parent is inaccessible. Single-document responses in DocumentService similarly resolve parent access before deciding between the personal order key and the owner's sibling order key. In DocumentRepository, findSharedWithUserId fetch-joins the parent reference to avoid N+1 queries during listing. --- .../repository/DocumentRepository.java | 13 +- .../service/DocumentListQueryHelper.java | 38 +++-- .../api/document/service/DocumentService.java | 74 +++++----- .../service/DocumentListQueryHelperTest.java | 132 ++++++++++++++++++ .../document/service/DocumentServiceTest.java | 126 +++++++++++++++++ 5 files changed, 334 insertions(+), 49 deletions(-) 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 d63b92a..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 @@ -25,10 +25,15 @@ public interface DocumentRepository extends JpaRepository { 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. 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 index dac5c55..b6da771 100644 --- a/api/src/main/java/com/nextdocs/api/document/service/DocumentListQueryHelper.java +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentListQueryHelper.java @@ -15,6 +15,7 @@ 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; @@ -333,13 +334,28 @@ private Page listFlatDocuments(UUID userId, String scope, Page Set collaboratorDocIds = new HashSet<>(collaboratorRepository.findDocumentIdsWithCollaborators(docIds)); Map accessLevels = fetchAccessLevels(userId, docIds); - Map rootOrderKeys = fetchRootOrderKeys(userId, docs); + + 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); - String orderKey = doc.getParent() != null ? doc.getSiblingOrderKey() : rootOrderKeys.get(doc.getId()); + 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( @@ -432,18 +448,22 @@ private Map fetchTrashAccessLevels(UUID userId, Colle return accessLevels; } - private Map fetchRootOrderKeys(UUID userId, List docs) { - List rootIds = docs.stream() - .filter(document -> document.getParent() == null) - .map(Document::getId) - .toList(); - if (rootIds.isEmpty()) { + private Map fetchUserOrderKeys(UUID userId, Collection docIds) { + if (docIds.isEmpty()) { return Map.of(); } Map orderKeys = new HashMap<>(); - for (Object[] row : userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(userId, rootIds)) { + 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 b3a45b2..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 @@ -23,9 +23,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; @@ -500,34 +498,53 @@ private static byte[] decodeBase64State(String yjsState) { } private DocumentResponse toResponse(Document document, boolean includeState) { - return toResponse(document, includeState, (Map) null); + 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) { - return toResponse(document, includeState, callerUserId, null); - } - - private DocumentResponse toResponse(Document document, boolean includeState, Map rootOrderKeys) { - return toResponse(document, includeState, null, rootOrderKeys); - } - - private DocumentResponse toResponse( - Document document, boolean includeState, UUID callerUserId, Map rootOrderKeys) { OffsetDateTime deletedAt = document.getDeletedAt(); OffsetDateTime purgeAt = null; if (deletedAt != null) { purgeAt = deletedAt.plusDays(documentProperties.getTrashRetentionDays()); } - String orderKey = document.getParent() != null - ? document.getSiblingOrderKey() - : rootOrderKeys != null - ? rootOrderKeys.get(document.getId()) - : callerUserId != null - ? userDocumentOrderRepository - .findOrderKeyByUserIdAndDocumentId(callerUserId, document.getId()) - .orElse(null) - : null; + 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()); @@ -562,21 +579,6 @@ private DocumentResponse toResponse( purgeAt); } - private Map fetchRootOrderKeys(UUID userId, List docs) { - List rootIds = docs.stream() - .filter(document -> document.getParent() == null) - .map(Document::getId) - .toList(); - if (rootIds.isEmpty()) { - return Map.of(); - } - Map orderKeys = new HashMap<>(); - for (Object[] row : userDocumentOrderRepository.findOrderKeysByUserIdAndDocumentIds(userId, rootIds)) { - orderKeys.put((UUID) row[0], (String) row[1]); - } - return orderKeys; - } - private List collectAllDescendants(UUID rootId) { List allDescendants = new ArrayList<>(); List currentParentIds = List.of(rootId); 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 index 30d3670..e3b87f5 100644 --- a/api/src/test/java/com/nextdocs/api/document/service/DocumentListQueryHelperTest.java +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentListQueryHelperTest.java @@ -136,6 +136,138 @@ void list_rootShared_returnsSharedRootsWithBatchPermissions() { 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); 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 030f81c..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 @@ -287,6 +287,132 @@ 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(); From 23184a6050cc8b5490fcd66c11b98c67895e95df Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Mon, 31 Aug 2026 23:40:16 +0530 Subject: [PATCH 19/20] web/store: Handle floated nested documents in sharedTree move thunk. When moving a shared document whose parent is private to the owner and not shared with the current user, the API response contains the document's true backend parentId. Previously, moveDocumentThunk.fulfilled attempted to attach the moved node to state.nodes[newParentId], which does not exist in the caller's shared tree state. As a result, the node became orphaned from the root list and failed to render in the sidebar. We determine whether the updated node's parent is present in the shared tree node registry. If absent, the node is treated as a floated root with an effectiveParentId of null, placing it into rootIds and sorting by personal orderKey so that tree rendering, reachability, and drag-and-drop constraints remain valid. --- web/stores/sharedTree/sharedTree.slice.ts | 26 ++- .../sharedTree/sharedTree.slice.test.ts | 160 ++++++++++++++++++ 2 files changed, 179 insertions(+), 7 deletions(-) diff --git a/web/stores/sharedTree/sharedTree.slice.ts b/web/stores/sharedTree/sharedTree.slice.ts index 1f5b635..f8f9903 100644 --- a/web/stores/sharedTree/sharedTree.slice.ts +++ b/web/stores/sharedTree/sharedTree.slice.ts @@ -322,9 +322,21 @@ const sharedTreeSlice = createSlice({ state.rootIds = state.rootIds.filter((rid) => rid !== updatedNode.id); } - // Update node record + // 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, + { + ...updatedNode, + parentId: effectiveParentId, + }, existing ? existing.isExpanded : false ); if (existing) { @@ -332,9 +344,8 @@ const sharedTreeSlice = createSlice({ state.nodes[updatedNode.id].childrenLoaded = existing.childrenLoaded; } - const newParentId = updatedNode.parentId; - if (newParentId && state.nodes[newParentId]) { - const newParent = state.nodes[newParentId]; + if (effectiveParentId && state.nodes[effectiveParentId]) { + const newParent = state.nodes[effectiveParentId]; newParent.hasChildren = true; if (!newParent.children.includes(updatedNode.id)) { newParent.children.push(updatedNode.id); @@ -345,8 +356,9 @@ const sharedTreeSlice = createSlice({ const keyB = state.nodes[bId]?.orderKey ?? ''; return compareOrderKeys(keyA, keyB); }); - } else if (!newParentId) { - // Root-level move: place the node and sort by personal orderKey + } 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); } diff --git a/web/tests/unit/stores/sharedTree/sharedTree.slice.test.ts b/web/tests/unit/stores/sharedTree/sharedTree.slice.test.ts index e063152..bd18e04 100644 --- a/web/tests/unit/stores/sharedTree/sharedTree.slice.test.ts +++ b/web/tests/unit/stores/sharedTree/sharedTree.slice.test.ts @@ -487,6 +487,166 @@ describe('sharedTree.slice moveDocumentThunk.fulfilled', () => { 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: { From 475458e9254fbca81cb3b55dda2e837682a30a1e Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Tue, 1 Sep 2026 17:46:50 +0530 Subject: [PATCH 20/20] test/auth: Restore OAuth token key test state. The converter unit tests mutate the JVM property that Maven supplies to every test in the shared Surefire fork. Clearing it leaked state into later Spring JPA tests, causing Hibernate to fail while constructing the OAuth token converter. Capture and restore the pre-existing property around each test, while clearing it only within the missing-key assertion. This keeps the fail-fast coverage intact and makes the test suite independent of execution order. --- .../OAuthTokenAttributeConverterTest.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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");