diff --git a/docs/registries/error-codes.yaml b/docs/registries/error-codes.yaml index b7a5714..22f655b 100644 --- a/docs/registries/error-codes.yaml +++ b/docs/registries/error-codes.yaml @@ -1402,4 +1402,69 @@ errors: log_level: INFO runbook_link: null compatibility_impact: additive + required_test: ManagementErrorRegistryTest + # source: studio-management-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (ManagementError.DOCUMENT_NOT_FOUND) + - code: DOCUMENT_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-management-v1 + owner_layer: application + client_safe_message: "작업본을 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: ManagementErrorRegistryTest + # source: studio-management-v1.yaml ApiError.code — DOCUMENT_PUBLISHED (ManagementError.DOCUMENT_PUBLISHED) + - code: DOCUMENT_PUBLISHED + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-management-v1 + owner_layer: application + client_safe_message: "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: ManagementErrorRegistryTest + # source: studio-management-v1.yaml ApiError.code — QUESTION_NOT_FOUND (ManagementError.QUESTION_NOT_FOUND) + - code: QUESTION_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-management-v1 + owner_layer: application + client_safe_message: "질문을 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: ManagementErrorRegistryTest + # source: studio-management-v1.yaml ApiError.code — DOCUMENT_IN_USE (ManagementError.DOCUMENT_IN_USE) + - code: DOCUMENT_IN_USE + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-management-v1 + owner_layer: application + client_safe_message: "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: ManagementErrorRegistryTest + # source: studio-management-v1.yaml ApiError.code — QUESTION_IN_USE (ManagementError.QUESTION_IN_USE) + - code: QUESTION_IN_USE + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-management-v1 + owner_layer: application + client_safe_message: "이 질문을 참조하는 곳이 있어 삭제할 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive required_test: ManagementErrorRegistryTest \ No newline at end of file diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java index fa5b9f6..547e63b 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java @@ -26,6 +26,11 @@ public final class ManagementClientSafeMessages { case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다"; case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다"; case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다"; + case DOCUMENT_NOT_FOUND -> "작업본을 찾을 수 없습니다"; + case DOCUMENT_PUBLISHED -> "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요"; + case DOCUMENT_IN_USE -> "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다"; + case QUESTION_NOT_FOUND -> "질문을 찾을 수 없습니다"; + case QUESTION_IN_USE -> "이 질문을 참조하는 곳이 있어 삭제할 수 없습니다"; }; } } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementDocumentController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementDocumentController.java new file mode 100644 index 0000000..68283e7 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementDocumentController.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.inbound.web.techlog.management.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest; +import dev.caskeleton.application.techlog.management.command.DeleteDocumentCommand; +import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase; +import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * 작업본 삭제. 계약의 delete 3개. + * + *
경로가 종류별로 갈리는 것은 계약이 그렇게 선언했기 때문이고, 그럴 이유도 있다 — Case 와 Reference 는 한 테이블을 나눠 쓰지만 Question 은 다른 + * 테이블이고, 종류를 경로에 두면 Case 주소로 Reference 를 지우는 요청이 애초에 성립하지 않는다. + */ +@RestController +public class ManagementDocumentController { + + private final DeleteDocumentDraftUseCase deleteDocument; + private final DeleteQuestionUseCase deleteQuestion; + + public ManagementDocumentController( + DeleteDocumentDraftUseCase deleteDocument, DeleteQuestionUseCase deleteQuestion) { + this.deleteDocument = deleteDocument; + this.deleteQuestion = deleteQuestion; + } + + @DeleteMapping("/v1/studio/cases/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteCaseDraft( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @PathVariable("id") UUID id, + @RequestBody ExpectedVersionRequest body) { + deleteDocument.handle(command(id, body, principal), "CASE"); + } + + @DeleteMapping("/v1/studio/references/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteReferenceDraft( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @PathVariable("id") UUID id, + @RequestBody ExpectedVersionRequest body) { + deleteDocument.handle(command(id, body, principal), "REFERENCE"); + } + + @DeleteMapping("/v1/studio/questions/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteQuestion( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @PathVariable("id") UUID id, + @RequestBody ExpectedVersionRequest body) { + deleteQuestion.handle(command(id, body, principal)); + } + + private static DeleteDocumentCommand command( + UUID id, ExpectedVersionRequest body, AuthenticatedPrincipal principal) { + return new DeleteDocumentCommand( + id, body.getExpectedVersion(), ManagementPrincipals.require(principal)); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java new file mode 100644 index 0000000..5fda351 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java @@ -0,0 +1,109 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.management; + +import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * 작업본 삭제. + * + *
작업본 자신의 것(상세, 태그, 나가는 관계)은 {@code ON DELETE CASCADE} 로 따라 지워진다. 하지만 다른 기록이 이쪽을 가리키는
+ * 참조는 그렇지 않다 — 다른 문서의 관계 대상, 질문의 링크, 프로젝트 소속, 주제의 추천 목록, 결정의 근거 Case 다섯 곳이 CASCADE 없이 걸려 있다. 그대로
+ * 지우면 외래키 위반이고, 작성자에게는 500 으로 도착한다. 그래서 먼저 확인하고 거절한다 — 남의 기록을 조용히 고쳐 주는 것보다 낫다.
+ */
+@Repository
+public class JdbcDocumentDeletionAdapter implements DocumentDeletionPort {
+
+ private final JdbcClient jdbcClient;
+
+ public JdbcDocumentDeletionAdapter(JdbcClient jdbcClient) {
+ this.jdbcClient = jdbcClient;
+ }
+
+ private static DeletableDocument mapDocument(ResultSet rs, int rowNum) throws SQLException {
+ return new DeletableDocument(
+ rs.getObject("id", UUID.class),
+ rs.getString("document_type"),
+ rs.getString("workflow_status"),
+ rs.getLong("version"));
+ }
+
+ private static DeletableQuestion mapQuestion(ResultSet rs, int rowNum) throws SQLException {
+ return new DeletableQuestion(rs.getObject("id", UUID.class), rs.getLong("version"));
+ }
+
+ @Override
+ public Optional {@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로 낼 수 있게 되면 그
* 순간 두 계약 모두 거짓이 된다.
*
- * 계약의 {@code ApiError.code} 는 15종인데 여기는 14종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통
+ * 계약의 {@code ApiError.code} 는 20종인데 여기는 19종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통
* 처리기가 내는 코드({@code OperationalError.INTERNAL_ERROR}) 이고, 같은 code 를 두 enum 이 각자 status 와 retryable
* 을 달고 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다 — 실제로 그쪽은 {@code retryable=true} 다. {@code PublicError} 가 같은
* 이유로 같은 선택을 했다.
@@ -29,7 +29,12 @@ public enum ManagementError implements ApiErrorCode {
PROJECT_IN_USE(Category.CONFLICT, 409, false),
RELEASE_NOT_FOUND(Category.NOT_FOUND, 404, false),
RELEASE_VERSION_TAKEN(Category.CONFLICT, 409, false),
- RELEASE_NOT_PUBLISHABLE(Category.CONFLICT, 409, false);
+ RELEASE_NOT_PUBLISHABLE(Category.CONFLICT, 409, false),
+ DOCUMENT_NOT_FOUND(Category.NOT_FOUND, 404, false),
+ DOCUMENT_PUBLISHED(Category.CONFLICT, 409, false),
+ DOCUMENT_IN_USE(Category.CONFLICT, 409, false),
+ QUESTION_NOT_FOUND(Category.NOT_FOUND, 404, false),
+ QUESTION_IN_USE(Category.CONFLICT, 409, false);
private final Category category;
private final int httpStatus;
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteDocumentCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteDocumentCommand.java
new file mode 100644
index 0000000..30ec468
--- /dev/null
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteDocumentCommand.java
@@ -0,0 +1,9 @@
+package dev.caskeleton.application.techlog.management.command;
+
+import java.util.UUID;
+
+/**
+ * 계약 {@code ExpectedVersionRequest}. Case / Reference / Question 삭제가 모두 같은 모양이라 하나로 둔다 — 대상 테이블만
+ * 다르고 요청은 "이 버전의 이 작업본을 지운다"로 동일하다.
+ */
+public record DeleteDocumentCommand(UUID id, long expectedVersion, String actor) {}
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java
new file mode 100644
index 0000000..f7ebb59
--- /dev/null
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java
@@ -0,0 +1,35 @@
+package dev.caskeleton.application.techlog.management.port.out;
+
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * 작업본 삭제.
+ *
+ * 편집·조회는 {@code studio} 쪽 포트가 이미 갖고 있다. 삭제만 따로 두는 이유는 이것이 관리 계약의 operation 이고, 지우기 전에 확인해야 하는
+ * 것(무슨 종류인지, 지금 공개돼 있는지)이 편집 화면이 읽는 것과 다르기 때문이다.
+ */
+public interface DocumentDeletionPort {
+
+ /** 삭제 가능 여부를 판단할 만큼만 읽는다. */
+ record DeletableDocument(UUID id, String documentType, String workflowStatus, long version) {}
+
+ record DeletableQuestion(UUID id, long version) {}
+
+ /** {@code documentType} 이 어긋나면 비어 있다 — Case 경로로 Reference 를 지울 수 없다. */
+ Optional 둘은 같은 일이고 대상 종류만 다르다 — {@code document} 한 테이블이 Case 와 Reference 를 {@code document_type} 으로 나눠
+ * 갖는다. 종류를 인자로 받아 조회 조건에 넣는 이유는, 그렇게 하지 않으면 Case 경로로 Reference 를 지울 수 있게 되기 때문이다.
+ *
+ * 공개된 기록은 지우지 않는다. 공개 화면과 검색과 다른 기록의 링크가 그것을 가리키고 있고, 조용히 사라지면 읽던 쪽에서 무슨 일이 있었는지 알 방법이 없다 — 공개
+ * 취소가 그 경로다.
+ */
+@RequiresPermission(StudioPermissions.WRITE)
+@UseCaseCapability(
+ transactionMode = TransactionMode.WRITE,
+ idempotency = Idempotency.NOT_IDEMPOTENT,
+ repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
+public class DeleteDocumentDraftUseCase {
+
+ private final DocumentDeletionPort documents;
+ private final TransactionPort transactions;
+
+ public DeleteDocumentDraftUseCase(DocumentDeletionPort documents, TransactionPort transactions) {
+ this.documents = Objects.requireNonNull(documents, "documents");
+ this.transactions = Objects.requireNonNull(transactions, "transactions");
+ }
+
+ public void handle(DeleteDocumentCommand command, String documentType) {
+ Objects.requireNonNull(command, "command");
+ Objects.requireNonNull(documentType, "documentType");
+ transactions.inWrite(
+ () -> {
+ DocumentDeletionPort.DeletableDocument current =
+ documents
+ .findDocument(command.id(), documentType)
+ .orElseThrow(
+ () ->
+ ManagementException.of(
+ ManagementError.DOCUMENT_NOT_FOUND, "no such working copy"));
+ if ("PUBLISHED".equals(current.workflowStatus())) {
+ throw ManagementException.of(
+ ManagementError.DOCUMENT_PUBLISHED,
+ "the record is published; unpublish it before deleting");
+ }
+ if (documents.documentReferenced(command.id())) {
+ throw ManagementException.of(
+ ManagementError.DOCUMENT_IN_USE,
+ "another record still links to this one; unlink it first");
+ }
+ if (documents.deleteDocument(command.id(), command.expectedVersion()) == 0) {
+ throw ManagementException.withDetails(
+ ManagementError.VERSION_CONFLICT,
+ "the working copy changed since it was loaded",
+ new SaveTopicUseCase.VersionConflict(current.version()));
+ }
+ return null;
+ });
+ }
+}
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java
new file mode 100644
index 0000000..310f802
--- /dev/null
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java
@@ -0,0 +1,60 @@
+package dev.caskeleton.application.techlog.management.service;
+
+import dev.caskeleton.application.capability.Idempotency;
+import dev.caskeleton.application.capability.RepositoryAccess;
+import dev.caskeleton.application.capability.UseCaseCapability;
+import dev.caskeleton.application.security.RequiresPermission;
+import dev.caskeleton.application.techlog.error.ManagementError;
+import dev.caskeleton.application.techlog.error.ManagementException;
+import dev.caskeleton.application.techlog.management.command.DeleteDocumentCommand;
+import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort;
+import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
+import dev.caskeleton.application.transaction.TransactionMode;
+import dev.caskeleton.application.transaction.TransactionPort;
+import java.util.Objects;
+
+/**
+ * {@code deleteQuestion}. 질문은 {@code document} 가 아니라 {@code open_question} 이 갖고 있어 별도 use case 다 —
+ * 같은 화면의 같은 줄로 보이지만 다른 테이블이다.
+ */
+@RequiresPermission(StudioPermissions.WRITE)
+@UseCaseCapability(
+ transactionMode = TransactionMode.WRITE,
+ idempotency = Idempotency.NOT_IDEMPOTENT,
+ repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
+public class DeleteQuestionUseCase {
+
+ private final DocumentDeletionPort documents;
+ private final TransactionPort transactions;
+
+ public DeleteQuestionUseCase(DocumentDeletionPort documents, TransactionPort transactions) {
+ this.documents = Objects.requireNonNull(documents, "documents");
+ this.transactions = Objects.requireNonNull(transactions, "transactions");
+ }
+
+ public void handle(DeleteDocumentCommand command) {
+ Objects.requireNonNull(command, "command");
+ transactions.inWrite(
+ () -> {
+ DocumentDeletionPort.DeletableQuestion current =
+ documents
+ .findQuestion(command.id())
+ .orElseThrow(
+ () ->
+ ManagementException.of(
+ ManagementError.QUESTION_NOT_FOUND, "no such question"));
+ if (documents.questionReferenced(command.id())) {
+ throw ManagementException.of(
+ ManagementError.QUESTION_IN_USE,
+ "another record still links to this question; unlink it first");
+ }
+ if (documents.deleteQuestion(command.id(), command.expectedVersion()) == 0) {
+ throw ManagementException.withDetails(
+ ManagementError.VERSION_CONFLICT,
+ "the question changed since it was loaded",
+ new SaveTopicUseCase.VersionConflict(current.version()));
+ }
+ return null;
+ });
+ }
+}
diff --git a/src/config/openapi/studio-management-v1.yaml b/src/config/openapi/studio-management-v1.yaml
index 46e7563..36c1573 100644
--- a/src/config/openapi/studio-management-v1.yaml
+++ b/src/config/openapi/studio-management-v1.yaml
@@ -5,11 +5,12 @@ info:
description: |-
⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다.
- topics 4개, projects 5개, releases 7개를 studio-v1.yaml과 같은 방식으로
- 변환했다 (`application/json` + ErrorEnvelope /