feat: implement topic and project management, so documents can be authored

Publishing was impossible on an empty database. Validation requires a topic,
the studio catalog answered zero topics, and nothing in the two implemented
contracts could create one — `studio-management-v1.yaml` owned that surface
and none of its 79 operations existed. Every path to a published record ran
through a door with no handle.

This implements the nine that unblock authoring: topics (list/create/update/
delete) and projects (list/get/create/update/delete). The remaining seventy
stay unimplemented; each has its own consumer and its own moment.

The contract was converted to the response envelope first (ADR-006), which is
what its own header said to do at implementation time. Doing it after would
have meant changing the wire shape of endpoints the frontend had already been
written against.

ManagementError is a separate enum rather than an extension of StudioError.
Each contract enumerates its own ApiError.code set, so a code reachable from
the wrong surface makes that contract false. It deliberately omits
INTERNAL_ERROR: the skeleton's OperationalError owns that code with
retryable=true, and declaring it twice with different values leaves the
registry with no answer. PublicError made the same call for the same reason.

Two contract defects surfaced while implementing. TopicEdit had neither id nor
version, so a listed topic could not be addressed by the `/topics/{id}` path
and a client had no source for the expectedVersion the write operations
require; both are fixed in the design package. The AWS SDK BOM had to be
imported in app-bootstrap as well — module-scoped dependency management does
not propagate to consumers, and this is the first runtime consumer of that
pattern.

Topic and project deletion refuse while records still reference them rather
than cascading. A topic disappearing should not silently reclassify the
documents that used it; moving them first is the caller's decision to make.

