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 findDocument(UUID id, String documentType) { + return jdbcClient + .sql( + "SELECT id, document_type, workflow_status, version FROM document" + + " WHERE id = :id AND document_type = :type") + .param("id", id) + .param("type", documentType) + .query(JdbcDocumentDeletionAdapter::mapDocument) + .optional(); + } + + @Override + public int deleteDocument(UUID id, long expectedVersion) { + return jdbcClient + .sql("DELETE FROM document WHERE id = :id AND version = :expected") + .param("id", id) + .param("expected", expectedVersion) + .update(); + } + + @Override + public boolean documentReferenced(UUID id) { + return Boolean.TRUE.equals( + jdbcClient + .sql( + "SELECT EXISTS (" + + " SELECT 1 FROM document_relation WHERE target_document_id = :id" + + " UNION ALL SELECT 1 FROM question_document_link WHERE document_id = :id" + + " UNION ALL SELECT 1 FROM project_document_link WHERE document_id = :id" + + " UNION ALL SELECT 1 FROM topic_featured_document WHERE document_id = :id" + + " UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id" + + " UNION ALL SELECT 1 FROM public_resource_projection WHERE document_id = :id" + + ")") + .param("id", id) + .query(Boolean.class) + .single()); + } + + @Override + public boolean questionReferenced(UUID id) { + return Boolean.TRUE.equals( + jdbcClient + .sql( + "SELECT EXISTS (" + + " SELECT 1 FROM project_question_link WHERE question_id = :id" + + " UNION ALL SELECT 1 FROM home_focus_config WHERE open_question_id = :id" + + ")") + .param("id", id) + .query(Boolean.class) + .single()); + } + + @Override + public Optional findQuestion(UUID id) { + return jdbcClient + .sql("SELECT id, version FROM open_question WHERE id = :id") + .param("id", id) + .query(JdbcDocumentDeletionAdapter::mapQuestion) + .optional(); + } + + @Override + public int deleteQuestion(UUID id, long expectedVersion) { + return jdbcClient + .sql("DELETE FROM open_question WHERE id = :id AND version = :expected") + .param("id", id) + .param("expected", expectedVersion) + .update(); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java index 32102d9..d333e69 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java @@ -1,12 +1,15 @@ package dev.caskeleton.bootstrap.techlog; +import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort; import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort; import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase; import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase; import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase; +import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase; import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase; +import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase; import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase; import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase; import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase; @@ -107,4 +110,15 @@ public class TechLogManagementConfig { ArchiveReleaseUseCase archiveReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) { return new ArchiveReleaseUseCase(releases, tx); } + + @Bean + DeleteDocumentDraftUseCase deleteDocumentDraftUseCase( + DocumentDeletionPort documents, TransactionPort tx) { + return new DeleteDocumentDraftUseCase(documents, tx); + } + + @Bean + DeleteQuestionUseCase deleteQuestionUseCase(DocumentDeletionPort documents, TransactionPort tx) { + return new DeleteQuestionUseCase(documents, tx); + } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java index 52b9b86..80ded76 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java @@ -10,7 +10,7 @@ import dev.caskeleton.shared.error.Category; *

{@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 findDocument(UUID id, String documentType); + + int deleteDocument(UUID id, long expectedVersion); + + /** + * 자기 자식이 아닌 곳에서 이 작업본을 가리키고 있는지. 그런 참조는 {@code ON DELETE CASCADE} 가 아니므로 그대로 지우면 외래키 위반이 되고, 그건 + * 작성자에게 500 으로 도착한다. + */ + boolean documentReferenced(UUID id); + + Optional findQuestion(UUID id); + + int deleteQuestion(UUID id, long expectedVersion); + + boolean questionReferenced(UUID id); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java new file mode 100644 index 0000000..c1bedd8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java @@ -0,0 +1,71 @@ +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 deleteCaseDraft} 와 {@code deleteReferenceDraft}. + * + *

둘은 같은 일이고 대상 종류만 다르다 — {@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 / Envelope). 그 16개가 - 구현된 것이자 소비자가 있는 것이기 때문이다. + topics 4개, projects 5개, releases 7개, 그리고 작업본 삭제 3개 + (deleteCaseDraft / deleteReferenceDraft / deleteQuestion)를 studio-v1.yaml과 + 같은 방식으로 변환했다 (`application/json` + ErrorEnvelope / Envelope). + 그 19개가 구현된 것이자 소비자가 있는 것이기 때문이다. - 나머지 63개는 아직 bare payload + `application/problem+json` + ProblemDetails + 나머지 60개는 아직 bare payload + `application/problem+json` + ProblemDetails 다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리 변환해 두면 검증되지 않은 모양이 계약에 고정된다. @@ -260,45 +261,45 @@ paths: '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -509,45 +510,45 @@ paths: '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -1795,45 +1796,45 @@ paths: '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -5270,6 +5271,11 @@ components: - RELEASE_NOT_FOUND - RELEASE_VERSION_TAKEN - RELEASE_NOT_PUBLISHABLE + - DOCUMENT_NOT_FOUND + - DOCUMENT_PUBLISHED + - DOCUMENT_IN_USE + - QUESTION_NOT_FOUND + - QUESTION_IN_USE - INTERNAL_ERROR description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' category: