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 index babf0b9..ab5b9f2 100644 --- 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 @@ -162,22 +162,50 @@ public class JdbcDocumentDeletionAdapter implements DocumentDeletionPort { } @Override - public boolean hasPublicationHistory(String sourceKind, UUID id) { + public boolean isCurrentlyPublished(String sourceKind, UUID id) { return Boolean.TRUE.equals( jdbcClient .sql( - "SELECT EXISTS (" - + " SELECT 1 FROM publication" - + " WHERE source_kind = :kind AND source_id = :id" - + " UNION ALL SELECT 1 FROM publication_event" - + " WHERE source_kind = :kind AND source_id = :id" - + ")") + "SELECT EXISTS (SELECT 1 FROM publication" + + " WHERE source_kind = :kind AND source_id = :id AND status = 'PUBLISHED')") .param("kind", sourceKind) .param("id", id) .query(Boolean.class) .single()); } + /** + * 이벤트와 게시를 한 문장으로 지운다. + * + *
둘은 서로를 가리키고, 한쪽 방향만 지연 검사다 — 어떤 순서로 나눠 지워도 중간 상태에서 한쪽 제약이 깨진다. 한 문장 안에서는 외래키 검사가 문장 끝에 한 번 + * 도는 덕에 그 중간 상태가 존재하지 않는다. 순서를 맞추는 대신 순서가 필요 없게 만든다. + */ + @Override + public int deletePublicationHistory(String sourceKind, UUID id) { + int snapshots = + jdbcClient + .sql( + "DELETE FROM publication_snapshot WHERE publication_event_id IN" + + " (SELECT publication_event_id FROM publication_event" + + " WHERE source_kind = :kind AND source_id = :id)") + .param("kind", sourceKind) + .param("id", id) + .update(); + int removed = + jdbcClient + .sql( + "WITH gone_events AS (" + + " DELETE FROM publication_event" + + " WHERE source_kind = :kind AND source_id = :id" + + " RETURNING publication_event_id" + + ")" + + " DELETE FROM publication WHERE source_kind = :kind AND source_id = :id") + .param("kind", sourceKind) + .param("id", id) + .update(); + return snapshots + removed; + } + /** 미리보기가 검증을 참조하므로 미리보기를 먼저 지운다. */ @Override public int deleteWorkArtifacts(String sourceKind, UUID id) { diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java index 471efac..3d24c57 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java @@ -271,16 +271,36 @@ class ManagementPersistenceIntegrationTest { } @Test - void seesPublicationHistoryThroughTheKindAndIdPair() { - // 게시 이력은 문서를 외래키 없이 (source_kind, source_id) 로 가리킨다. 이 쿼리가 없어서 - // 지운 문서를 가리키는 이력이 남았고, 대시보드와 게시 기록 화면이 null 을 읽고 죽었다. + void seesOnlyAPublicationThatIsStillLive() { + // 막아야 하는 것은 "게시한 적이 있다" 가 아니라 "지금 읽히고 있다" 다 — 게시를 취소한 기록을 + // 영영 지울 수 없게 하면, 작성자가 공개를 취소하는 이유 자체가 막힌다. UUID id = insertCase("게시된 적 있는 기록"); - assertThat(deletion.hasPublicationHistory("CASE", id)).isFalse(); + assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse(); insertPublication(id); + assertThat(deletion.isCurrentlyPublished("CASE", id)).isTrue(); + assertThat(deletion.isCurrentlyPublished("QUESTION", id)).isFalse(); - assertThat(deletion.hasPublicationHistory("CASE", id)).isTrue(); - assertThat(deletion.hasPublicationHistory("QUESTION", id)).isFalse(); + jdbcClient + .sql("UPDATE publication SET status = 'UNPUBLISHED' WHERE source_id = :id") + .param("id", id) + .update(); + assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse(); + } + + @Test + void clearsThePublicationHistoryInDependencyOrder() { + // 이력은 기록에 무슨 일이 있었는지 말하는 것이라, 기록이 사라지면 아무것도 가리키지 않는다. + // 외래키가 없어 DB 가 대신 지워 주지 않으므로, 순서까지 여기서 확인한다. + UUID id = insertCase("이력을 남긴 기록"); + insertPublication(id); + assertThat(countIn("publication", "source_id", id)).isEqualTo(1); + assertThat(countIn("publication_event", "source_id", id)).isEqualTo(1); + + // 반환값은 지운 publication 행 수다 — 이벤트는 같은 문장에서 함께 사라지므로 따로 세지 않는다. + assertThat(deletion.deletePublicationHistory("CASE", id)).isEqualTo(1); + assertThat(countIn("publication", "source_id", id)).isZero(); + assertThat(countIn("publication_event", "source_id", id)).isZero(); } @Test 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 index 529cd52..eae0541 100644 --- 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 @@ -46,10 +46,18 @@ public interface DocumentDeletionPort { boolean questionReferenced(UUID id); /** - * 게시 이력이 남아 있는지. {@code publication}/{@code publication_event} 는 문서를 외래키 없이 {@code (source_kind, - * source_id)} 로 가리키므로 DB 가 막아 주지 않는다 — 실제로 그래서 지운 문서를 가리키는 이력이 남아 대시보드와 게시 기록 화면이 null 을 읽고 죽었다. + * 지금 공개돼 있는지. + * + *
처음에는 "게시한 적이 있는가" 로 막았는데, 그러면 게시를 취소한 기록을 영영 지울 수 없다 — 작성자가 공개를 취소하는 이유가 대개 없애려는 것인데도. 막아야 + * 하는 것은 지금 읽히고 있는 것이 소리 없이 사라지는 일 이므로 조건도 그것이다. */ - boolean hasPublicationHistory(String sourceKind, UUID id); + boolean isCurrentlyPublished(String sourceKind, UUID id); + + /** + * 게시 이력을 치운다. 이력은 어떤 기록에 무슨 일이 있었는지 말하는 것이라, 그 기록이 사라지면 아무것도 가리키지 않는다 — 실제로 그렇게 남은 행들이 대시보드와 게시 + * 기록 화면을 죽였다. {@code publication} 계열은 외래키가 없어 DB 가 대신 해 주지 않는다. + */ + int deletePublicationHistory(String sourceKind, UUID id); /** Decision 은 프로젝트에 속한다 — 경로가 둘 다 들고 있으므로 둘로 찾는다. */ record DeletableDecision(UUID id, String decisionStatus, long version) {} 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 index 17384fa..bcc6064 100644 --- 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 @@ -49,19 +49,15 @@ public class DeleteDocumentDraftUseCase { () -> ManagementException.of( ManagementError.DOCUMENT_NOT_FOUND, "no such working copy")); + // 지금 읽히고 있는 것만 막는다. 세 곳이 공개 여부를 따로 들고 있으므로 셋 다 본다 — + // 어긋난 상태로 지우면 공개 화면이 없는 기록을 가리키게 된다. if ("PUBLISHED".equals(current.workflowStatus()) - || documents.publiclyProjected(documentType, command.id())) { + || documents.publiclyProjected(documentType, command.id()) + || documents.isCurrentlyPublished(documentType, command.id())) { throw ManagementException.of( ManagementError.DOCUMENT_PUBLISHED, "the record is published; unpublish it before deleting"); } - // 게시된 적이 있으면 이력이 남아 있다. 그 이력은 무슨 일이 있었는지에 대한 기록이고, - // 문서만 지우면 아무것도 가리키지 않는 이력이 되어 화면을 깨뜨린다. - if (documents.hasPublicationHistory(documentType, command.id())) { - throw ManagementException.of( - ManagementError.DOCUMENT_IN_USE, - "the record has publication history; it cannot be deleted"); - } if (documents.documentReferenced(command.id())) { throw ManagementException.of( ManagementError.DOCUMENT_IN_USE, @@ -71,6 +67,7 @@ public class DeleteDocumentDraftUseCase { // 대신 해 주지 않고, 남겨 두면 없는 기록을 가리키는 행이 된다. documents.deleteProjection(documentType, command.id()); documents.deleteWorkArtifacts(documentType, command.id()); + documents.deletePublicationHistory(documentType, command.id()); if (documents.deleteDocument(command.id(), command.expectedVersion()) == 0) { throw ManagementException.withDetails( ManagementError.VERSION_CONFLICT, diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java index aa1a605..746e62b 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java @@ -50,10 +50,10 @@ public class DeleteProjectDecisionUseCase { () -> ManagementException.of( ManagementError.DECISION_NOT_FOUND, "no such decision")); - if (documents.hasPublicationHistory("PROJECT_DECISION", decisionId)) { + if (documents.isCurrentlyPublished("PROJECT_DECISION", decisionId)) { throw ManagementException.of( ManagementError.DECISION_IN_USE, - "the decision has publication history; it cannot be deleted"); + "the decision is published; unpublish it before deleting"); } if (documents.decisionReferenced(decisionId)) { throw ManagementException.of( @@ -62,6 +62,7 @@ public class DeleteProjectDecisionUseCase { } documents.deleteProjection("PROJECT_DECISION", decisionId); documents.deleteWorkArtifacts("PROJECT_DECISION", decisionId); + documents.deletePublicationHistory("PROJECT_DECISION", decisionId); if (documents.deleteDecision(decisionId, expectedVersion) == 0) { throw ManagementException.withDetails( ManagementError.VERSION_CONFLICT, 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 index b0bd700..6ceecfe 100644 --- 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 @@ -43,16 +43,12 @@ public class DeleteQuestionUseCase { () -> ManagementException.of( ManagementError.QUESTION_NOT_FOUND, "no such question")); - if (documents.publiclyProjected("QUESTION", command.id())) { + if (documents.publiclyProjected("QUESTION", command.id()) + || documents.isCurrentlyPublished("QUESTION", command.id())) { throw ManagementException.of( ManagementError.DOCUMENT_PUBLISHED, "the question is published; unpublish it before deleting"); } - if (documents.hasPublicationHistory("QUESTION", command.id())) { - throw ManagementException.of( - ManagementError.QUESTION_IN_USE, - "the question has publication history; it cannot be deleted"); - } if (documents.questionReferenced(command.id())) { throw ManagementException.of( ManagementError.QUESTION_IN_USE, @@ -60,6 +56,7 @@ public class DeleteQuestionUseCase { } documents.deleteProjection("QUESTION", command.id()); documents.deleteWorkArtifacts("QUESTION", command.id()); + documents.deletePublicationHistory("QUESTION", command.id()); if (documents.deleteQuestion(command.id(), command.expectedVersion()) == 0) { throw ManagementException.withDetails( ManagementError.VERSION_CONFLICT, diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java index d6cb3e7..0c1a864 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java @@ -55,6 +55,15 @@ public final class StudioDocumentValidator { return new Outcome(status, issues.toList()); } + /** + * 게시를 막는 것은 두 가지뿐이다: 제목과 slug. + * + *
예전에는 Case 하나에 일곱 칸을 요구했고, 보여 줄 만한 초안을 가진 작성자가 그것을 보여 줄 수 없었다 — 기록을 남기라고 만든 도구가 기록을 막고 있었다. + * 무엇을 얼마나 쓸지는 작성자가 정하고, 덜 쓴 기록은 덜 쓴 채로 공개된다. + * + *
남긴 둘은 취향이 아니라 도달 가능성이다. 제목이 없으면 목록에 실을 수 없고, slug 가 없으면 가리킬 주소가 없다. 그 밖에 오류로 남은 것들은 전부 "가리키는
+ * 대상이 없다" 는 문제다 — 없는 주제·프로젝트·관계·Asset 을 가리킨 채 공개하면 읽는 쪽에서 깨진다.
+ */
private static void validateBase(WorkingCopyBaseInput base, ValidationIssues issues) {
requireText(issues, base.title(), "TITLE_REQUIRED", "/title", "a title is required to publish");
// 렌더 모델의 slug 는 minLength 3 + 패턴이다. 저장은 빈 slug 를 허용하지만 게시는 못 한다.
@@ -63,10 +72,10 @@ public final class StudioDocumentValidator {
} else if (base.slug().length() < 3) {
issues.error("SLUG_TOO_SHORT", "/slug", "a slug must be at least 3 characters");
}
- requireText(
+ warnIfBlank(
issues, base.summary(), "SUMMARY_REQUIRED", "/summary", "a summary is required to publish");
if (base.topicId() == null) {
- issues.error("TOPIC_REQUIRED", "/topicId", "a topic is required to publish");
+ issues.warning("TOPIC_REQUIRED", "/topicId", "a topic is required to publish");
}
}
@@ -85,7 +94,8 @@ public final class StudioDocumentValidator {
== dev.caskeleton.application.techlog.studio.model.RecordKind.PROJECT_DECISION
&& document.base().projectId() == null) {
// 계약: "kind=PROJECT_DECISION 은 게시 시점에 non-null 이어야 한다."
- issues.error("PROJECT_REQUIRED", "/projectId", "a project decision must belong to a project");
+ issues.warning(
+ "PROJECT_REQUIRED", "/projectId", "a project decision must belong to a project");
}
List