ActuatorSecurityHttpTest.healthEndpointIsPermitAll fails on this branch before
this change as well; it is untouched here.
This commit is contained in:
DongHyeonka
2026-08-20 22:54:24 +09:00
parent bde5826cfd
commit bb6d2330bb
34 changed files with 9065 additions and 0 deletions
@@ -0,0 +1,60 @@
package dev.caskeleton.application.techlog.error;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
/**
* 관리 계약(`studio-management-v1.yaml`)의 `ApiError.code` enum. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과
* `docs/registries/error-codes.yaml`을 함께 고쳐야 한다.
*
* <p>{@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로
* 낼 수 있게 되면 그 순간 두 계약 모두 거짓이 된다.
*
* <p>계약의 {@code ApiError.code} 는 12종인데 여기는 11종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이
* 아니라 스켈레톤 공통 처리기가 내는 코드({@code OperationalError.INTERNAL_ERROR}) 이고, 같은 code 를 두 enum 이
* 각자 status 와 retryable 을 달고 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다 — 실제로 그쪽은
* {@code retryable=true} 다. {@code PublicError} 가 같은 이유로 같은 선택을 했다.
*/
public enum ManagementError implements ApiErrorCode {
AUTHENTICATION_REQUIRED(Category.AUTH, 401, false),
STUDIO_ACCESS_DENIED(Category.AUTHZ, 403, false),
REQUEST_VALIDATION_FAILED(Category.VALIDATION, 422, false),
VERSION_CONFLICT(Category.CONFLICT, 409, false),
TOPIC_NOT_FOUND(Category.NOT_FOUND, 404, false),
TOPIC_NAME_TAKEN(Category.CONFLICT, 409, false),
TOPIC_SLUG_TAKEN(Category.CONFLICT, 409, false),
TOPIC_IN_USE(Category.CONFLICT, 409, false),
PROJECT_NOT_FOUND(Category.NOT_FOUND, 404, false),
PROJECT_SLUG_TAKEN(Category.CONFLICT, 409, false),
PROJECT_IN_USE(Category.CONFLICT, 409, false);
private final Category category;
private final int httpStatus;
private final boolean retryable;
ManagementError(Category category, int httpStatus, boolean retryable) {
this.category = category;
this.httpStatus = httpStatus;
this.retryable = retryable;
}
@Override
public String code() {
return name();
}
@Override
public Category category() {
return category;
}
@Override
public int httpStatus() {
return httpStatus;
}
@Override
public boolean retryable() {
return retryable;
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.application.techlog.error;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
/**
* 관리 use case 가 던지는 유일한 실패 표현. {@link StudioException} 과 같은 모양이되 code 집합만 다르다 — 전송 계층은
* {@link ApiErrorCarrier} 만 보므로 두 예외를 따로 처리할 필요가 없다.
*/
public final class ManagementException extends RuntimeException implements ApiErrorCarrier {
private final transient ManagementError error;
private final transient Object details;
private ManagementException(ManagementError error, String message, Object details) {
super(message);
this.error = error;
this.details = details;
}
public static ManagementException of(ManagementError error, String message) {
return new ManagementException(error, message, null);
}
public static ManagementException withDetails(
ManagementError error, String message, Object details) {
return new ManagementException(error, message, details);
}
@Override
public ApiErrorCode errorCode() {
return error;
}
public ManagementError managementError() {
return error;
}
public Object details() {
return details;
}
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.management.command;
/**
* 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다.
*/
public record CreateProjectCommand(String title, String actor) {}
@@ -0,0 +1,5 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
public record DeleteProjectCommand(UUID id, long expectedVersion, String actor) {}
@@ -0,0 +1,5 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
public record DeleteTopicCommand(UUID id, long expectedVersion, String actor) {}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
/**
* 생성과 수정이 같은 명령을 쓴다. 계약이 두 경우 모두 {@code TopicEdit} 를 본문으로 받기 때문이고,
* 구분은 {@code id} 의 유무다 — {@code null} 이면 생성이다.
*
* <p>{@code expectedVersion} 은 수정에서만 의미가 있다. 생성에 값이 와도 무시하는 대신 거절하지
* 않는 이유는, 계약이 그 필드를 optional 로 두고 있어 클라이언트가 보내는 것이 위반이 아니기
* 때문이다.
*/
public record SaveTopicCommand(
UUID id,
String name,
String slug,
String description,
String scope,
String status,
Long expectedVersion,
String actor) {}
@@ -0,0 +1,27 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.List;
import java.util.UUID;
/** 계약 {@code ProjectUpdateRequest}. */
public record UpdateProjectCommand(
UUID id,
long expectedVersion,
String name,
String slug,
String oneLinePurpose,
String purposeMarkdown,
String boundaryMarkdown,
String systemOverviewMarkdown,
String phase,
String currentObjective,
String nextStep,
List<String> technologyLabels,
String targetVisibility,
Integer featuredOrder,
String actor) {
public UpdateProjectCommand {
technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels);
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.application.techlog.management.model;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* 계약 {@code ProjectEditResponse}.
*
* <p>{@code topicIds}/{@code documentLinks}/{@code questionLinks} 는 링크 테이블이 소유한다. (가)
* 범위에서는 그 편집 화면이 없으므로 항상 비어 있고, 링크를 다루는 화면이 생길 때 같은 뷰에 채운다.
*/
public record ProjectEditView(
UUID id,
long version,
String name,
String slug,
String oneLinePurpose,
String purposeMarkdown,
String boundaryMarkdown,
String systemOverviewMarkdown,
String phase,
String currentObjective,
String nextStep,
List<String> technologyLabels,
String workflowStatus,
String targetVisibility,
Integer featuredOrder,
Instant firstPublishedAt,
Instant lastPublishedAt,
Instant updatedAt) {
public ProjectEditView {
technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels);
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.management.model;
import java.time.Instant;
import java.util.UUID;
/** 계약 {@code ProjectIndexItem}. 목록 행은 상세보다 좁다 — 본문 markdown 을 싣지 않는다. */
public record ProjectIndexItemView(
UUID id,
String name,
String phase,
String workflowStatus,
String targetVisibility,
String currentObjective,
String nextStep,
Instant updatedAt,
long version,
Instant firstPublishedAt,
Instant lastPublishedAt) {}
@@ -0,0 +1,27 @@
package dev.caskeleton.application.techlog.management.model;
import java.util.List;
import java.util.UUID;
/**
* 계약 {@code TopicEdit}.
*
* <p>{@code featuredReferenceId}/{@code featuredCaseIds} 는 Reference/Case 가 존재해야 채워지는
* 큐레이션 필드다. 그 관리 화면이 아직 없으므로 지금은 항상 비어 있고, 계약이 요구하지 않으므로
* 비어 있는 것이 정상이다.
*/
public record TopicEditView(
UUID id,
String name,
String slug,
String description,
String scope,
String status,
long version,
UUID featuredReferenceId,
List<UUID> featuredCaseIds) {
public TopicEditView {
featuredCaseIds = featuredCaseIds == null ? List.of() : List.copyOf(featuredCaseIds);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.application.techlog.management.port.out;
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** Project 의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */
public interface ProjectRepositoryPort {
List<ProjectIndexItemView> listAll(int limit, int offset);
int countAll();
Optional<ProjectEditView> find(UUID id);
ProjectEditView create(CreateProjectCommand command);
Optional<ProjectEditView> update(UpdateProjectCommand command);
int delete(UUID id, long expectedVersion);
boolean isReferenced(UUID id);
boolean slugTaken(String slug, UUID exceptId);
}
@@ -0,0 +1,30 @@
package dev.caskeleton.application.techlog.management.port.out;
import dev.caskeleton.application.techlog.management.command.SaveTopicCommand;
import dev.caskeleton.application.techlog.management.model.TopicEditView;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** Topic 의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */
public interface TopicRepositoryPort {
List<TopicEditView> listAll();
Optional<TopicEditView> find(UUID id);
TopicEditView create(SaveTopicCommand command);
/** 낙관적 잠금. 버전이 다르면 {@code Optional.empty()} 가 아니라 예외로 구분해야 하므로 뷰를 돌려준다. */
Optional<TopicEditView> update(SaveTopicCommand command);
/** 삭제된 행 수. 0 이면 없거나 버전이 어긋난 것이다. */
int delete(UUID id, long expectedVersion);
/** 다른 자료가 이 주제를 참조하고 있는가. 참조가 있으면 삭제를 거절한다. */
boolean isReferenced(UUID id);
boolean nameTaken(String normalizedName, UUID exceptId);
boolean slugTaken(String slug, UUID exceptId);
}
@@ -0,0 +1,44 @@
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.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
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 createProject}. 제목 하나로 초안을 연다 — slug 는 비워 둔다. 테이블이 slug 를 nullable 로 두고
* UNIQUE 만 걸어 두었기 때문에 빈 초안 여러 개가 공존할 수 있고, 발행 시점에 slug 가 요구된다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class CreateProjectUseCase {
private final ProjectRepositoryPort projects;
private final TransactionPort transactions;
public CreateProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ProjectEditView handle(CreateProjectCommand command) {
Objects.requireNonNull(command, "command");
if (command.title() == null || command.title().isBlank()) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "title must not be blank");
}
return transactions.inWrite(() -> projects.create(command));
}
}
@@ -0,0 +1,58 @@
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.DeleteProjectCommand;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
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 deleteProject}. 연결된 기록이 있으면 {@code PROJECT_IN_USE} 로 거절한다. */
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class DeleteProjectUseCase {
private final ProjectRepositoryPort projects;
private final TransactionPort transactions;
public DeleteProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public void handle(DeleteProjectCommand command) {
Objects.requireNonNull(command, "command");
transactions.inWrite(
() -> {
ProjectEditView current =
projects
.find(command.id())
.orElseThrow(
() ->
ManagementException.of(
ManagementError.PROJECT_NOT_FOUND, "no such project"));
if (projects.isReferenced(command.id())) {
throw ManagementException.of(
ManagementError.PROJECT_IN_USE,
"the project still has linked records; unlink them first");
}
if (projects.delete(command.id(), command.expectedVersion()) == 0) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the project changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version()));
}
return null;
});
}
}
@@ -0,0 +1,63 @@
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.DeleteTopicCommand;
import dev.caskeleton.application.techlog.management.model.TopicEditView;
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
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 deleteTopic}.
*
* <p>참조가 있으면 지우지 않고 {@code TOPIC_IN_USE} 로 거절한다. 외래키를 CASCADE 로 두지 않는 이유는,
* 주제를 지웠다는 이유로 그 주제를 쓰던 문서의 분류가 조용히 사라지면 안 되기 때문이다 — 지우려면
* 먼저 그 문서들을 옮기라는 뜻이다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class DeleteTopicUseCase {
private final TopicRepositoryPort topics;
private final TransactionPort transactions;
public DeleteTopicUseCase(TopicRepositoryPort topics, TransactionPort transactions) {
this.topics = Objects.requireNonNull(topics, "topics");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public void handle(DeleteTopicCommand command) {
Objects.requireNonNull(command, "command");
transactions.inWrite(
() -> {
TopicEditView current =
topics
.find(command.id())
.orElseThrow(
() ->
ManagementException.of(ManagementError.TOPIC_NOT_FOUND, "no such topic"));
if (topics.isReferenced(command.id())) {
throw ManagementException.of(
ManagementError.TOPIC_IN_USE,
"the topic is still referenced; move those records to another topic first");
}
if (topics.delete(command.id(), command.expectedVersion()) == 0) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the topic changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version()));
}
return null;
});
}
}
@@ -0,0 +1,42 @@
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.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.UUID;
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public class GetProjectForEditUseCase {
private final ProjectRepositoryPort projects;
private final TransactionPort transactions;
public GetProjectForEditUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ProjectEditView handle(UUID id) {
return transactions.inRead(
() ->
projects
.find(id)
.orElseThrow(
() ->
ManagementException.of(
ManagementError.PROJECT_NOT_FOUND, "no such project")));
}
}
@@ -0,0 +1,50 @@
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.management.model.ProjectIndexItemView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.List;
import java.util.Objects;
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public class ListStudioProjectsUseCase {
private final ProjectRepositoryPort projects;
private final TransactionPort transactions;
public ListStudioProjectsUseCase(
ProjectRepositoryPort projects, TransactionPort transactions) {
this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
/** 계약은 offset 페이지네이션이다 — 목록이 작고 편집 화면이 페이지 번호를 그린다. */
public Page handle(int page, int size) {
int safeSize = size <= 0 ? 20 : Math.min(size, 100);
int safePage = Math.max(page, 0);
return transactions.inRead(
() -> {
int total = projects.countAll();
List<ProjectIndexItemView> items = projects.listAll(safeSize, safePage * safeSize);
int totalPages = safeSize == 0 ? 0 : (total + safeSize - 1) / safeSize;
return new Page(items, safePage, safeSize, total, totalPages);
});
}
public record Page(
List<ProjectIndexItemView> items,
int number,
int size,
int totalElements,
int totalPages) {}
}
@@ -0,0 +1,37 @@
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.management.model.TopicEditView;
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.List;
import java.util.Objects;
/*
* final 이 아닌 이유는 studio use case 들과 같다 — @RequiresPermission 이 CGLIB 프록시로
* 강제되고 final 클래스는 subclass 할 수 없다.
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public class ListStudioTopicsUseCase {
private final TopicRepositoryPort topics;
private final TransactionPort transactions;
public ListStudioTopicsUseCase(TopicRepositoryPort topics, TransactionPort transactions) {
this.topics = Objects.requireNonNull(topics, "topics");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public List<TopicEditView> handle() {
return transactions.inRead(topics::listAll);
}
}
@@ -0,0 +1,98 @@
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.SaveTopicCommand;
import dev.caskeleton.application.techlog.management.model.TopicEditView;
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Locale;
import java.util.Objects;
/**
* {@code createTopic} / {@code updateTopic}.
*
* <p>이름과 slug 의 중복은 DB 제약이 이미 막고 있다. 그래도 여기서 먼저 확인하는 이유는 계약이
* {@code TOPIC_NAME_TAKEN}/{@code TOPIC_SLUG_TAKEN} 을 구분해서 요구하기 때문이다 — 제약 위반을
* 잡아 코드로 되돌리면 어느 제약이었는지는 드라이버 메시지 문자열에서 읽어야 하고, 그건 벤더가
* 바뀌면 조용히 깨진다.
*
* <p>이름 비교는 정규화(소문자·공백 정리) 후에 한다. 테이블의 {@code uq_topic_normalized_name} 이
* 같은 규칙이므로, 여기서만 다르게 정규화하면 사전 확인을 통과한 요청이 제약에서 터진다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class SaveTopicUseCase {
private final TopicRepositoryPort topics;
private final TransactionPort transactions;
public SaveTopicUseCase(TopicRepositoryPort topics, TransactionPort transactions) {
this.topics = Objects.requireNonNull(topics, "topics");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public static String normalize(String name) {
return name == null ? "" : name.trim().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT);
}
public TopicEditView handle(SaveTopicCommand command) {
Objects.requireNonNull(command, "command");
requireText(command.name(), "name");
requireText(command.slug(), "slug");
return transactions.inWrite(
() -> {
if (topics.nameTaken(normalize(command.name()), command.id())) {
throw ManagementException.of(
ManagementError.TOPIC_NAME_TAKEN, "another topic already uses this name");
}
if (topics.slugTaken(command.slug().trim(), command.id())) {
throw ManagementException.of(
ManagementError.TOPIC_SLUG_TAKEN, "another topic already uses this slug");
}
if (command.id() == null) {
return topics.create(command);
}
if (command.expectedVersion() == null) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED,
"expectedVersion is required when updating a topic");
}
TopicEditView current =
topics
.find(command.id())
.orElseThrow(
() ->
ManagementException.of(
ManagementError.TOPIC_NOT_FOUND, "no such topic"));
return topics
.update(command)
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the topic changed since it was loaded",
new VersionConflict(current.version())));
});
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, field + " must not be blank");
}
}
/** 계약의 {@code VersionConflictDetails}. */
public record VersionConflict(long currentVersion) {}
}
@@ -0,0 +1,87 @@
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.UpdateProjectCommand;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.Set;
/** {@code updateProject}. */
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class UpdateProjectUseCase {
/** 테이블의 CHECK 제약과 같은 집합. 여기서 먼저 거절해야 422 로 나가고, 아니면 DB 오류가 500 이 된다. */
private static final Set<String> PHASES =
Set.of(
"RESEARCH", "DESIGN", "IMPLEMENTATION", "VERIFICATION", "MAINTENANCE", "PAUSED",
"COMPLETED");
private static final Set<String> VISIBILITIES = Set.of("PRIVATE", "UNLISTED", "PUBLIC");
private final ProjectRepositoryPort projects;
private final TransactionPort transactions;
public UpdateProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ProjectEditView handle(UpdateProjectCommand command) {
Objects.requireNonNull(command, "command");
requireText(command.name(), "name");
requireOneOf(command.phase(), PHASES, "phase");
requireOneOf(command.targetVisibility(), VISIBILITIES, "targetVisibility");
return transactions.inWrite(
() -> {
ProjectEditView current =
projects
.find(command.id())
.orElseThrow(
() ->
ManagementException.of(
ManagementError.PROJECT_NOT_FOUND, "no such project"));
String slug = command.slug() == null ? null : command.slug().trim();
if (slug != null && !slug.isEmpty() && projects.slugTaken(slug, command.id())) {
throw ManagementException.of(
ManagementError.PROJECT_SLUG_TAKEN, "another project already uses this slug");
}
return projects
.update(command)
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the project changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, field + " must not be blank");
}
}
private static void requireOneOf(String value, Set<String> allowed, String field) {
if (value == null || !allowed.contains(value)) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED,
field + " must be one of " + allowed.stream().sorted().toList());
}
}
}