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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
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.BulkImportRequest;
import com.nextdocs.api.document.dto.request.DocumentCreateRequest;
import com.nextdocs.api.document.dto.request.DocumentUpdateRequest;
import com.nextdocs.api.document.dto.response.BulkImportResponse;
import com.nextdocs.api.document.dto.response.DocumentResponse;
import com.nextdocs.api.document.service.DocumentService;
import io.swagger.v3.oas.annotations.Operation;
Expand Down Expand Up @@ -37,6 +35,9 @@ public class DocumentController {
summary = "Create a document",
description = "Creates a new document owned by the authenticated user.",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "Document already existed for the authenticated user"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "201",
description = "Document created"),
Expand All @@ -50,8 +51,10 @@ public class DocumentController {
@PostMapping
public ResponseEntity<ApiResponse<DocumentResponse>> create(
@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody DocumentCreateRequest request) {
DocumentResponse response = documentService.create(principal.getId(), request);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(response, "Document created."));
DocumentService.CreateDocumentResult result = documentService.create(principal.getId(), request);
HttpStatus status = result.created() ? HttpStatus.CREATED : HttpStatus.OK;
String message = result.created() ? "Document created." : "Document already exists.";
return ResponseEntity.status(status).body(ApiResponse.ok(result.document(), message));
}

@Operation(
Expand Down Expand Up @@ -195,26 +198,4 @@ public ResponseEntity<ApiResponse<DocumentResponse>> restore(
DocumentResponse response = documentService.restore(principal.getId(), id);
return ResponseEntity.ok(ApiResponse.ok(response, "Document restored."));
}

@Operation(
summary = "Bulk import local documents",
description = "Imports local documents into the authenticated user's account. "
+ "Operation is transactional: if one document fails validation, none are imported.",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "201",
description = "Documents imported"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "Invalid request payload"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "Authentication required")
})
@PostMapping("/bulk-import")
public ResponseEntity<ApiResponse<BulkImportResponse>> bulkImport(
@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody BulkImportRequest request) {
BulkImportResponse response = documentService.bulkImport(principal.getId(), request);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(response, "Documents imported."));
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.UUID;

