Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f5c5256
api/util: Implement FractionalIndex utility for lexicographical order…
santhoshh-kumar Jul 27, 2026
fa254c9
api/schema: Add document nesting schema migrations and entity mappings.
santhoshh-kumar Jul 29, 2026
56fdaec
api/schema: Introduce user_document_orders table and repository.
santhoshh-kumar Jul 31, 2026
4e89d15
api/permission: Implement recursive ancestor access resolution.
santhoshh-kumar Aug 3, 2026
883b266
api/document: Support nested document creation and cascade deletion.
santhoshh-kumar Aug 6, 2026
efe4a57
api/document: Support collaborator navigation ordering and trash access.
santhoshh-kumar Aug 9, 2026
612a014
api/document: Implement document tree navigation and move endpoints.
santhoshh-kumar Aug 13, 2026
e6ae04d
realtime: Migrate test suite to ESM runner.
santhoshh-kumar Aug 16, 2026
ff19fbd
api/document: Consolidate document listing and tree queries.
santhoshh-kumar Aug 18, 2026
7570a99
web/service: Update document client for unified tree queries.
santhoshh-kumar Aug 20, 2026
9cead2e
web/store: Introduce sidebarTree and sharedTree Redux slices.
santhoshh-kumar Aug 22, 2026
c611be6
web/sidebar: Implement hierarchical sidebar tree navigation.
santhoshh-kumar Aug 24, 2026
d053bb2
sidebar: Track sidebar width in Redux and expose CSS custom property.
santhoshh-kumar Aug 25, 2026
b5389f5
api: Add document breadcrumb hierarchy endpoints.
santhoshh-kumar Aug 26, 2026
fc30998
document service: Add client method for fetching document breadcrumbs.
santhoshh-kumar Aug 27, 2026
3431503
document hook: Add useDocumentBreadcrumbs hook.
santhoshh-kumar Aug 28, 2026
ed1ec86
toolbar: Render breadcrumb hierarchy and collapsed parent navigation.
santhoshh-kumar Aug 29, 2026
299289b
api/document: Use personal order keys for floated shared documents.
santhoshh-kumar Aug 31, 2026
23184a6
web/store: Handle floated nested documents in sharedTree move thunk.
santhoshh-kumar Aug 31, 2026
475458e
test/auth: Restore OAuth token key test state.
santhoshh-kumar Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ node_modules/
.env
.env.local
.env.*.local
config.json

# Turbo
.turbo
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,8 +24,12 @@ public ResponseEntity<ApiResponse<Void>> 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)
Expand Down Expand Up @@ -55,6 +60,14 @@ public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMi
.body(ApiResponse.error(ErrorCode.VALIDATION_FAILED.defaultMessage(), detail));
}

@ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
log.warn("Access denied: {}", ex.getMessage(), ex);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@
import com.nextdocs.api.common.response.ApiResponse;
import com.nextdocs.api.common.response.PagedResponse;
import com.nextdocs.api.document.dto.request.DocumentCreateRequest;
import com.nextdocs.api.document.dto.request.DocumentMoveRequest;
import com.nextdocs.api.document.dto.request.DocumentUpdateRequest;
import com.nextdocs.api.document.dto.response.DocumentBreadcrumbResponse;
import com.nextdocs.api.document.dto.response.DocumentResponse;
import com.nextdocs.api.document.service.DocumentService;
import com.nextdocs.api.document.service.DocumentTreeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
Expand All @@ -30,6 +34,7 @@
public class DocumentController {

private final DocumentService documentService;
private final DocumentTreeService documentTreeService;

@Operation(
summary = "Create a document",
Expand Down Expand Up @@ -58,33 +63,39 @@ public ResponseEntity<ApiResponse<DocumentResponse>> create(
}

@Operation(
summary = "List current user's documents",
description = "Returns a paged list of documents owned by the authenticated user. "
+ "By default only active documents are returned (ordered by last update). "
+ "Use trashed=true to list documents in trash (ordered by time moved to trash).",
summary = "List documents",
description = "Returns a paged list of documents filtered by parentId, scope, and trashed. "
+ "Use parentId=root for root-level documents, or parentId=<UUID> 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")
})
@GetMapping
public ResponseEntity<ApiResponse<PagedResponse<DocumentResponse>>> 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<DocumentResponse> page = documentService.list(principal.getId(), pageable, trashedOnly);
Page<DocumentResponse> page = documentService.list(principal.getId(), parentId, scope, trashed, pageable);
return ResponseEntity.ok(ApiResponse.ok(PagedResponse.from(page)));
}

@Operation(
summary = "Get a single document",
description = "Returns one document if it exists and belongs to the authenticated user. "
description = "Returns one document the authenticated user can access. "
+ "Trashed documents are omitted by default (404) so realtime access checks stay strict. "
+ "Pass includeTrashed=true to load a trashed document (e.g. trash UI or restore).",
+ "Pass includeTrashed=true to load a trashed document (e.g. trash UI or restore); trashed "
+ "documents are visible to their owner and collaborators with at least EDIT access.",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
Expand Down Expand Up @@ -122,6 +133,43 @@ public ResponseEntity<ApiResponse<DocumentResponse>> 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<ApiResponse<List<DocumentBreadcrumbResponse>>> 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<ApiResponse<List<DocumentBreadcrumbResponse>>> 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. "
Expand Down Expand Up @@ -153,8 +201,10 @@ public ResponseEntity<ApiResponse<DocumentResponse>> 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",
Expand All @@ -180,7 +230,9 @@ public ResponseEntity<Void> 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",
Expand All @@ -198,4 +250,37 @@ public ResponseEntity<ApiResponse<DocumentResponse>> restore(
DocumentResponse response = documentService.restore(principal.getId(), id);
return ResponseEntity.ok(ApiResponse.ok(response, "Document restored."));
}

@Operation(
summary = "Move a document to a new parent / position",
description =
"Relocates a document within the tree by updating its parent or personal navigation order. "
+ "When newParentId is present the caller must have EDIT access to both the document and the target parent "
+ "(owner or collaborator via ancestor sharing). "
+ "When newParentId is null the document is reordered in the caller's root navigation: owners may un-parent their own documents, "
+ "while collaborators with at least VIEW access may reorder a shared root document in their personal Shared section.",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "Document moved"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "Cycle detected or invalid sibling references"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "Authentication required"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403",
description = "Caller lacks required access"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "Document or sibling not found")
})
@PostMapping("/{id}/move")
public ResponseEntity<ApiResponse<DocumentResponse>> move(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID id,
@Valid @RequestBody DocumentMoveRequest request) {
return ResponseEntity.ok(ApiResponse.ok(documentTreeService.move(principal.getId(), id, request)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -202,24 +197,6 @@ public ResponseEntity<ApiResponse<SharingSettingsResponse>> 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<ApiResponse<PagedResponse<DocumentResponse>>> listSharedWithMe(
@AuthenticationPrincipal UserPrincipal principal, @PageableDefault(size = 20) Pageable pageable) {
Page<DocumentResponse> 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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,6 +13,20 @@ public record DocumentResponse(
@Schema(description = "Base64-encoded Yjs state when requested")
String yjsState,

@Schema(description = "Parent document ID, null for root-level")
UUID parentId,

@Schema(description = "Fractional ordering key") String orderKey,

@Schema(description = "Whether this document has active non-trashed children")
boolean hasChildren,

@Schema(description = "Whether this document has external collaborators")
boolean hasCollaborators,

@Schema(description = "Effective access level of the requesting user")
DocumentAccessLevel accessLevel,

@Schema(description = "Creator label") String createdBy,
@Schema(description = "Creation timestamp") OffsetDateTime createdAt,
@Schema(description = "Last update timestamp") OffsetDateTime updatedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading