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:
@@ -1264,3 +1264,103 @@ errors:
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: PublicErrorRegistryTest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TECH LOG STUDIO MANAGEMENT (studio-management-v1.yaml ApiError.code)
|
||||
#
|
||||
# ManagementError(dev.caskeleton.application.techlog.error.ManagementError)와 1:1.
|
||||
# AUTHENTICATION_REQUIRED / STUDIO_ACCESS_DENIED / REQUEST_VALIDATION_FAILED /
|
||||
# VERSION_CONFLICT 는 StudioError 에도 있어 행이 이미 존재한다 — 이 파일의 identity
|
||||
# column 은 `code` 이므로 중복 행을 만들지 않는다.
|
||||
# ---------------------------------------------------------------------------
|
||||
# source: studio-management-v1.yaml ApiError.code — TOPIC_NOT_FOUND (ManagementError.TOPIC_NOT_FOUND)
|
||||
- code: TOPIC_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 — TOPIC_NAME_TAKEN (ManagementError.TOPIC_NAME_TAKEN)
|
||||
- code: TOPIC_NAME_TAKEN
|
||||
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 — TOPIC_SLUG_TAKEN (ManagementError.TOPIC_SLUG_TAKEN)
|
||||
- code: TOPIC_SLUG_TAKEN
|
||||
category: CONFLICT
|
||||
http_status: 409
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-management-v1
|
||||
owner_layer: application
|
||||
client_safe_message: "같은 slug 의 주제가 이미 있습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: ManagementErrorRegistryTest
|
||||
# source: studio-management-v1.yaml ApiError.code — TOPIC_IN_USE (ManagementError.TOPIC_IN_USE)
|
||||
- code: TOPIC_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 — PROJECT_NOT_FOUND (ManagementError.PROJECT_NOT_FOUND)
|
||||
- code: PROJECT_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 — PROJECT_SLUG_TAKEN (ManagementError.PROJECT_SLUG_TAKEN)
|
||||
- code: PROJECT_SLUG_TAKEN
|
||||
category: CONFLICT
|
||||
http_status: 409
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-management-v1
|
||||
owner_layer: application
|
||||
client_safe_message: "같은 slug 의 프로젝트가 이미 있습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: ManagementErrorRegistryTest
|
||||
# source: studio-management-v1.yaml ApiError.code — PROJECT_IN_USE (ManagementError.PROJECT_IN_USE)
|
||||
- code: PROJECT_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
|
||||
@@ -32,6 +32,11 @@ sourceSets {
|
||||
// 얹어야 함)을 갖기 때문이다 — sourceSet 을 늘리면 그 배선을 한 벌 더 복제하게 된다.
|
||||
java.srcDir(layout.buildDirectory.dir('generated/openapi-public/src/main/java'))
|
||||
java.srcDir(layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java'))
|
||||
// studio-management-v1 도 같은 방식이다. 세 번째 계약이라 이 목록이 길어지는데,
|
||||
// 계약마다 model 패키지를 분리하는 편이 이름 충돌보다 낫다 — 세 계약 모두
|
||||
// TopicSummary 처럼 같은 이름의 서로 다른 스키마를 갖는다.
|
||||
java.srcDir(layout.buildDirectory.dir('generated/openapi-management/src/main/java'))
|
||||
java.srcDir(layout.buildDirectory.dir('generated/openapi-management-unions/src/main/java'))
|
||||
}
|
||||
// main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation
|
||||
// Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을
|
||||
@@ -457,6 +462,11 @@ ext.publicCodegenSpecFile = layout.buildDirectory.file('openapi/public-v1-codege
|
||||
ext.publicCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-public')
|
||||
ext.publicUnionSrcDir = layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java')
|
||||
|
||||
ext.managementModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.management.api.model'
|
||||
ext.managementCodegenSpecFile = layout.buildDirectory.file('openapi/studio-management-v1-codegen.yaml')
|
||||
ext.managementCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-management')
|
||||
ext.managementUnionSrcDir = layout.buildDirectory.dir('generated/openapi-management-unions/src/main/java')
|
||||
|
||||
tasks.register('prepareStudioCodegenSpec') {
|
||||
description = 'studio-v1 계약에서 생성기 입력을 파생시킨다.'
|
||||
def specSource = file("${rootDir}/config/openapi/studio-v1.yaml")
|
||||
@@ -585,12 +595,97 @@ tasks.register('verifyPublicGeneratedModels') {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('prepareManagementCodegenSpec') {
|
||||
description = 'studio-management-v1 계약에서 생성기 입력을 파생시킨다.'
|
||||
def specSource = file("${rootDir}/config/openapi/studio-management-v1.yaml")
|
||||
def specOut = managementCodegenSpecFile
|
||||
def ignoreOut = managementCodegenIgnoreFile
|
||||
def unionDir = managementUnionSrcDir
|
||||
def modelPackage = managementModelPackage
|
||||
def prepare = prepareTechLogCodegenSpec
|
||||
inputs.file(specSource)
|
||||
outputs.file(specOut)
|
||||
outputs.file(ignoreOut)
|
||||
outputs.dir(unionDir)
|
||||
doLast {
|
||||
prepare('prepareManagementCodegenSpec', specSource, specOut.get().asFile,
|
||||
ignoreOut.get().asFile, unionDir.get().asFile, modelPackage)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('openApiGenerateManagement',
|
||||
org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
|
||||
dependsOn tasks.named('prepareManagementCodegenSpec')
|
||||
generatorName = 'spring'
|
||||
inputSpec = managementCodegenSpecFile.get().asFile.path
|
||||
ignoreFileOverride = managementCodegenIgnoreFile.get().asFile.path
|
||||
outputDir = layout.buildDirectory.dir('generated/openapi-management').get().asFile.path
|
||||
modelPackage = managementModelPackage
|
||||
validateSpec = true
|
||||
globalProperties.set(['models': ''])
|
||||
generateModelTests = false
|
||||
generateModelDocumentation = false
|
||||
configOptions = [
|
||||
useSpringBoot3: 'true',
|
||||
useJakartaEe: 'true',
|
||||
openApiNullable: 'false',
|
||||
useOneOfInterfaces: 'false',
|
||||
]
|
||||
doFirst { project.delete(layout.buildDirectory.dir('generated/openapi-management')) }
|
||||
}
|
||||
|
||||
tasks.register('verifyManagementGeneratedModels') {
|
||||
group = 'verification'
|
||||
description = 'studio-management-v1 계약의 schema 와 property 가 전부 모델로 생성됐는지 대조한다.'
|
||||
dependsOn tasks.named('openApiGenerateManagement')
|
||||
def specFile = managementCodegenSpecFile
|
||||
def modelDirProvider = layout.buildDirectory.dir('generated/openapi-management/src/main/java')
|
||||
def modelPackage = managementModelPackage
|
||||
doLast {
|
||||
def doc = new org.yaml.snakeyaml.Yaml().load(specFile.get().asFile.getText('UTF-8'))
|
||||
Set<String> declared = new TreeSet<>(((Map) doc.components.schemas).keySet())
|
||||
File packageDir = new File(modelDirProvider.get().asFile, modelPackage.replace('.', '/'))
|
||||
Set<String> generated = new TreeSet<>()
|
||||
if (packageDir.isDirectory()) {
|
||||
packageDir.eachFile { File f -> if (f.name.endsWith('.java')) generated << f.name[0..-6] }
|
||||
}
|
||||
Set<String> missing = new TreeSet<>(declared - generated)
|
||||
if (!missing.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"studio-management-v1 계약의 schema ${missing.size()}개가 모델로 생성되지 않았다: ${missing}")
|
||||
}
|
||||
int checkedProps = 0
|
||||
List<String> lost = []
|
||||
((Map) doc.components.schemas).each { String name, Object schema ->
|
||||
if (!(schema instanceof Map)) return
|
||||
Object props = ((Map) schema).get('properties')
|
||||
if (!(props instanceof Map)) return
|
||||
File modelFile = new File(packageDir, "${name}.java")
|
||||
if (!modelFile.isFile()) return
|
||||
String body = modelFile.getText('UTF-8')
|
||||
((Map) props).keySet().each { Object prop ->
|
||||
checkedProps++
|
||||
if (!body.contains("\"${prop}\"")) lost << "${name}.${prop}"
|
||||
}
|
||||
}
|
||||
if (!lost.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"studio-management-v1 계약의 property ${lost.size()}개가 모델에서 빠졌다: ${lost.take(20)}")
|
||||
}
|
||||
logger.lifecycle(
|
||||
"verifyManagementGeneratedModels: 계약 schema ${declared.size()}개 · " +
|
||||
"property ${checkedProps}개 전부 생성 (생성 모델 ${generated.size()}개)")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('verifyPublicGeneratedModels')
|
||||
dependsOn tasks.named('verifyManagementGeneratedModels')
|
||||
}
|
||||
|
||||
tasks.named('compileGeneratedOpenapiJava') {
|
||||
dependsOn tasks.named('openApiGeneratePublic')
|
||||
dependsOn tasks.named('openApiGenerateManagement')
|
||||
}
|
||||
|
||||
// openApiGenerate 는 확장(extension) 이름이자 태스크 이름이다 — 위 블록은 확장 설정이라
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.inbound.web.techlog.management;
|
||||
|
||||
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||
|
||||
/**
|
||||
* code 별 고정 문구. 예외의 원문 메시지는 진단용이라 그대로 내보내지 않는다 — 저장소 제약 이름이나
|
||||
* SQL 조각이 새어 나갈 수 있고, 그건 클라이언트가 분기할 값도 아니다.
|
||||
*/
|
||||
public final class ManagementClientSafeMessages {
|
||||
|
||||
private ManagementClientSafeMessages() {}
|
||||
|
||||
public static String forError(ManagementError error) {
|
||||
return switch (error) {
|
||||
case AUTHENTICATION_REQUIRED -> "로그인이 필요합니다";
|
||||
case STUDIO_ACCESS_DENIED -> "권한이 없습니다";
|
||||
case REQUEST_VALIDATION_FAILED -> "요청 값이 올바르지 않습니다";
|
||||
case VERSION_CONFLICT -> "다른 곳에서 먼저 수정되었습니다. 새로 불러온 뒤 다시 시도해 주세요";
|
||||
case TOPIC_NOT_FOUND -> "주제를 찾을 수 없습니다";
|
||||
case TOPIC_NAME_TAKEN -> "같은 이름의 주제가 이미 있습니다";
|
||||
case TOPIC_SLUG_TAKEN -> "같은 slug 의 주제가 이미 있습니다";
|
||||
case TOPIC_IN_USE -> "이 주제를 쓰는 기록이 있어 삭제할 수 없습니다";
|
||||
case PROJECT_NOT_FOUND -> "프로젝트를 찾을 수 없습니다";
|
||||
case PROJECT_SLUG_TAKEN -> "같은 slug 의 프로젝트가 이미 있습니다";
|
||||
case PROJECT_IN_USE -> "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다";
|
||||
};
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.caskeleton.adapter.inbound.web.techlog.management;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
|
||||
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
/**
|
||||
* 관리 표면의 실패를 봉투로 옮긴다. 스코프를 {@code ...web.techlog.management} 로 좁히는 이유는 형제 표면들과
|
||||
* 같다 — 각 계약이 자기 {@code ApiError.code} 집합만 열거하고 있어서, 다른 표면의 코드가 새어 들어가면 그
|
||||
* 계약이 거짓이 된다.
|
||||
*/
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.management")
|
||||
public class ManagementExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ManagementExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(ManagementException.class)
|
||||
public ResponseEntity<Envelope<Void>> handle(ManagementException ex) {
|
||||
ManagementError error = ex.managementError();
|
||||
if (error.httpStatus() >= 500) {
|
||||
log.error("management request failed as {}: {}", error.code(), ex.getMessage(), ex);
|
||||
} else {
|
||||
log.warn("management request rejected as {}: {}", error.code(), ex.getMessage());
|
||||
}
|
||||
return ErrorResponseFactory.envelope(
|
||||
error, ManagementClientSafeMessages.forError(error), ex.details());
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문을 못 읽는 경우(빈 본문, 깨진 JSON, enum 값 불일치). 그냥 두면 부모 처리기가 RFC 7807 을 만들고
|
||||
* {@code EnvelopeBodyAdvice} 의 미디어타입 검사에 걸려 봉투가 안 씌워진다 — ADR-006 이 쓰지 않기로 한
|
||||
* 모양이 그대로 나간다.
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleUnreadableBody(HttpMessageNotReadableException ex) {
|
||||
log.warn("management request body was unreadable: {}", ex.getMessage());
|
||||
return invalid("body", "MALFORMED", "Request body is malformed or unparsable");
|
||||
}
|
||||
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleMissingParameter(
|
||||
MissingServletRequestParameterException ex) {
|
||||
return invalid(ex.getParameterName(), "REQUIRED", "Required parameter is missing");
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
return invalid(ex.getName(), "TYPE_MISMATCH", "Parameter value is invalid");
|
||||
}
|
||||
|
||||
/** 계약의 {@code ValidationErrorDetails} — {@code field/code/message} 셋 다 required 다. */
|
||||
private static ResponseEntity<Envelope<Void>> invalid(
|
||||
String field, String code, String message) {
|
||||
Map<String, Object> fieldError = Map.of("field", field, "code", code, "message", message);
|
||||
return ErrorResponseFactory.envelope(
|
||||
ManagementError.REQUEST_VALIDATION_FAILED,
|
||||
ManagementClientSafeMessages.forError(ManagementError.REQUEST_VALIDATION_FAILED),
|
||||
Map.of("fieldErrors", List.of(fieldError)));
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.adapter.inbound.web.techlog.management;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||
|
||||
/**
|
||||
* 감사 컬럼에 남길 주체. {@code StudioPrincipals} 와 같은 일을 하되 이 표면의 error code 로 던진다 —
|
||||
* 계약이 각자 code 집합을 열거하므로 예외까지 공유하면 한쪽 계약이 거짓이 된다.
|
||||
*/
|
||||
public final class ManagementPrincipals {
|
||||
|
||||
private ManagementPrincipals() {}
|
||||
|
||||
public static String require(AuthenticatedPrincipal principal) {
|
||||
if (principal == null || principal.idpUserId() == null || principal.idpUserId().isBlank()) {
|
||||
throw ManagementException.of(
|
||||
ManagementError.AUTHENTICATION_REQUIRED,
|
||||
"the request has no usable authenticated principal");
|
||||
}
|
||||
return principal.idpUserId();
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
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.CreateDraftRequest;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectUpdateRequest;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
|
||||
import dev.caskeleton.application.techlog.management.command.DeleteProjectCommand;
|
||||
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
|
||||
import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
|
||||
import java.util.List;
|
||||
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.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 프로젝트 관리. 계약의 project CRUD 5개. */
|
||||
@RestController
|
||||
public class ManagementProjectController {
|
||||
|
||||
private final ListStudioProjectsUseCase listProjects;
|
||||
private final GetProjectForEditUseCase getProject;
|
||||
private final CreateProjectUseCase createProject;
|
||||
private final UpdateProjectUseCase updateProject;
|
||||
private final DeleteProjectUseCase deleteProject;
|
||||
|
||||
public ManagementProjectController(
|
||||
ListStudioProjectsUseCase listProjects,
|
||||
GetProjectForEditUseCase getProject,
|
||||
CreateProjectUseCase createProject,
|
||||
UpdateProjectUseCase updateProject,
|
||||
DeleteProjectUseCase deleteProject) {
|
||||
this.listProjects = listProjects;
|
||||
this.getProject = getProject;
|
||||
this.createProject = createProject;
|
||||
this.updateProject = updateProject;
|
||||
this.deleteProject = deleteProject;
|
||||
}
|
||||
|
||||
@GetMapping("/v1/studio/projects")
|
||||
public ProjectIndexPage listStudioProjects(
|
||||
@RequestParam(name = "page", defaultValue = "0") int page,
|
||||
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||
return ManagementResponseMapper.projects(listProjects.handle(page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/v1/studio/projects/{id}")
|
||||
public ProjectEditResponse getProjectForEdit(@PathVariable("id") UUID id) {
|
||||
return ManagementResponseMapper.project(getProject.handle(id));
|
||||
}
|
||||
|
||||
@PostMapping("/v1/studio/projects")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public CreateDraftResponse createProject(
|
||||
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||
@RequestBody CreateDraftRequest body) {
|
||||
return ManagementResponseMapper.draft(
|
||||
createProject.handle(
|
||||
new CreateProjectCommand(body.getTitle(), ManagementPrincipals.require(principal))));
|
||||
}
|
||||
|
||||
@PutMapping("/v1/studio/projects/{id}")
|
||||
public ProjectEditResponse updateProject(
|
||||
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||
@PathVariable("id") UUID id,
|
||||
@RequestBody ProjectUpdateRequest body) {
|
||||
List<String> labels =
|
||||
body.getTechnologyLabels() == null ? List.of() : List.copyOf(body.getTechnologyLabels());
|
||||
return ManagementResponseMapper.project(
|
||||
updateProject.handle(
|
||||
new UpdateProjectCommand(
|
||||
id,
|
||||
body.getExpectedVersion(),
|
||||
body.getName(),
|
||||
body.getSlug(),
|
||||
body.getOneLinePurpose(),
|
||||
body.getPurposeMarkdown(),
|
||||
body.getBoundaryMarkdown(),
|
||||
body.getSystemOverviewMarkdown(),
|
||||
body.getPhase(),
|
||||
body.getCurrentObjective(),
|
||||
body.getNextStep(),
|
||||
labels,
|
||||
body.getTargetVisibility().getValue(),
|
||||
body.getFeaturedOrder(),
|
||||
ManagementPrincipals.require(principal))));
|
||||
}
|
||||
|
||||
@DeleteMapping("/v1/studio/projects/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteProject(
|
||||
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||
@PathVariable("id") UUID id,
|
||||
@RequestBody ExpectedVersionRequest body) {
|
||||
deleteProject.handle(
|
||||
new DeleteProjectCommand(
|
||||
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
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.adapter.inbound.web.techlog.management.api.model.TopicEdit;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||
import dev.caskeleton.application.techlog.management.command.DeleteTopicCommand;
|
||||
import dev.caskeleton.application.techlog.management.command.SaveTopicCommand;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase;
|
||||
import java.util.List;
|
||||
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.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 주제 관리. 계약 {@code listStudioTopics}/{@code createTopic}/{@code updateTopic}/{@code
|
||||
* deleteTopic}.
|
||||
*
|
||||
* <p>쓰기 응답의 {@code id}/{@code version} 은 서버가 소유한다. 요청 본문에 실려 와도 무시하고 경로와
|
||||
* 저장소가 정한 값을 쓴다 — 그러지 않으면 클라이언트가 남의 행을 덮어쓸 수 있다.
|
||||
*/
|
||||
@RestController
|
||||
public class ManagementTopicController {
|
||||
|
||||
private final ListStudioTopicsUseCase listTopics;
|
||||
private final SaveTopicUseCase saveTopic;
|
||||
private final DeleteTopicUseCase deleteTopic;
|
||||
|
||||
public ManagementTopicController(
|
||||
ListStudioTopicsUseCase listTopics,
|
||||
SaveTopicUseCase saveTopic,
|
||||
DeleteTopicUseCase deleteTopic) {
|
||||
this.listTopics = listTopics;
|
||||
this.saveTopic = saveTopic;
|
||||
this.deleteTopic = deleteTopic;
|
||||
}
|
||||
|
||||
@GetMapping("/v1/studio/topics")
|
||||
public List<TopicEdit> listStudioTopics() {
|
||||
return ManagementResponseMapper.topics(listTopics.handle());
|
||||
}
|
||||
|
||||
@PostMapping("/v1/studio/topics")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public TopicEdit createTopic(
|
||||
@AuthenticationPrincipal AuthenticatedPrincipal principal, @RequestBody TopicEdit body) {
|
||||
return ManagementResponseMapper.topic(
|
||||
saveTopic.handle(command(null, body, ManagementPrincipals.require(principal))));
|
||||
}
|
||||
|
||||
@PutMapping("/v1/studio/topics/{id}")
|
||||
public TopicEdit updateTopic(
|
||||
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||
@PathVariable("id") UUID id,
|
||||
@RequestBody TopicEdit body) {
|
||||
return ManagementResponseMapper.topic(
|
||||
saveTopic.handle(command(id, body, ManagementPrincipals.require(principal))));
|
||||
}
|
||||
|
||||
@DeleteMapping("/v1/studio/topics/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteTopic(
|
||||
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||
@PathVariable("id") UUID id,
|
||||
@RequestBody ExpectedVersionRequest body) {
|
||||
deleteTopic.handle(
|
||||
new DeleteTopicCommand(
|
||||
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
|
||||
}
|
||||
|
||||
private static SaveTopicCommand command(UUID id, TopicEdit body, String actor) {
|
||||
return new SaveTopicCommand(
|
||||
id,
|
||||
body.getName(),
|
||||
body.getSlug(),
|
||||
body.getDescription(),
|
||||
body.getScope(),
|
||||
body.getStatus() == null ? null : body.getStatus().getValue(),
|
||||
body.getExpectedVersion(),
|
||||
actor);
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package dev.caskeleton.adapter.inbound.web.techlog.management.mapper;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PageMetadata;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublicationStatus;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit;
|
||||
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
||||
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
|
||||
import dev.caskeleton.application.techlog.management.model.TopicEditView;
|
||||
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
|
||||
/** 애플리케이션 뷰 → 계약 모델. 반대 방향(요청 → command)은 컨트롤러가 직접 한다. */
|
||||
public final class ManagementResponseMapper {
|
||||
|
||||
private ManagementResponseMapper() {}
|
||||
|
||||
private static OffsetDateTime at(Instant value) {
|
||||
return value == null ? null : value.atOffset(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
public static TopicEdit topic(TopicEditView view) {
|
||||
TopicEdit model = new TopicEdit(view.name(), view.slug());
|
||||
model.setId(view.id());
|
||||
model.setVersion(view.version());
|
||||
model.setDescription(view.description());
|
||||
model.setScope(view.scope());
|
||||
if (view.status() != null) {
|
||||
model.setStatus(TopicEdit.StatusEnum.fromValue(view.status()));
|
||||
}
|
||||
model.setFeaturedReferenceId(view.featuredReferenceId());
|
||||
model.setFeaturedCaseIds(List.copyOf(view.featuredCaseIds()));
|
||||
return model;
|
||||
}
|
||||
|
||||
public static List<TopicEdit> topics(List<TopicEditView> views) {
|
||||
return views.stream().map(ManagementResponseMapper::topic).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 상태는 두 타임스탬프에서 파생한다 — 테이블에 상태 컬럼이 따로 없고, 그 둘이 사실의
|
||||
* 출처이기 때문이다.
|
||||
*/
|
||||
private static PublicationStatus publication(Instant first, Instant last) {
|
||||
// 상태 컬럼이 따로 없으므로 두 타임스탬프에서 파생한다. WITHDRAWN 은 발행 이력이 있는데
|
||||
// 지금은 내려간 상태인데, 그 구분은 unpublish 를 구현할 때 생긴다 — 지금은 그 경로가
|
||||
// 없으므로 발행된 적이 있으면 ACTIVE 다.
|
||||
PublicationStatus status =
|
||||
new PublicationStatus(
|
||||
first == null ? PublicationStatus.StateEnum.NEVER_PUBLISHED
|
||||
: PublicationStatus.StateEnum.ACTIVE,
|
||||
false);
|
||||
status.setPublishedAt(at(last));
|
||||
return status;
|
||||
}
|
||||
|
||||
public static ProjectEditResponse project(ProjectEditView view) {
|
||||
ProjectEditResponse model =
|
||||
new ProjectEditResponse(
|
||||
view.id(),
|
||||
view.version(),
|
||||
view.name(),
|
||||
view.phase(),
|
||||
view.workflowStatus(),
|
||||
view.targetVisibility(),
|
||||
publication(view.firstPublishedAt(), view.lastPublishedAt()),
|
||||
at(view.updatedAt()));
|
||||
model.setSlug(view.slug());
|
||||
model.setOneLinePurpose(view.oneLinePurpose());
|
||||
model.setPurposeMarkdown(view.purposeMarkdown());
|
||||
model.setBoundaryMarkdown(view.boundaryMarkdown());
|
||||
model.setSystemOverviewMarkdown(view.systemOverviewMarkdown());
|
||||
model.setCurrentObjective(view.currentObjective());
|
||||
model.setNextStep(view.nextStep());
|
||||
model.setTechnologyLabels(List.copyOf(view.technologyLabels()));
|
||||
model.setFeaturedOrder(view.featuredOrder());
|
||||
model.setTopicIds(List.of());
|
||||
model.setDocumentLinks(List.of());
|
||||
model.setQuestionLinks(List.of());
|
||||
return model;
|
||||
}
|
||||
|
||||
public static CreateDraftResponse draft(ProjectEditView view) {
|
||||
return new CreateDraftResponse(
|
||||
view.id(), CreateDraftResponse.StatusEnum.DRAFT, view.version(), at(view.updatedAt()));
|
||||
}
|
||||
|
||||
private static ProjectIndexItem indexItem(ProjectIndexItemView view) {
|
||||
ProjectIndexItem item =
|
||||
new ProjectIndexItem(
|
||||
view.id(),
|
||||
view.name(),
|
||||
view.phase(),
|
||||
view.workflowStatus(),
|
||||
view.targetVisibility(),
|
||||
at(view.updatedAt()),
|
||||
view.version(),
|
||||
publication(view.firstPublishedAt(), view.lastPublishedAt()));
|
||||
item.setCurrentObjective(view.currentObjective());
|
||||
item.setNextStep(view.nextStep());
|
||||
return item;
|
||||
}
|
||||
|
||||
public static ProjectIndexPage projects(ListStudioProjectsUseCase.Page page) {
|
||||
PageMetadata meta =
|
||||
new PageMetadata(
|
||||
page.number(),
|
||||
page.size(),
|
||||
(long) page.totalElements(),
|
||||
page.totalPages(),
|
||||
page.number() > 0,
|
||||
page.number() + 1 < page.totalPages());
|
||||
return new ProjectIndexPage(page.items().stream().map(ManagementResponseMapper::indexItem).toList(), meta);
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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 dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Project 편집 저장소.
|
||||
*
|
||||
* <p>{@code technology_labels} 는 jsonb 다. 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로
|
||||
* 보내 타입이 어긋나므로, JSON 문자열로 직렬화해 {@code ::jsonb} 로 캐스팅한다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
|
||||
|
||||
private static final String EDIT_COLUMNS =
|
||||
"id, version, name, slug, one_line_purpose, purpose_markdown, boundary_markdown,"
|
||||
+ " system_overview_markdown, phase, current_objective, next_step, technology_labels,"
|
||||
+ " workflow_status, target_visibility, featured_order, first_published_at,"
|
||||
+ " last_published_at, updated_at";
|
||||
|
||||
private static final String INDEX_COLUMNS =
|
||||
"id, name, phase, workflow_status, target_visibility, current_objective, next_step,"
|
||||
+ " updated_at, version, first_published_at, last_published_at";
|
||||
|
||||
private static final TypeReference<List<String>> LABELS = new TypeReference<>() {};
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public JdbcProjectRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
private static Instant instant(ResultSet rs, String column) throws SQLException {
|
||||
Timestamp t = rs.getTimestamp(column);
|
||||
return t == null ? null : t.toInstant();
|
||||
}
|
||||
|
||||
private List<String> labels(String json) {
|
||||
if (json == null || json.isBlank()) return List.of();
|
||||
try {
|
||||
return objectMapper.readValue(json, LABELS);
|
||||
} catch (Exception malformed) {
|
||||
// 열 자체가 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다.
|
||||
// 편집 화면 전체를 막는 대신 빈 목록으로 두고 나머지 필드를 보여준다.
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private String labelsJson(List<String> values) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(values == null ? List.of() : values);
|
||||
} catch (Exception impossible) {
|
||||
throw new IllegalStateException("technology labels are not serialisable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectEditView mapEdit(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new ProjectEditView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getLong("version"),
|
||||
rs.getString("name"),
|
||||
rs.getString("slug"),
|
||||
rs.getString("one_line_purpose"),
|
||||
rs.getString("purpose_markdown"),
|
||||
rs.getString("boundary_markdown"),
|
||||
rs.getString("system_overview_markdown"),
|
||||
rs.getString("phase"),
|
||||
rs.getString("current_objective"),
|
||||
rs.getString("next_step"),
|
||||
labels(rs.getString("technology_labels")),
|
||||
rs.getString("workflow_status"),
|
||||
rs.getString("target_visibility"),
|
||||
(Integer) rs.getObject("featured_order"),
|
||||
instant(rs, "first_published_at"),
|
||||
instant(rs, "last_published_at"),
|
||||
instant(rs, "updated_at"));
|
||||
}
|
||||
|
||||
private static ProjectIndexItemView mapIndex(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new ProjectIndexItemView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("phase"),
|
||||
rs.getString("workflow_status"),
|
||||
rs.getString("target_visibility"),
|
||||
rs.getString("current_objective"),
|
||||
rs.getString("next_step"),
|
||||
instant(rs, "updated_at"),
|
||||
rs.getLong("version"),
|
||||
instant(rs, "first_published_at"),
|
||||
instant(rs, "last_published_at"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProjectIndexItemView> listAll(int limit, int offset) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT " + INDEX_COLUMNS + " FROM project ORDER BY updated_at DESC, id"
|
||||
+ " LIMIT :limit OFFSET :offset")
|
||||
.param("limit", limit)
|
||||
.param("offset", offset)
|
||||
.query(JdbcProjectRepositoryAdapter::mapIndex)
|
||||
.list();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countAll() {
|
||||
return Optional.ofNullable(
|
||||
jdbcClient.sql("SELECT COUNT(*) FROM project").query(Integer.class).single())
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectEditView> find(UUID id) {
|
||||
return jdbcClient
|
||||
.sql("SELECT " + EDIT_COLUMNS + " FROM project WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(this::mapEdit)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectEditView create(CreateProjectCommand command) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project (id, name, created_by, updated_by) "
|
||||
+ "VALUES (:id, :name, :actor, :actor)")
|
||||
.param("id", id)
|
||||
.param("name", command.title().trim())
|
||||
.param("actor", command.actor())
|
||||
.update();
|
||||
return find(id).orElseThrow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectEditView> update(UpdateProjectCommand command) {
|
||||
String slug = command.slug() == null || command.slug().isBlank() ? null : command.slug().trim();
|
||||
int updated =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE project SET name = :name, slug = :slug, one_line_purpose = :purpose,"
|
||||
+ " purpose_markdown = :purposeMd, boundary_markdown = :boundaryMd,"
|
||||
+ " system_overview_markdown = :overviewMd, phase = :phase,"
|
||||
+ " current_objective = :objective, next_step = :nextStep,"
|
||||
+ " technology_labels = CAST(:labels AS jsonb),"
|
||||
+ " target_visibility = :visibility, featured_order = :featured,"
|
||||
+ " version = version + 1, updated_at = now(), updated_by = :actor"
|
||||
+ " WHERE id = :id AND version = :expected")
|
||||
.param("id", command.id())
|
||||
.param("expected", command.expectedVersion())
|
||||
.param("name", command.name().trim())
|
||||
.param("slug", slug)
|
||||
.param("purpose", nullToEmpty(command.oneLinePurpose()))
|
||||
.param("purposeMd", nullToEmpty(command.purposeMarkdown()))
|
||||
.param("boundaryMd", nullToEmpty(command.boundaryMarkdown()))
|
||||
.param("overviewMd", nullToEmpty(command.systemOverviewMarkdown()))
|
||||
.param("phase", command.phase())
|
||||
.param("objective", command.currentObjective())
|
||||
.param("nextStep", command.nextStep())
|
||||
.param("labels", labelsJson(command.technologyLabels()))
|
||||
.param("visibility", command.targetVisibility())
|
||||
.param("featured", command.featuredOrder())
|
||||
.param("actor", command.actor())
|
||||
.update();
|
||||
return updated == 0 ? Optional.empty() : find(command.id());
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(UUID id, long expectedVersion) {
|
||||
return jdbcClient
|
||||
.sql("DELETE FROM project WHERE id = :id AND version = :expected")
|
||||
.param("id", id)
|
||||
.param("expected", expectedVersion)
|
||||
.update();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReferenced(UUID id) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS ("
|
||||
+ " SELECT 1 FROM project_document_link WHERE project_id = :id"
|
||||
+ " UNION ALL SELECT 1 FROM project_question_link WHERE project_id = :id"
|
||||
+ " UNION ALL SELECT 1 FROM project_decision WHERE project_id = :id"
|
||||
+ " UNION ALL SELECT 1 FROM public_resource_project_link WHERE project_id = :id"
|
||||
+ ")")
|
||||
.param("id", id)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean slugTaken(String slug, UUID exceptId) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS (SELECT 1 FROM project WHERE slug = :slug"
|
||||
+ " AND (:except IS NULL OR id <> :except))")
|
||||
.param("slug", slug)
|
||||
.param("except", exceptId)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||
|
||||
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 java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Topic 편집 저장소.
|
||||
*
|
||||
* <p>정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@code
|
||||
* uq_topic_normalized_name} 이 그 컬럼 위에 있으므로, 계산이 한 곳에만 있어야 사전 확인과 제약이
|
||||
* 같은 값을 본다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort {
|
||||
|
||||
private static final String COLUMNS =
|
||||
"id, name, slug, description, scope, status, version";
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcTopicRepositoryAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
private static String normalize(String name) {
|
||||
return name == null ? "" : name.trim().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static TopicEditView map(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new TopicEditView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("slug"),
|
||||
rs.getString("description"),
|
||||
rs.getString("scope"),
|
||||
rs.getString("status"),
|
||||
rs.getLong("version"),
|
||||
null,
|
||||
List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TopicEditView> listAll() {
|
||||
return jdbcClient
|
||||
.sql("SELECT " + COLUMNS + " FROM topic ORDER BY name")
|
||||
.query(JdbcTopicRepositoryAdapter::map)
|
||||
.list();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<TopicEditView> find(UUID id) {
|
||||
return jdbcClient
|
||||
.sql("SELECT " + COLUMNS + " FROM topic WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(JdbcTopicRepositoryAdapter::map)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TopicEditView create(SaveTopicCommand command) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic (id, name, normalized_name, slug, description, scope, status,"
|
||||
+ " version, created_by, updated_by)"
|
||||
+ " VALUES (:id, :name, :normalized, :slug, :description, :scope,"
|
||||
+ " COALESCE(:status, 'ACTIVE'), 0, :actor, :actor)")
|
||||
.param("id", id)
|
||||
.param("name", command.name().trim())
|
||||
.param("normalized", normalize(command.name()))
|
||||
.param("slug", command.slug().trim())
|
||||
.param("description", command.description())
|
||||
.param("scope", command.scope())
|
||||
.param("status", command.status())
|
||||
.param("actor", command.actor())
|
||||
.update();
|
||||
return find(id).orElseThrow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<TopicEditView> update(SaveTopicCommand command) {
|
||||
int updated =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE topic SET name = :name, normalized_name = :normalized, slug = :slug,"
|
||||
+ " description = :description, scope = :scope,"
|
||||
+ " status = COALESCE(:status, status), version = version + 1,"
|
||||
+ " updated_at = now(), updated_by = :actor"
|
||||
+ " WHERE id = :id AND version = :expected")
|
||||
.param("id", command.id())
|
||||
.param("expected", command.expectedVersion())
|
||||
.param("name", command.name().trim())
|
||||
.param("normalized", normalize(command.name()))
|
||||
.param("slug", command.slug().trim())
|
||||
.param("description", command.description())
|
||||
.param("scope", command.scope())
|
||||
.param("status", command.status())
|
||||
.param("actor", command.actor())
|
||||
.update();
|
||||
return updated == 0 ? Optional.empty() : find(command.id());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(UUID id, long expectedVersion) {
|
||||
return jdbcClient
|
||||
.sql("DELETE FROM topic WHERE id = :id AND version = :expected")
|
||||
.param("id", id)
|
||||
.param("expected", expectedVersion)
|
||||
.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 확인. 주제를 가리키는 곳이 늘어나면 여기도 늘어야 한다 — 빠뜨리면 외래키가 대신 막고
|
||||
* 500 이 나간다.
|
||||
*/
|
||||
@Override
|
||||
public boolean isReferenced(UUID id) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS ("
|
||||
+ " SELECT 1 FROM document WHERE primary_topic_id = :id"
|
||||
+ " UNION ALL SELECT 1 FROM open_question WHERE primary_topic_id = :id"
|
||||
+ " UNION ALL SELECT 1 FROM public_resource_projection WHERE primary_topic_id = :id"
|
||||
+ ")")
|
||||
.param("id", id)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean nameTaken(String normalizedName, UUID exceptId) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS (SELECT 1 FROM topic WHERE normalized_name = :name"
|
||||
+ " AND (:except IS NULL OR id <> :except))")
|
||||
.param("name", normalizedName)
|
||||
.param("except", exceptId)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean slugTaken(String slug, UUID exceptId) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS (SELECT 1 FROM topic WHERE slug = :slug"
|
||||
+ " AND (:except IS NULL OR id <> :except))")
|
||||
.param("slug", slug)
|
||||
.param("except", exceptId)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package dev.caskeleton.bootstrap.techlog;
|
||||
|
||||
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
||||
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
|
||||
import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 관리 표면(`studio-management-v1.yaml`)의 use case 배선. 지금은 topics 4개와 projects 5개만 있다 —
|
||||
* 그 둘이 문서 작성을 막고 있던 선행 조건이기 때문이다.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class TechLogManagementConfig {
|
||||
|
||||
@Bean
|
||||
ListStudioTopicsUseCase listStudioTopicsUseCase(TopicRepositoryPort topics, TransactionPort tx) {
|
||||
return new ListStudioTopicsUseCase(topics, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SaveTopicUseCase saveTopicUseCase(TopicRepositoryPort topics, TransactionPort tx) {
|
||||
return new SaveTopicUseCase(topics, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DeleteTopicUseCase deleteTopicUseCase(TopicRepositoryPort topics, TransactionPort tx) {
|
||||
return new DeleteTopicUseCase(topics, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListStudioProjectsUseCase listStudioProjectsUseCase(
|
||||
ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new ListStudioProjectsUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetProjectForEditUseCase getProjectForEditUseCase(
|
||||
ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new GetProjectForEditUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
CreateProjectUseCase createProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new CreateProjectUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
UpdateProjectUseCase updateProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new UpdateProjectUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DeleteProjectUseCase deleteProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new DeleteProjectUseCase(projects, tx);
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package dev.caskeleton.bootstrap.architecture;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementClientSafeMessages;
|
||||
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* {@code PublicErrorRegistryTest} 가 {@code PublicError} 에 대해 하는 일을 {@link ManagementError} 에
|
||||
* 대해 한다 — row 존재 / 값 드리프트 / client-safe 문구, 그리고 계약 code 집합 대조.
|
||||
*
|
||||
* <p>계약은 12종을 열거하는데 enum 은 11종이다. 나머지 {@code INTERNAL_ERROR} 는 스켈레톤 공통 처리기가
|
||||
* 소유하며({@link OperationalError#INTERNAL_ERROR}) 여기서 재선언하지 않는다 — 그쪽은 {@code
|
||||
* retryable=true} 라 같은 code 를 두 곳에서 선언하면 레지스트리가 어느 값을 따라야 할지 알 수 없다.
|
||||
*/
|
||||
class ManagementErrorRegistryTest {
|
||||
|
||||
private static final String SKELETON_OWNED_CODE = OperationalError.INTERNAL_ERROR.code();
|
||||
|
||||
private static Map<String, Map<String, Object>> registryRowsByCode;
|
||||
private static Set<String> contractCodes;
|
||||
|
||||
@BeforeAll
|
||||
@SuppressWarnings("unchecked")
|
||||
static void load() throws Exception {
|
||||
RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty();
|
||||
|
||||
Path registry = resources.requireTrackedFile("docs/registries/error-codes.yaml");
|
||||
registryRowsByCode = new LinkedHashMap<>();
|
||||
try (InputStream in = Files.newInputStream(registry)) {
|
||||
Map<String, Object> root = new Yaml().load(in);
|
||||
for (Map<String, Object> row : (List<Map<String, Object>>) root.get("errors")) {
|
||||
registryRowsByCode.put((String) row.get("code"), row);
|
||||
}
|
||||
}
|
||||
|
||||
Path contract = resources.requireTrackedFile("src/config/openapi/studio-management-v1.yaml");
|
||||
try (InputStream in = Files.newInputStream(contract)) {
|
||||
Map<String, Object> doc = new Yaml().load(in);
|
||||
Map<String, Object> components = (Map<String, Object>) doc.get("components");
|
||||
Map<String, Object> schemas = (Map<String, Object>) components.get("schemas");
|
||||
Map<String, Object> apiError = (Map<String, Object>) schemas.get("ApiError");
|
||||
Map<String, Object> properties = (Map<String, Object>) apiError.get("properties");
|
||||
Map<String, Object> code = (Map<String, Object>) properties.get("code");
|
||||
contractCodes = new TreeSet<>((List<String>) code.get("enum"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyManagementErrorHasARegistryRow() {
|
||||
Set<String> declared =
|
||||
Arrays.stream(ManagementError.values())
|
||||
.map(ManagementError::code)
|
||||
.collect(Collectors.toSet());
|
||||
assertThat(registryRowsByCode.keySet()).containsAll(declared);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRowMatchesCategoryHttpStatusAndRetryable() {
|
||||
for (ManagementError error : ManagementError.values()) {
|
||||
Map<String, Object> row = registryRowsByCode.get(error.code());
|
||||
assertThat(row).as("registry row for %s", error.code()).isNotNull();
|
||||
assertThat(row.get("category")).as("category of %s", error.code())
|
||||
.isEqualTo(error.category().name());
|
||||
assertThat(((Number) row.get("http_status")).intValue()).as("http_status of %s", error.code())
|
||||
.isEqualTo(error.httpStatus());
|
||||
assertThat(row.get("retryable")).as("retryable of %s", error.code())
|
||||
.isEqualTo(error.retryable());
|
||||
}
|
||||
}
|
||||
|
||||
/** 계약이 열거하는 code = enum 이 소유하는 code ∪ 스켈레톤 소유 code. 어느 쪽이 늘어도 여기서 걸린다. */
|
||||
@Test
|
||||
void contractCodeSetMatchesTheEnumPlusTheSkeletonOwnedCode() {
|
||||
Set<String> owned =
|
||||
Arrays.stream(ManagementError.values())
|
||||
.map(ManagementError::code)
|
||||
.collect(Collectors.toCollection(TreeSet::new));
|
||||
owned.add(SKELETON_OWNED_CODE);
|
||||
assertThat(contractCodes).isEqualTo(owned);
|
||||
}
|
||||
|
||||
/** 모든 code 가 문구를 갖는다. switch 가 전부를 덮지 않으면 컴파일이 막지만, 빈 문구는 막지 못한다. */
|
||||
@Test
|
||||
void everyErrorHasANonBlankClientSafeMessage() {
|
||||
for (ManagementError error : ManagementError.values()) {
|
||||
assertThat(ManagementClientSafeMessages.forError(error))
|
||||
.as("client-safe message for %s", error.code())
|
||||
.isNotBlank();
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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;
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
/**
|
||||
* 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다.
|
||||
*/
|
||||
public record CreateProjectCommand(String title, String actor) {}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record DeleteProjectCommand(UUID id, long expectedVersion, String actor) {}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record DeleteTopicCommand(UUID id, long expectedVersion, String actor) {}
|
||||
+21
@@ -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) {}
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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) {}
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+44
@@ -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));
|
||||
}
|
||||
}
|
||||
+58
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+63
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+42
@@ -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")));
|
||||
}
|
||||
}
|
||||
+50
@@ -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) {}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+98
@@ -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) {}
|
||||
}
|
||||
+87
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,5 @@
|
||||
6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4 studio-v1.yaml
|
||||
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 55a9599 (feature/public-v1-response-envelope)
|
||||
8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e public-v1.yaml
|
||||
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 6ef5c1c (master)
|
||||
ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f studio-management-v1.yaml
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user