@Schema(description = "Request body for creating a document")
public record DocumentCreateRequest(
@Schema(
description = "Client-generated document ID. Must be a UUID.",
example = "550e8400-e29b-41d4-a716-446655440000")
UUID id,

@Schema(description = "Document title", example = "My First Doc")
@NotBlank(message = "Title is required")
@Size(max = 255, message = "Title must be at most 255 characters")
Expand All @@ -18,8 +24,4 @@ public record DocumentCreateRequest(

@Schema(description = "Optional creator label", example = "Anonymous")
@Size(max = 255, message = "createdBy must be at most 255 characters")
String createdBy,

@Schema(description = "Optional local source ID for idempotent import/promotion", example = "local-123")
@Size(max = 128, message = "sourceLocalId must be at most 128 characters")
String sourceLocalId) {}
String createdBy) {}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
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_source_local", columnList = "user_id,source_local_id")
@Index(name = "idx_documents_user_updated", columnList = "user_id,updated_at")
})
@Getter
@Setter
Expand All @@ -26,7 +25,6 @@
public class Document {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
Expand All @@ -43,9 +41,6 @@ public class Document {
@Column(name = "created_by", length = 255)
private String createdBy;

@Column(name = "source_local_id", length = 128)
private String sourceLocalId;

@Column(name = "deleted_at")
private OffsetDateTime deletedAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.nextdocs.api.document.entity.Document;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.domain.Page;
Expand All @@ -20,10 +19,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID> {

Page<Document> findAllByUser_IdAndDeletedAtIsNotNull(UUID userId, Pageable pageable);

List<Document> findAllByUser_IdAndSourceLocalIdInAndDeletedAtIsNull(UUID userId, List<String> sourceLocalIds);

Optional<Document> findByUser_IdAndSourceLocalIdAndDeletedAtIsNull(UUID userId, String sourceLocalId);

Optional<Document> findByIdAndUser_IdAndDeletedAtIsNull(UUID id, UUID userId);

Optional<Document> findByIdAndUser_Id(UUID id, UUID userId);
Expand Down
141 changes: 32 additions & 109 deletions api/src/main/java/com/nextdocs/api/document/service/DocumentService.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
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.request.*;
import com.nextdocs.api.document.dto.response.*;
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;
Expand All @@ -16,9 +17,6 @@
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
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;
Expand All @@ -29,13 +27,14 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class DocumentService {

public record CreateDocumentResult(DocumentResponse document, boolean created) {}

private final DocumentRepository documentRepository;
private final DocumentCollaboratorRepository collaboratorRepository;
private final UserRepository userRepository;
Expand All @@ -46,48 +45,51 @@ public class DocumentService {
private DocumentService selfProxy;

@Transactional
public DocumentResponse create(UUID userId, DocumentCreateRequest request) {
public CreateDocumentResult create(UUID userId, DocumentCreateRequest request) {
User user = userRepository.findById(userId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND));
String sourceLocalId = normalizeSourceLocalId(request.sourceLocalId());
String yjsState = request.yjsState();

if (yjsState == null) {
throw new ApiException(ErrorCode.VALIDATION_FAILED, "yjsState is required.");
}

if (sourceLocalId != null) {
Document existing = documentRepository
.findByUser_IdAndSourceLocalIdAndDeletedAtIsNull(userId, sourceLocalId)
.orElse(null);
UUID documentId = request.id() != null ? request.id() : UUID.randomUUID();

if (request.id() != null) {
Document existing =
documentRepository.findByIdAndUser_Id(documentId, userId).orElse(null);
if (existing != null) {
applyFields(existing, user, request.title(), yjsState, request.createdBy(), sourceLocalId);
return toResponse(documentRepository.save(existing), true);
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 document = Document.builder()
.id(documentId)
.user(user)
.title(normalizeTitle(request.title()))
.yjsState(decodeBase64State(yjsState))
.createdBy(request.createdBy())
.sourceLocalId(sourceLocalId)
.build();

if (sourceLocalId != null) {
try {
return toResponse(documentRepository.saveAndFlush(document), true);
} catch (DataIntegrityViolationException ex) {
Document existing = documentRepository
.findByUser_IdAndSourceLocalIdAndDeletedAtIsNull(userId, sourceLocalId)
.orElseThrow(() -> ex);
try {
return new CreateDocumentResult(toResponse(documentRepository.saveAndFlush(document), true), true);
} catch (DataIntegrityViolationException ex) {
if (request.id() == null) {
throw ex;
}

applyFields(existing, user, request.title(), yjsState, request.createdBy(), sourceLocalId);
return toResponse(documentRepository.save(existing), true);
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);
}

return toResponse(documentRepository.save(document), true);
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -221,67 +223,6 @@ public int purgeExpiredTrash() {
return purgeExpiredTrash(nowUtc);
}

@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public BulkImportResponse bulkImport(UUID userId, BulkImportRequest request) {
User user = userRepository.findById(userId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND));

List<String> sourceLocalIds = request.docs().stream()
.map(BulkImportItemRequest::localId)
.map(DocumentService::normalizeSourceLocalId)
.filter(id -> id != null)
.toList();

Map<String, Document> existingByLocalId = new HashMap<>();
if (!sourceLocalIds.isEmpty()) {
existingByLocalId =
documentRepository
.findAllByUser_IdAndSourceLocalIdInAndDeletedAtIsNull(userId, sourceLocalIds)
.stream()
.collect(java.util.stream.Collectors.toMap(Document::getSourceLocalId, doc -> doc));
}

List<BulkImportItemResponse> imported = new java.util.ArrayList<>();
for (BulkImportItemRequest item : request.docs()) {
String localId = normalizeSourceLocalId(item.localId());
Document existing = localId == null ? null : existingByLocalId.get(localId);
Document target = existing == null ? new Document() : existing;

applyFields(target, user, item.title(), item.yjsState(), item.createdBy(), localId);

target = saveWithSourceLocalIdRetry(userId, localId, target, user, item);

if (localId != null) {
existingByLocalId.put(localId, target);
}

imported.add(new BulkImportItemResponse(item.localId(), target.getId(), target.getTitle()));
}

return new BulkImportResponse(imported);
}

private Document saveWithSourceLocalIdRetry(
UUID userId, String localId, Document target, User user, BulkImportItemRequest item) {
if (target.getId() != null) {
return documentRepository.save(target);
}

try {
return documentRepository.saveAndFlush(target);
} catch (DataIntegrityViolationException ex) {
if (localId == null) {
throw ex;
}

Document latest = documentRepository
.findByUser_IdAndSourceLocalIdAndDeletedAtIsNull(userId, localId)
.orElseThrow(() -> ex);

applyFields(latest, user, item.title(), item.yjsState(), item.createdBy(), localId);
return documentRepository.save(latest);
}
}

private static String normalizeTitle(String title) {
String value = title == null ? "" : title.strip();
if (value.isBlank()) {
Expand All @@ -290,24 +231,6 @@ private static String normalizeTitle(String title) {
return value;
}

private static String normalizeSourceLocalId(String sourceLocalId) {
if (sourceLocalId == null) {
return null;
}

String value = sourceLocalId.strip();
return value.isBlank() ? null : value;
}

private static void applyFields(
Document target, User user, String title, String yjsState, String createdBy, String sourceLocalId) {
target.setUser(user);
target.setTitle(normalizeTitle(title));
target.setYjsState(decodeBase64State(yjsState));
target.setCreatedBy(createdBy);
target.setSourceLocalId(sourceLocalId);
}

private static byte[] decodeBase64State(String yjsState) {
if (yjsState == null) {
return null;
Expand Down
Loading