feat: let an author delete a working copy
An author who opens a draft and thinks better of it had no way out — the working-copy list could create, edit, validate and publish, and that was all. The three delete operations existed in the contract with no implementation. Deleting is not a cascade, which is the part worth being careful about. A document's own rows follow it: the detail row, its tags, the relations it points outward. But five tables reference `document` without ON DELETE CASCADE — another document's relation target, a question's link, a project's membership, a topic's featured list, a decision's source case — and two do the same for `open_question`. Deleting through any of them is a foreign-key violation, which reaches the author as a 500 that explains nothing. So the delete checks first and refuses with DOCUMENT_IN_USE, the same refusal TOPIC_IN_USE already makes. Quietly editing someone else's record to make room is the worse option. A published record is refused outright. Public pages, search and other records link to it, and one that vanishes leaves all of them pointing at nothing — unpublishing is the way out, and it already exists. Case and Reference share one table split by `document_type`, so the type is part of the lookup: without it, the Case route would happily delete a Reference. Decisions have no delete at all, and that is the contract's judgment rather than an omission — accept, reject and supersede record what happened instead of erasing it.
This commit is contained in:
@@ -1403,3 +1403,68 @@ errors:
|
||||
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
|
||||
+5
@@ -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 -> "이 질문을 참조하는 곳이 있어 삭제할 수 없습니다";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -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개.
|
||||
*
|
||||
* <p>경로가 종류별로 갈리는 것은 계약이 그렇게 선언했기 때문이고, 그럴 이유도 있다 — 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));
|
||||
}
|
||||
}
|
||||
+109
@@ -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;
|
||||
|
||||
/**
|
||||
* 작업본 삭제.
|
||||
*
|
||||
* <p>작업본 자신의 것(상세, 태그, 나가는 관계)은 {@code ON DELETE CASCADE} 로 따라 지워진다. 하지만 <em>다른</em> 기록이 이쪽을 가리키는
|
||||
* 참조는 그렇지 않다 — 다른 문서의 관계 대상, 질문의 링크, 프로젝트 소속, 주제의 추천 목록, 결정의 근거 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<DeletableDocument> 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<DeletableQuestion> 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();
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -10,7 +10,7 @@ import dev.caskeleton.shared.error.Category;
|
||||
* <p>{@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로 낼 수 있게 되면 그
|
||||
* 순간 두 계약 모두 거짓이 된다.
|
||||
*
|
||||
* <p>계약의 {@code ApiError.code} 는 15종인데 여기는 14종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통
|
||||
* <p>계약의 {@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;
|
||||
|
||||
+9
@@ -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) {}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.application.techlog.management.port.out;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 작업본 삭제.
|
||||
*
|
||||
* <p>편집·조회는 {@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<DeletableDocument> findDocument(UUID id, String documentType);
|
||||
|
||||
int deleteDocument(UUID id, long expectedVersion);
|
||||
|
||||
/**
|
||||
* 자기 자식이 아닌 곳에서 이 작업본을 가리키고 있는지. 그런 참조는 {@code ON DELETE CASCADE} 가 아니므로 그대로 지우면 외래키 위반이 되고, 그건
|
||||
* 작성자에게 500 으로 도착한다.
|
||||
*/
|
||||
boolean documentReferenced(UUID id);
|
||||
|
||||
Optional<DeletableQuestion> findQuestion(UUID id);
|
||||
|
||||
int deleteQuestion(UUID id, long expectedVersion);
|
||||
|
||||
boolean questionReferenced(UUID id);
|
||||
}
|
||||
+71
@@ -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}.
|
||||
*
|
||||
* <p>둘은 같은 일이고 대상 종류만 다르다 — {@code document} 한 테이블이 Case 와 Reference 를 {@code document_type} 으로 나눠
|
||||
* 갖는다. 종류를 인자로 받아 조회 조건에 넣는 이유는, 그렇게 하지 않으면 Case 경로로 Reference 를 지울 수 있게 되기 때문이다.
|
||||
*
|
||||
* <p>공개된 기록은 지우지 않는다. 공개 화면과 검색과 다른 기록의 링크가 그것을 가리키고 있고, 조용히 사라지면 읽던 쪽에서 무슨 일이 있었는지 알 방법이 없다 — 공개
|
||||
* 취소가 그 경로다.
|
||||
*/
|
||||
@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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+60
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@ info:
|
||||
description: |-
|
||||
⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다.
|
||||
|
||||
topics 4개, projects 5개, releases 7개를 studio-v1.yaml과 같은 방식으로
|
||||
변환했다 (`application/json` + ErrorEnvelope / <Payload>Envelope). 그 16개가
|
||||
구현된 것이자 소비자가 있는 것이기 때문이다.
|
||||
topics 4개, projects 5개, releases 7개, 그리고 작업본 삭제 3개
|
||||
(deleteCaseDraft / deleteReferenceDraft / deleteQuestion)를 studio-v1.yaml과
|
||||
같은 방식으로 변환했다 (`application/json` + ErrorEnvelope / <Payload>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:
|
||||
|
||||
Reference in New Issue
Block a user