From bb6d2330bb8bdeec5cad9f0f23943005bb531d99 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 22:54:24 +0900 Subject: [PATCH] feat: implement topic and project management, so documents can be authored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/registries/error-codes.yaml | 100 + src/adapter/inbound/web/build.gradle | 95 + .../ManagementClientSafeMessages.java | 28 + .../ManagementExceptionHandler.java | 74 + .../management/ManagementPrincipals.java | 23 + .../ManagementProjectController.java | 116 + .../controller/ManagementTopicController.java | 93 + .../mapper/ManagementResponseMapper.java | 121 + .../JdbcProjectRepositoryAdapter.java | 226 + .../JdbcTopicRepositoryAdapter.java | 165 + .../techlog/TechLogManagementConfig.java | 65 + .../ManagementErrorRegistryTest.java | 107 + .../techlog/error/ManagementError.java | 60 + .../techlog/error/ManagementException.java | 42 + .../command/CreateProjectCommand.java | 6 + .../command/DeleteProjectCommand.java | 5 + .../command/DeleteTopicCommand.java | 5 + .../management/command/SaveTopicCommand.java | 21 + .../command/UpdateProjectCommand.java | 27 + .../management/model/ProjectEditView.java | 36 + .../model/ProjectIndexItemView.java | 18 + .../management/model/TopicEditView.java | 27 + .../port/out/ProjectRepositoryPort.java | 29 + .../port/out/TopicRepositoryPort.java | 30 + .../service/CreateProjectUseCase.java | 44 + .../service/DeleteProjectUseCase.java | 58 + .../service/DeleteTopicUseCase.java | 63 + .../service/GetProjectForEditUseCase.java | 42 + .../service/ListStudioProjectsUseCase.java | 50 + .../service/ListStudioTopicsUseCase.java | 37 + .../management/service/SaveTopicUseCase.java | 98 + .../service/UpdateProjectUseCase.java | 87 + src/config/openapi/MANIFEST.sha256 | 2 + src/config/openapi/studio-management-v1.yaml | 7065 +++++++++++++++++ 34 files changed, 9065 insertions(+) create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementExceptionHandler.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementPrincipals.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementTopicController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcTopicRepositoryAdapter.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ManagementErrorRegistryTest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementException.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateProjectCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteProjectCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteTopicCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/SaveTopicCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectEditView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectIndexItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/TopicEditView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectRepositoryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/TopicRepositoryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateProjectUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteTopicUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetProjectForEditUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioProjectsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioTopicsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/SaveTopicUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateProjectUseCase.java create mode 100644 src/config/openapi/studio-management-v1.yaml diff --git a/docs/registries/error-codes.yaml b/docs/registries/error-codes.yaml index 4136e09..06f0b05 100644 --- a/docs/registries/error-codes.yaml +++ b/docs/registries/error-codes.yaml @@ -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 \ No newline at end of file diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index c06f7b5..4264d61 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -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 declared = new TreeSet<>(((Map) doc.components.schemas).keySet()) + File packageDir = new File(modelDirProvider.get().asFile, modelPackage.replace('.', '/')) + Set generated = new TreeSet<>() + if (packageDir.isDirectory()) { + packageDir.eachFile { File f -> if (f.name.endsWith('.java')) generated << f.name[0..-6] } + } + Set missing = new TreeSet<>(declared - generated) + if (!missing.isEmpty()) { + throw new GradleException( + "studio-management-v1 계약의 schema ${missing.size()}개가 모델로 생성되지 않았다: ${missing}") + } + int checkedProps = 0 + List 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) 이름이자 태스크 이름이다 — 위 블록은 확장 설정이라 diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java new file mode 100644 index 0000000..979d5c5 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java @@ -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 -> "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다"; + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementExceptionHandler.java new file mode 100644 index 0000000..ebf7d41 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementExceptionHandler.java @@ -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> 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> 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> handleMissingParameter( + MissingServletRequestParameterException ex) { + return invalid(ex.getParameterName(), "REQUIRED", "Required parameter is missing"); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + return invalid(ex.getName(), "TYPE_MISMATCH", "Parameter value is invalid"); + } + + /** 계약의 {@code ValidationErrorDetails} — {@code field/code/message} 셋 다 required 다. */ + private static ResponseEntity> invalid( + String field, String code, String message) { + Map 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))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementPrincipals.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementPrincipals.java new file mode 100644 index 0000000..bb7bae7 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementPrincipals.java @@ -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(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java new file mode 100644 index 0000000..df811a0 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java @@ -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 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))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementTopicController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementTopicController.java new file mode 100644 index 0000000..8b66abe --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementTopicController.java @@ -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}. + * + *

쓰기 응답의 {@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 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); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java new file mode 100644 index 0000000..938e685 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java @@ -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 topics(List 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); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java new file mode 100644 index 0000000..e48f6d0 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java @@ -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 편집 저장소. + * + *

{@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> 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 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 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 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 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 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()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcTopicRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcTopicRepositoryAdapter.java new file mode 100644 index 0000000..ce13ac1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcTopicRepositoryAdapter.java @@ -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 편집 저장소. + * + *

정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@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 listAll() { + return jdbcClient + .sql("SELECT " + COLUMNS + " FROM topic ORDER BY name") + .query(JdbcTopicRepositoryAdapter::map) + .list(); + } + + @Override + public Optional 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 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()); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java new file mode 100644 index 0000000..cbd0ef8 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java @@ -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); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ManagementErrorRegistryTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ManagementErrorRegistryTest.java new file mode 100644 index 0000000..555fe02 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ManagementErrorRegistryTest.java @@ -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 집합 대조. + * + *

계약은 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> registryRowsByCode; + private static Set 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 root = new Yaml().load(in); + for (Map row : (List>) 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 doc = new Yaml().load(in); + Map components = (Map) doc.get("components"); + Map schemas = (Map) components.get("schemas"); + Map apiError = (Map) schemas.get("ApiError"); + Map properties = (Map) apiError.get("properties"); + Map code = (Map) properties.get("code"); + contractCodes = new TreeSet<>((List) code.get("enum")); + } + } + + @Test + void everyManagementErrorHasARegistryRow() { + Set 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 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 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(); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java new file mode 100644 index 0000000..44726c1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java @@ -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`을 함께 고쳐야 한다. + * + *

{@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로 + * 낼 수 있게 되면 그 순간 두 계약 모두 거짓이 된다. + * + *

계약의 {@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; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementException.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementException.java new file mode 100644 index 0000000..c40fc97 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementException.java @@ -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; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateProjectCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateProjectCommand.java new file mode 100644 index 0000000..4da712e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateProjectCommand.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.management.command; + +/** + * 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다. + */ +public record CreateProjectCommand(String title, String actor) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteProjectCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteProjectCommand.java new file mode 100644 index 0000000..670e968 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteProjectCommand.java @@ -0,0 +1,5 @@ +package dev.caskeleton.application.techlog.management.command; + +import java.util.UUID; + +public record DeleteProjectCommand(UUID id, long expectedVersion, String actor) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteTopicCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteTopicCommand.java new file mode 100644 index 0000000..701eb27 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/DeleteTopicCommand.java @@ -0,0 +1,5 @@ +package dev.caskeleton.application.techlog.management.command; + +import java.util.UUID; + +public record DeleteTopicCommand(UUID id, long expectedVersion, String actor) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/SaveTopicCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/SaveTopicCommand.java new file mode 100644 index 0000000..3676886 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/SaveTopicCommand.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.techlog.management.command; + +import java.util.UUID; + +/** + * 생성과 수정이 같은 명령을 쓴다. 계약이 두 경우 모두 {@code TopicEdit} 를 본문으로 받기 때문이고, + * 구분은 {@code id} 의 유무다 — {@code null} 이면 생성이다. + * + *

{@code expectedVersion} 은 수정에서만 의미가 있다. 생성에 값이 와도 무시하는 대신 거절하지 + * 않는 이유는, 계약이 그 필드를 optional 로 두고 있어 클라이언트가 보내는 것이 위반이 아니기 + * 때문이다. + */ +public record SaveTopicCommand( + UUID id, + String name, + String slug, + String description, + String scope, + String status, + Long expectedVersion, + String actor) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectCommand.java new file mode 100644 index 0000000..c82413f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectCommand.java @@ -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 technologyLabels, + String targetVisibility, + Integer featuredOrder, + String actor) { + + public UpdateProjectCommand { + technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectEditView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectEditView.java new file mode 100644 index 0000000..08fce2f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectEditView.java @@ -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}. + * + *

{@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 technologyLabels, + String workflowStatus, + String targetVisibility, + Integer featuredOrder, + Instant firstPublishedAt, + Instant lastPublishedAt, + Instant updatedAt) { + + public ProjectEditView { + technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectIndexItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectIndexItemView.java new file mode 100644 index 0000000..800937c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectIndexItemView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/TopicEditView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/TopicEditView.java new file mode 100644 index 0000000..6bfcc16 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/TopicEditView.java @@ -0,0 +1,27 @@ +package dev.caskeleton.application.techlog.management.model; + +import java.util.List; +import java.util.UUID; + +/** + * 계약 {@code TopicEdit}. + * + *

{@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 featuredCaseIds) { + + public TopicEditView { + featuredCaseIds = featuredCaseIds == null ? List.of() : List.copyOf(featuredCaseIds); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectRepositoryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectRepositoryPort.java new file mode 100644 index 0000000..6b04815 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectRepositoryPort.java @@ -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 listAll(int limit, int offset); + + int countAll(); + + Optional find(UUID id); + + ProjectEditView create(CreateProjectCommand command); + + Optional update(UpdateProjectCommand command); + + int delete(UUID id, long expectedVersion); + + boolean isReferenced(UUID id); + + boolean slugTaken(String slug, UUID exceptId); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/TopicRepositoryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/TopicRepositoryPort.java new file mode 100644 index 0000000..5a08692 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/TopicRepositoryPort.java @@ -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 listAll(); + + Optional find(UUID id); + + TopicEditView create(SaveTopicCommand command); + + /** 낙관적 잠금. 버전이 다르면 {@code Optional.empty()} 가 아니라 예외로 구분해야 하므로 뷰를 돌려준다. */ + Optional 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); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateProjectUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateProjectUseCase.java new file mode 100644 index 0000000..9a05d37 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateProjectUseCase.java @@ -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)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectUseCase.java new file mode 100644 index 0000000..1879562 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectUseCase.java @@ -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; + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteTopicUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteTopicUseCase.java new file mode 100644 index 0000000..41cdb40 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteTopicUseCase.java @@ -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}. + * + *

참조가 있으면 지우지 않고 {@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; + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetProjectForEditUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetProjectForEditUseCase.java new file mode 100644 index 0000000..0456b51 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetProjectForEditUseCase.java @@ -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"))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioProjectsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioProjectsUseCase.java new file mode 100644 index 0000000..2b9541c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioProjectsUseCase.java @@ -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 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 items, + int number, + int size, + int totalElements, + int totalPages) {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioTopicsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioTopicsUseCase.java new file mode 100644 index 0000000..3cb9e9e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioTopicsUseCase.java @@ -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 handle() { + return transactions.inRead(topics::listAll); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/SaveTopicUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/SaveTopicUseCase.java new file mode 100644 index 0000000..cd2554c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/SaveTopicUseCase.java @@ -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}. + * + *

이름과 slug 의 중복은 DB 제약이 이미 막고 있다. 그래도 여기서 먼저 확인하는 이유는 계약이 + * {@code TOPIC_NAME_TAKEN}/{@code TOPIC_SLUG_TAKEN} 을 구분해서 요구하기 때문이다 — 제약 위반을 + * 잡아 코드로 되돌리면 어느 제약이었는지는 드라이버 메시지 문자열에서 읽어야 하고, 그건 벤더가 + * 바뀌면 조용히 깨진다. + * + *

이름 비교는 정규화(소문자·공백 정리) 후에 한다. 테이블의 {@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) {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateProjectUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateProjectUseCase.java new file mode 100644 index 0000000..2a033bd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateProjectUseCase.java @@ -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 PHASES = + Set.of( + "RESEARCH", "DESIGN", "IMPLEMENTATION", "VERIFICATION", "MAINTENANCE", "PAUSED", + "COMPLETED"); + + private static final Set 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 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()); + } + } +} diff --git a/src/config/openapi/MANIFEST.sha256 b/src/config/openapi/MANIFEST.sha256 index f7c25bb..659be88 100644 --- a/src/config/openapi/MANIFEST.sha256 +++ b/src/config/openapi/MANIFEST.sha256 @@ -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 diff --git a/src/config/openapi/studio-management-v1.yaml b/src/config/openapi/studio-management-v1.yaml new file mode 100644 index 0000000..0a65f79 --- /dev/null +++ b/src/config/openapi/studio-management-v1.yaml @@ -0,0 +1,7065 @@ +openapi: 3.1.0 +info: + title: Tech Log Studio Management API (Secondary) + version: 1.0.0 + description: |- + ⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다. + + topics 4개와 projects 5개는 studio-v1.yaml과 같은 방식으로 변환했다 + (`application/json` + ErrorEnvelope / Envelope). 그 9개가 첫 구현 + 대상이자 첫 소비자이기 때문이다. + + 나머지 70개는 아직 bare payload + `application/problem+json` + ProblemDetails + 다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리 + 변환해 두면 검증되지 않은 모양이 계약에 고정된다. + + Tech Log의 유형별 specialized management 계약이다. + + 이 계약은 primary Studio orchestration 계약(`studio-v1.yaml`)이 아니다. + 현재 Studio UI는 이 operation들을 직접 호출하지 않는다. + + 존재 이유는 설계 수정 가이드 14장과 23.7의 규칙이다. + + > 현재 UI에서 사용하지 않는다는 이유로 Backend capability를 삭제하지 않는다. + > 다만 capability 보존과 endpoint 보존을 동일시하지 않는다. + + 따라서 Question start/pause/resume/reopen/archive, Project phase change, + Project Activity, Release, Topic/Tag, Profile/Site/Home Focus, + Decision supersede/reject 등의 Domain/Application capability를 여기에 보존하고, + 현재 Frontend가 사용하는 통합 편집 흐름은 `studio-v1.yaml`이 소유한다. + + ## 이 계약을 사용하는 규칙 + + - primary 흐름(작업본 편집·검증·미리보기·게시)은 반드시 `studio-v1.yaml`을 사용한다. + - 여기의 publish/unpublish 계열 operation은 primary `publishStudioDocument`와 + 동일한 Publication Event/Snapshot 경로를 거쳐야 한다. + 서로 다른 두 개의 게시 경로를 허용하지 않는다. + - 새 UI가 특정 capability를 실제로 사용하기 시작하면 해당 operation을 + primary 계약으로 승격하고 여기에서 제거한다. + + ## 이 계약에서 제거된 것 + + - `GET /session` : primary `studio-v1.yaml`이 소유한다. + - `/documents` 계열 : primary 통합 Working Copy operation으로 대체되었다. + - `/assets` 계열 : primary `studio-v1.yaml`이 소유한다. + - `/previews`, `/previews/{token}` : Preview Token 계약은 폐기되었다. + 인증된 Preview Artifact(`studio-v1.yaml`)로 대체되었다. +servers: +- url: / +security: +- sessionCookie: [] +tags: +- name: Cases +- name: References +- name: Questions +- name: Projects +- name: Decisions +- name: Releases +- name: Taxonomy +- name: Identity +paths: + /api/v1/studio/cases: + post: + operationId: createCaseDraft + tags: + - Cases + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '201': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftRequest' + security: + - sessionCookie: [] + /api/v1/studio/cases/{id}: + get: + operationId: getCaseForEdit + tags: + - Cases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + put: + operationId: updateCaseDraft + tags: + - Cases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CaseUpdateRequest' + security: + - sessionCookie: [] + delete: + operationId: deleteCaseDraft + tags: + - Cases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/references: + post: + operationId: createReferenceDraft + tags: + - References + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '201': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftRequest' + security: + - sessionCookie: [] + /api/v1/studio/references/{id}: + get: + operationId: getReferenceForEdit + tags: + - References + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + put: + operationId: updateReferenceDraft + tags: + - References + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceUpdateRequest' + security: + - sessionCookie: [] + delete: + operationId: deleteReferenceDraft + tags: + - References + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/cases/{id}/validate: + post: + operationId: validateCase + tags: + - Cases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/cases/{id}/submit-review: + post: + operationId: submitReviewCase + tags: + - Cases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/cases/{id}/return-to-draft: + post: + operationId: returnToDraftCase + tags: + - Cases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/cases/{id}/unpublish: + post: + operationId: unpublishCase + tags: + - Cases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/cases/{id}/archive: + post: + operationId: archiveCase + tags: + - Cases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/cases/{id}/restore: + post: + operationId: restoreCase + tags: + - Cases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/cases/{id}/publish: + post: + operationId: publishCase + tags: + - Cases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PublishResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishRequest' + security: + - sessionCookie: [] + /api/v1/studio/references/{id}/validate: + post: + operationId: validateReference + tags: + - References + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/references/{id}/submit-review: + post: + operationId: submitReviewReference + tags: + - References + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/references/{id}/return-to-draft: + post: + operationId: returnToDraftReference + tags: + - References + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/references/{id}/unpublish: + post: + operationId: unpublishReference + tags: + - References + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/references/{id}/archive: + post: + operationId: archiveReference + tags: + - References + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/references/{id}/restore: + post: + operationId: restoreReference + tags: + - References + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/references/{id}/publish: + post: + operationId: publishReference + tags: + - References + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PublishResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions: + post: + operationId: createQuestion + tags: + - Questions + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '201': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftRequest' + security: + - sessionCookie: [] + get: + operationId: listStudioQuestions + tags: + - Questions + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionIndexPage' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}: + get: + operationId: getQuestionForEdit + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + put: + operationId: updateQuestion + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionUpdateBody' + security: + - sessionCookie: [] + delete: + operationId: deleteQuestion + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}/updates: + post: + operationId: addQuestionUpdate + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '201': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionUpdateResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionUpdateRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}/updates/{updateId}: + put: + operationId: updateQuestionUpdate + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: updateId + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionUpdateResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionUpdateRequest' + security: + - sessionCookie: [] + delete: + operationId: deleteQuestionUpdate + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: updateId + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}/resolve: + post: + operationId: resolveQuestion + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveQuestionResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveQuestionRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}/start-investigation: + post: + operationId: startQuestionInvestigation + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}/pause: + post: + operationId: pauseQuestion + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}/resume: + post: + operationId: resumeQuestion + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}/reopen: + post: + operationId: reopenQuestion + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}/archive: + post: + operationId: archiveQuestion + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}/publish: + post: + operationId: publishQuestion + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PublishResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishRequest' + security: + - sessionCookie: [] + /api/v1/studio/questions/{id}/unpublish: + post: + operationId: unpublishQuestion + tags: + - Questions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/projects: + post: + operationId: createProject + tags: + - Projects + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '201': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftResponseEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: Unprocessable Content + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftRequest' + security: + - sessionCookie: [] + get: + operationId: listStudioProjects + tags: + - Projects + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectIndexPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + security: + - sessionCookie: [] + /api/v1/studio/projects/{id}: + get: + operationId: getProjectForEdit + tags: + - Projects + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectEditResponseEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + security: + - sessionCookie: [] + put: + operationId: updateProject + tags: + - Projects + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectEditResponseEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: Unprocessable Content + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectUpdateRequest' + security: + - sessionCookie: [] + delete: + operationId: deleteProject + tags: + - Projects + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: Unprocessable Content + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/projects/{id}/change-phase: + post: + operationId: changeProjectPhase + tags: + - Projects + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChangeProjectPhaseRequest' + security: + - sessionCookie: [] + /api/v1/studio/projects/{id}/publish: + post: + operationId: publishProject + tags: + - Projects + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PublishResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishRequest' + security: + - sessionCookie: [] + /api/v1/studio/projects/{id}/unpublish: + post: + operationId: unpublishProject + tags: + - Projects + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/projects/{id}/decisions: + post: + operationId: createProjectDecision + tags: + - Decisions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '201': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDecisionRequest' + security: + - sessionCookie: [] + get: + operationId: listStudioProjectDecisions + tags: + - Decisions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DecisionResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + /api/v1/studio/projects/{id}/decisions/{decisionId}: + get: + operationId: getProjectDecision + tags: + - Decisions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + put: + operationId: updateProjectDecision + tags: + - Decisions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionUpdateRequest' + security: + - sessionCookie: [] + /api/v1/studio/projects/{id}/decisions/{decisionId}/accept: + post: + operationId: acceptProjectDecision + tags: + - Decisions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/projects/{id}/decisions/{decisionId}/reject: + post: + operationId: rejectProjectDecision + tags: + - Decisions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/projects/{id}/decisions/{decisionId}/supersede: + post: + operationId: supersedeProjectDecision + tags: + - Decisions + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SupersedeDecisionRequest' + security: + - sessionCookie: [] + /api/v1/studio/releases: + post: + operationId: createRelease + tags: + - Releases + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '201': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftRequest' + security: + - sessionCookie: [] + get: + operationId: listStudioReleases + tags: + - Releases + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseIndexPage' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + /api/v1/studio/releases/{id}: + get: + operationId: getReleaseForEdit + tags: + - Releases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + put: + operationId: updateRelease + tags: + - Releases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseUpdateRequest' + security: + - sessionCookie: [] + delete: + operationId: deleteRelease + tags: + - Releases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/releases/{id}/publish: + post: + operationId: publishRelease + tags: + - Releases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PublishResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/releases/{id}/archive: + post: + operationId: archiveRelease + tags: + - Releases + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/topics: + get: + operationId: listStudioTopics + tags: + - Taxonomy + parameters: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TopicEditListEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + security: + - sessionCookie: [] + post: + operationId: createTopic + tags: + - Taxonomy + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '201': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TopicEditEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: Unprocessable Content + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TopicEdit' + security: + - sessionCookie: [] + /api/v1/studio/topics/{id}: + put: + operationId: updateTopic + tags: + - Taxonomy + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TopicEditEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: Unprocessable Content + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TopicEdit' + security: + - sessionCookie: [] + delete: + operationId: deleteTopic + tags: + - Taxonomy + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: Unprocessable Content + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/tags: + get: + operationId: listStudioTags + tags: + - Taxonomy + parameters: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TagEdit' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + post: + operationId: createTag + tags: + - Taxonomy + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '201': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TagEdit' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagEdit' + security: + - sessionCookie: [] + /api/v1/studio/tags/{id}: + put: + operationId: updateTag + tags: + - Taxonomy + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TagEdit' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagEdit' + security: + - sessionCookie: [] + delete: + operationId: deleteTag + tags: + - Taxonomy + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/site: + get: + operationId: getStudioSite + tags: + - Identity + parameters: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SiteConfigResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + put: + operationId: updateStudioSite + tags: + - Identity + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SiteConfigResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SiteConfigRequest' + security: + - sessionCookie: [] + /api/v1/studio/profile: + get: + operationId: getStudioProfile + tags: + - Identity + parameters: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + put: + operationId: updateStudioProfile + tags: + - Identity + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileEditRequest' + security: + - sessionCookie: [] + /api/v1/studio/profile/publish: + post: + operationId: publishProfile + tags: + - Identity + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PublishResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/profile/unpublish: + post: + operationId: unpublishProfile + tags: + - Identity + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileEditResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + security: + - sessionCookie: [] + /api/v1/studio/home-focus: + get: + operationId: getHomeFocus + tags: + - Identity + parameters: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HomeFocusResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + security: + - sessionCookie: [] + put: + operationId: updateHomeFocus + tags: + - Identity + parameters: + - $ref: '#/components/parameters/CsrfToken' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HomeFocusResponse' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/HomeFocusRequest' + security: + - sessionCookie: [] + /api/v1/studio/projects/{id}/activities: + get: + operationId: listStudioProjectActivities + tags: + - Projects + security: + - sessionCookie: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ProjectActivityResponse' + '401': + description: '401' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: '403' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: '404' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: '500' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + post: + operationId: createProjectActivity + tags: + - Projects + security: + - sessionCookie: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectActivityRequest' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectActivityResponse' + '400': + description: '400' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: '401' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: '403' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: '404' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: '409' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: '422' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: '500' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + /api/v1/studio/projects/{id}/activities/{activityId}: + put: + operationId: updateProjectActivity + tags: + - Projects + security: + - sessionCookie: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: activityId + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProjectActivityRequest' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectActivityResponse' + '400': + description: '400' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '401': + description: '401' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '403': + description: '403' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: '404' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: '409' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: '422' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: '500' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' +components: + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: TECHLOG_SESSION + parameters: + CsrfToken: + name: X-CSRF-TOKEN + in: header + required: true + schema: + type: string + schemas: + # ------------------------------------------------------------------------- + # 응답 봉투 (ADR-006) + # + # 이 파일 상단이 예고한 변환이다. 지금은 topics/projects 9개 operation 만 + # 옮겼다 — 그 둘이 첫 소비자이기 때문이고, 나머지는 구현에 착수할 때 같은 + # 방식으로 따라온다. 그동안 이 파일에는 두 모양이 공존한다: 변환된 + # operation 은 `application/json` + Envelope, 나머지는 예전 + # `application/problem+json` + ProblemDetails 다. + # ------------------------------------------------------------------------- + ResponseMeta: + type: object + additionalProperties: false + required: + - requestId + - traceId + properties: + requestId: + type: string + minLength: 1 + maxLength: 200 + traceId: + type: string + minLength: 1 + maxLength: 200 + correlationId: + type: + - string + - 'null' + maxLength: 200 + page: + type: + - object + - 'null' + additionalProperties: true + description: 백엔드 템플릿의 ResponseMeta record 가 직렬화하는 자리다. 페이지 정보는 payload 의 `page` 가 소유하므로 이 관리 표면에서는 항상 null 이다. + ApiError: + type: object + additionalProperties: false + required: + - code + - category + - message + - retryable + properties: + code: + type: string + enum: + - AUTHENTICATION_REQUIRED + - STUDIO_ACCESS_DENIED + - REQUEST_VALIDATION_FAILED + - VERSION_CONFLICT + - TOPIC_NOT_FOUND + - TOPIC_NAME_TAKEN + - TOPIC_SLUG_TAKEN + - TOPIC_IN_USE + - PROJECT_NOT_FOUND + - PROJECT_SLUG_TAKEN + - PROJECT_IN_USE + - INTERNAL_ERROR + description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' + category: + type: string + enum: + - VALIDATION + - AUTH + - AUTHZ + - NOT_FOUND + - CONFLICT + - RATE_LIMIT + - TRANSIENT_DEPENDENCY + - PERMANENT_DEPENDENCY + - DATA_INTEGRITY + - INTERNAL + message: + type: string + minLength: 1 + maxLength: 5000 + retryable: + type: boolean + details: + oneOf: + - $ref: '#/components/schemas/ValidationErrorDetails' + - $ref: '#/components/schemas/VersionConflictDetails' + - type: 'null' + ValidationErrorDetails: + type: object + additionalProperties: false + required: + - fieldErrors + properties: + fieldErrors: + type: array + maxItems: 200 + items: + $ref: '#/components/schemas/FieldError' + VersionConflictDetails: + type: object + additionalProperties: false + required: + - currentVersion + properties: + currentVersion: + type: integer + format: int64 + minimum: 0 + ErrorEnvelope: + type: object + additionalProperties: false + required: + - success + - error + - meta + properties: + success: + type: boolean + const: false + error: + $ref: '#/components/schemas/ApiError' + meta: + $ref: '#/components/schemas/ResponseMeta' + TopicEditEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/TopicEdit' + meta: + $ref: '#/components/schemas/ResponseMeta' + TopicEditListEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + type: array + maxItems: 500 + items: + $ref: '#/components/schemas/TopicEdit' + meta: + $ref: '#/components/schemas/ResponseMeta' + CreateDraftResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/CreateDraftResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectIndexPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectIndexPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectEditResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectEditResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + FieldError: + type: object + required: + - field + - code + - message + properties: + field: + type: string + code: + type: string + message: + type: string + ProblemDetails: + type: object + required: + - type + - title + - status + - code + - detail + - instance + - traceId + properties: + type: + type: string + title: + type: string + status: + type: integer + code: + type: string + detail: + type: string + instance: + type: string + traceId: + type: string + fieldErrors: + type: array + items: + $ref: '#/components/schemas/FieldError' + currentVersion: + type: integer + format: int64 + PageMetadata: + type: object + required: + - number + - size + - totalElements + - totalPages + - hasPrevious + - hasNext + properties: + number: + type: integer + minimum: 1 + size: + type: integer + minimum: 1 + maximum: 100 + totalElements: + type: integer + format: int64 + minimum: 0 + totalPages: + type: integer + minimum: 0 + hasPrevious: + type: boolean + hasNext: + type: boolean + TagSummary: + type: object + required: + - name + - slug + properties: + name: + type: string + slug: + type: string + ProjectSummary: + type: object + required: + - name + - slug + - path + properties: + name: + type: string + slug: + type: string + path: + type: string + AssetReference: + type: object + required: + - assetId + - url + properties: + assetId: + type: string + format: uuid + url: + type: string + altText: + type: string + width: + type: integer + height: + type: integer + contentType: + type: string + RelatedEntry: + type: object + required: + - type + - title + - path + properties: + type: + type: string + enum: + - CASE + - REFERENCE + - QUESTION + - PROJECT + - PROJECT_DECISION + - RELEASE + title: + type: string + summary: + type: string + path: + type: string + PublicationStatus: + type: object + required: + - state + - hasUnpublishedChanges + properties: + state: + type: string + enum: + - NEVER_PUBLISHED + - ACTIVE + - WITHDRAWN + publishedSourceVersion: + type: integer + format: int64 + hasUnpublishedChanges: + type: boolean + canonicalPath: + type: string + publishedAt: + type: string + format: date-time + ValidationIssue: + type: object + required: + - severity + - code + - message + properties: + severity: + type: string + enum: + - ERROR + - WARNING + code: + type: string + field: + type: string + message: + type: string + ValidationResponse: + type: object + required: + - validForSave + - validForPublish + - issues + properties: + validForSave: + type: boolean + validForPublish: + type: boolean + issues: + type: array + items: + $ref: '#/components/schemas/ValidationIssue' + CreateDraftRequest: + type: object + required: + - title + properties: + title: + type: string + maxLength: 180 + CreateDraftResponse: + type: object + required: + - id + - status + - version + - createdAt + properties: + id: + type: string + format: uuid + status: + type: string + enum: + - DRAFT + version: + type: integer + format: int64 + createdAt: + type: string + format: date-time + RelationEdit: + type: object + properties: + primaryProjectId: + type: string + format: uuid + relatedProjectIds: + type: array + items: + type: string + format: uuid + originQuestionId: + type: string + format: uuid + relatedDocumentIds: + type: array + items: + type: string + format: uuid + derivedReferenceIds: + type: array + items: + type: string + format: uuid + CaseUpdateRequest: + type: object + required: + - expectedVersion + - title + - problemSummary + - conclusionSummary + - content + - contentFormatVersion + - tagIds + - relations + properties: + expectedVersion: + type: integer + format: int64 + title: + type: string + maxLength: 180 + slug: + type: string + maxLength: 180 + problemSummary: + type: string + maxLength: 600 + conclusionSummary: + type: string + maxLength: 600 + environmentSummary: + type: array + items: + type: string + content: + type: string + maxLength: 2000000 + contentFormatVersion: + type: integer + minimum: 1 + primaryTopicId: + type: string + format: uuid + tagIds: + type: array + maxItems: 12 + items: + type: string + format: uuid + relations: + $ref: '#/components/schemas/RelationEdit' + coverAssetId: + type: string + format: uuid + lastVerifiedAt: + type: string + format: date-time + targetVisibility: + type: string + enum: + - PRIVATE + - UNLISTED + - PUBLIC + CaseEditResponse: + type: object + required: + - id + - version + - title + - problemSummary + - conclusionSummary + - content + - contentFormatVersion + - tagIds + - relations + - workflowStatus + - targetVisibility + - publication + - updatedAt + properties: + id: + type: string + format: uuid + version: + type: integer + format: int64 + title: + type: string + slug: + type: string + problemSummary: + type: string + conclusionSummary: + type: string + environmentSummary: + type: array + items: + type: string + content: + type: string + contentFormatVersion: + type: integer + primaryTopicId: + type: string + format: uuid + tagIds: + type: array + items: + type: string + format: uuid + relations: + $ref: '#/components/schemas/RelationEdit' + coverAssetId: + type: string + format: uuid + lastVerifiedAt: + type: string + format: date-time + workflowStatus: + type: string + targetVisibility: + type: string + publication: + $ref: '#/components/schemas/PublicationStatus' + updatedAt: + type: string + format: date-time + ReferenceUpdateRequest: + type: object + required: + - expectedVersion + - title + - scopeSummary + - appliesTo + - excludedScope + - freshnessStatus + - content + - contentFormatVersion + - tagIds + - relations + properties: + expectedVersion: + type: integer + format: int64 + title: + type: string + maxLength: 180 + slug: + type: string + maxLength: 180 + scopeSummary: + type: string + maxLength: 600 + appliesTo: + type: array + items: + type: string + excludedScope: + type: array + items: + type: string + freshnessStatus: + type: string + enum: + - CURRENT + - REVIEW_DUE + - HISTORICAL + content: + type: string + maxLength: 2000000 + contentFormatVersion: + type: integer + minimum: 1 + primaryTopicId: + type: string + format: uuid + tagIds: + type: array + maxItems: 12 + items: + type: string + format: uuid + relations: + $ref: '#/components/schemas/RelationEdit' + coverAssetId: + type: string + format: uuid + lastVerifiedAt: + type: string + format: date-time + targetVisibility: + type: string + enum: + - PRIVATE + - UNLISTED + - PUBLIC + ReferenceEditResponse: + type: object + required: + - id + - version + - title + - scopeSummary + - appliesTo + - excludedScope + - freshnessStatus + - content + - contentFormatVersion + - tagIds + - relations + - workflowStatus + - targetVisibility + - publication + - updatedAt + properties: + id: + type: string + format: uuid + version: + type: integer + format: int64 + title: + type: string + slug: + type: string + scopeSummary: + type: string + appliesTo: + type: array + items: + type: string + excludedScope: + type: array + items: + type: string + freshnessStatus: + type: string + content: + type: string + contentFormatVersion: + type: integer + primaryTopicId: + type: string + format: uuid + tagIds: + type: array + items: + type: string + format: uuid + relations: + $ref: '#/components/schemas/RelationEdit' + coverAssetId: + type: string + format: uuid + lastVerifiedAt: + type: string + format: date-time + workflowStatus: + type: string + targetVisibility: + type: string + publication: + $ref: '#/components/schemas/PublicationStatus' + updatedAt: + type: string + format: date-time + ExpectedVersionRequest: + type: object + required: + - expectedVersion + properties: + expectedVersion: + type: integer + format: int64 + PublishRequest: + type: object + required: + - expectedVersion + - visibility + properties: + expectedVersion: + type: integer + format: int64 + visibility: + type: string + enum: + - PUBLIC + - UNLISTED + PublishResponse: + type: object + required: + - id + - status + - visibility + - canonicalPath + - publishedAt + - version + properties: + id: + type: string + format: uuid + status: + type: string + enum: + - PUBLISHED + visibility: + type: string + enum: + - PUBLIC + - UNLISTED + canonicalPath: + type: string + publishedAt: + type: string + format: date-time + version: + type: integer + format: int64 + QuestionPointEdit: + type: object + required: + - kind + - content + - displayOrder + properties: + id: + type: string + format: uuid + kind: + type: string + enum: + - FACT + - ASSUMPTION + - UNKNOWN + - CONSTRAINT + content: + type: string + displayOrder: + type: integer + minimum: 0 + QuestionUpdateRequest: + type: object + required: + - expectedVersion + - type + - title + - bodyMarkdown + - visibility + - occurredAt + properties: + type: + type: string + enum: + - OBSERVATION + - EVIDENCE + - SCOPE_CHANGE + - BLOCKER + - NEXT_STEP + - RESOLUTION + - RESOLUTION_REOPENED + title: + type: string + maxLength: 180 + bodyMarkdown: + type: string + visibility: + type: string + enum: + - PRIVATE + - PUBLIC + occurredAt: + type: string + format: date-time + expectedVersion: + type: integer + format: int64 + QuestionUpdateResponse: + type: object + required: + - id + - questionVersion + - type + - title + - bodyMarkdown + - visibility + - occurredAt + properties: + id: + type: string + format: uuid + questionVersion: + type: integer + format: int64 + type: + type: string + title: + type: string + bodyMarkdown: + type: string + visibility: + type: string + occurredAt: + type: string + format: date-time + QuestionUpdateBody: + type: object + required: + - expectedVersion + - question + - summary + - context + - importance + - points + - tagIds + properties: + expectedVersion: + type: integer + format: int64 + question: + type: string + maxLength: 300 + slug: + type: string + maxLength: 180 + summary: + type: string + maxLength: 600 + context: + type: string + importance: + type: string + nextVerification: + type: string + targetVisibility: + type: string + enum: + - PRIVATE + - UNLISTED + - PUBLIC + primaryProjectId: + type: string + format: uuid + topicId: + type: string + format: uuid + tagIds: + type: array + maxItems: 12 + items: + type: string + format: uuid + points: + type: array + items: + $ref: '#/components/schemas/QuestionPointEdit' + QuestionEditResponse: + type: object + required: + - id + - version + - question + - status + - targetVisibility + - points + - updates + - publication + - updatedAt + properties: + id: + type: string + format: uuid + version: + type: integer + format: int64 + question: + type: string + slug: + type: string + summary: + type: string + context: + type: string + importance: + type: string + nextVerification: + type: string + status: + type: string + targetVisibility: + type: string + primaryProjectId: + type: string + format: uuid + topicId: + type: string + format: uuid + tagIds: + type: array + items: + type: string + format: uuid + points: + type: array + items: + $ref: '#/components/schemas/QuestionPointEdit' + updates: + type: array + items: + $ref: '#/components/schemas/QuestionUpdateResponse' + resolution: + type: object + properties: + type: + type: string + summary: + type: string + resolvedAt: + type: string + format: date-time + publication: + $ref: '#/components/schemas/PublicationStatus' + updatedAt: + type: string + format: date-time + ResolveQuestionRequest: + type: object + required: + - expectedVersion + - resolutionType + - resolutionSummary + - resolvedAt + - createCaseDraft + - createProjectDecision + properties: + expectedVersion: + type: integer + format: int64 + resolutionType: + type: string + enum: + - DECISION_MADE + - ASSUMPTION_REJECTED + - QUESTION_REFRAMED + - NO_LONGER_RELEVANT + resolutionSummary: + type: string + resolvedAt: + type: string + format: date-time + createCaseDraft: + type: boolean + createProjectDecision: + type: boolean + ResolveQuestionResponse: + type: object + required: + - questionId + - status + - version + properties: + questionId: + type: string + format: uuid + status: + type: string + enum: + - RESOLVED + version: + type: integer + format: int64 + createdCaseDraftId: + type: string + format: uuid + createdProjectDecisionId: + type: string + format: uuid + ProjectUpdateRequest: + type: object + required: + - expectedVersion + - name + - oneLinePurpose + - purposeMarkdown + - boundaryMarkdown + - phase + - technologyLabels + - targetVisibility + properties: + expectedVersion: + type: integer + format: int64 + name: + type: string + maxLength: 180 + slug: + type: string + maxLength: 180 + oneLinePurpose: + type: string + maxLength: 600 + purposeMarkdown: + type: string + boundaryMarkdown: + type: string + systemOverviewMarkdown: + type: string + phase: + type: string + currentObjective: + type: string + nextStep: + type: string + technologyLabels: + type: array + items: + type: string + targetVisibility: + type: string + enum: + - PRIVATE + - UNLISTED + - PUBLIC + featuredOrder: + type: integer + topicIds: + type: array + items: + type: string + format: uuid + documentLinks: + type: array + items: + $ref: '#/components/schemas/ProjectResourceLinkEdit' + questionLinks: + type: array + items: + $ref: '#/components/schemas/ProjectResourceLinkEdit' + ProjectEditResponse: + type: object + required: + - id + - version + - name + - phase + - workflowStatus + - targetVisibility + - publication + - updatedAt + properties: + id: + type: string + format: uuid + version: + type: integer + format: int64 + name: + type: string + slug: + type: string + oneLinePurpose: + type: string + purposeMarkdown: + type: string + boundaryMarkdown: + type: string + systemOverviewMarkdown: + type: string + phase: + type: string + currentObjective: + type: string + nextStep: + type: string + technologyLabels: + type: array + items: + type: string + workflowStatus: + type: string + targetVisibility: + type: string + featuredOrder: + type: integer + publication: + $ref: '#/components/schemas/PublicationStatus' + updatedAt: + type: string + format: date-time + topicIds: + type: array + items: + type: string + format: uuid + documentLinks: + type: array + items: + $ref: '#/components/schemas/ProjectResourceLinkEdit' + questionLinks: + type: array + items: + $ref: '#/components/schemas/ProjectResourceLinkEdit' + ChangeProjectPhaseRequest: + type: object + required: + - expectedVersion + - phase + - reason + - publishActivity + properties: + expectedVersion: + type: integer + format: int64 + phase: + type: string + reason: + type: string + publishActivity: + type: boolean + DecisionUpdateRequest: + type: object + required: + - expectedVersion + - statement + - rationaleMarkdown + - consequences + - alternativesMarkdown + - targetVisibility + properties: + expectedVersion: + type: integer + format: int64 + statement: + type: string + maxLength: 1000 + rationaleMarkdown: + type: string + consequences: + type: array + items: + type: string + alternativesMarkdown: + type: string + targetVisibility: + type: string + enum: + - PRIVATE + - PUBLIC + sourceQuestionId: + type: string + format: uuid + sourceCaseId: + type: string + format: uuid + isFeatured: + type: boolean + decidedAt: + type: string + format: date-time + DecisionResponse: + allOf: + - $ref: '#/components/schemas/DecisionUpdateRequest' + - type: object + required: + - id + - projectId + - status + - version + properties: + id: + type: string + format: uuid + projectId: + type: string + format: uuid + status: + type: string + version: + type: integer + format: int64 + supersededById: + type: string + format: uuid + ReleaseUpdateRequest: + type: object + required: + - expectedVersion + - versionLabel + - title + - summary + - changeTypes + - changesMarkdown + - verificationMarkdown + properties: + expectedVersion: + type: integer + format: int64 + versionLabel: + type: string + maxLength: 32 + title: + type: string + maxLength: 180 + summary: + type: string + maxLength: 600 + releasedOn: + type: string + format: date + changeTypes: + type: array + items: + type: string + reasonMarkdown: + type: string + changesMarkdown: + type: string + userImpactMarkdown: + type: string + implementationImpactMarkdown: + type: string + verificationMarkdown: + type: string + knownLimitationsMarkdown: + type: string + relatedResources: + type: array + items: + type: object + required: + - resourceType + - resourceId + properties: + resourceType: + type: string + resourceId: + type: string + format: uuid + ReleaseEditResponse: + allOf: + - $ref: '#/components/schemas/ReleaseUpdateRequest' + - type: object + required: + - id + - workflowStatus + - version + - publication + properties: + id: + type: string + format: uuid + workflowStatus: + type: string + version: + type: integer + format: int64 + publication: + $ref: '#/components/schemas/PublicationStatus' + TopicEdit: + type: object + required: + - name + - slug + properties: + id: + type: string + format: uuid + description: |- + 응답에만 실린다. 경로가 `/topics/{id}`이므로 이 값이 없으면 목록에서 어떤 주제도 + 주소지정할 수 없다 — ProjectEditResponse / CaseEditResponse는 같은 이유로 이미 + id를 싣고 있었고, TopicEdit만 빠져 있었다. 쓰기 요청에 와도 서버는 무시한다; + 수정 대상은 경로가 소유한다. + version: + type: integer + format: int64 + description: |- + 응답에만 실린다. 다음 수정에서 보낼 `expectedVersion`의 출처다. 이것이 없으면 + 클라이언트가 낙관적 잠금에 쓸 값을 얻을 방법이 없다. + name: + type: string + maxLength: 80 + slug: + type: string + maxLength: 100 + description: + type: string + maxLength: 600 + scope: + type: string + status: + type: string + enum: + - ACTIVE + - ARCHIVED + expectedVersion: + type: integer + format: int64 + featuredReferenceId: + type: string + format: uuid + featuredCaseIds: + type: array + maxItems: 3 + items: + type: string + format: uuid + TagEdit: + type: object + required: + - name + - slug + properties: + name: + type: string + maxLength: 40 + slug: + type: string + maxLength: 60 + expectedVersion: + type: integer + format: int64 + SiteConfigRequest: + type: object + required: + - expectedVersion + - brandTitle + - identityStatement + - operatorDisplayName + - contacts + properties: + expectedVersion: + type: integer + format: int64 + brandTitle: + type: string + identityStatement: + type: string + operatorDisplayName: + type: string + shortIdentity: + type: string + avatarAssetId: + type: string + format: uuid + contacts: + type: array + items: + type: object + ProfileEditRequest: + type: object + required: + - expectedVersion + - headline + - workingModel + - territories + - selectedEvidence + - trajectory + - contacts + - targetVisibility + properties: + expectedVersion: + type: integer + format: int64 + headline: + type: string + introductionMarkdown: + type: string + workingModel: + type: array + items: + type: object + territories: + type: array + items: + type: object + selectedEvidence: + type: array + items: + type: object + trajectory: + type: array + items: + type: object + contacts: + type: array + items: + type: object + targetVisibility: + type: string + enum: + - PRIVATE + - PUBLIC + HomeFocusRequest: + type: object + required: + - expectedVersion + properties: + expectedVersion: + type: integer + format: int64 + defaultType: + type: string + enum: + - CURRENT_WORK + - OPEN_QUESTION + - RECENT_DECISION + currentProjectId: + type: string + format: uuid + openQuestionId: + type: string + format: uuid + recentDecisionId: + type: string + format: uuid + QuestionIndexItem: + type: object + required: + - id + - question + - status + - targetVisibility + - updatedAt + - version + - publication + properties: + id: + type: string + format: uuid + question: + type: string + status: + type: string + targetVisibility: + type: string + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + nextVerification: + type: string + updatedAt: + type: string + format: date-time + version: + type: integer + format: int64 + publication: + $ref: '#/components/schemas/PublicationStatus' + QuestionIndexPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/QuestionIndexItem' + page: + $ref: '#/components/schemas/PageMetadata' + ProjectIndexItem: + type: object + required: + - id + - name + - phase + - workflowStatus + - targetVisibility + - updatedAt + - version + - publication + properties: + id: + type: string + format: uuid + name: + type: string + phase: + type: string + workflowStatus: + type: string + targetVisibility: + type: string + currentObjective: + type: string + nextStep: + type: string + updatedAt: + type: string + format: date-time + version: + type: integer + format: int64 + publication: + $ref: '#/components/schemas/PublicationStatus' + ProjectIndexPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectIndexItem' + page: + $ref: '#/components/schemas/PageMetadata' + ReleaseIndexItem: + type: object + required: + - id + - versionLabel + - title + - workflowStatus + - updatedAt + - version + - publication + properties: + id: + type: string + format: uuid + versionLabel: + type: string + title: + type: string + releasedOn: + type: string + format: date + workflowStatus: + type: string + updatedAt: + type: string + format: date-time + version: + type: integer + format: int64 + publication: + $ref: '#/components/schemas/PublicationStatus' + ReleaseIndexPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/ReleaseIndexItem' + page: + $ref: '#/components/schemas/PageMetadata' + SiteConfigResponse: + type: object + required: + - id + - version + - brandTitle + - identityStatement + - operatorDisplayName + - contacts + properties: + id: + type: string + format: uuid + version: + type: integer + format: int64 + brandTitle: + type: string + identityStatement: + type: string + operatorDisplayName: + type: string + shortIdentity: + type: string + avatarAssetId: + type: string + format: uuid + contacts: + type: array + items: + type: object + ProfileEditResponse: + type: object + required: + - id + - version + - headline + - workingModel + - territories + - selectedEvidence + - trajectory + - contacts + - targetVisibility + - publication + properties: + id: + type: string + format: uuid + version: + type: integer + format: int64 + headline: + type: string + introductionMarkdown: + type: string + workingModel: + type: array + items: + type: object + territories: + type: array + items: + type: object + selectedEvidence: + type: array + items: + type: object + trajectory: + type: array + items: + type: object + contacts: + type: array + items: + type: object + targetVisibility: + type: string + publication: + $ref: '#/components/schemas/PublicationStatus' + HomeFocusResponse: + type: object + required: + - id + - version + properties: + id: + type: string + format: uuid + version: + type: integer + format: int64 + defaultType: + type: string + currentProjectId: + type: string + format: uuid + openQuestionId: + type: string + format: uuid + recentDecisionId: + type: string + format: uuid + ProjectResourceLinkEdit: + type: object + required: + - resourceId + - relationType + properties: + resourceId: + type: string + format: uuid + relationType: + type: string + enum: + - PRIMARY + - RELATED + featuredOrder: + type: integer + minimum: 0 + CreateDecisionRequest: + type: object + required: + - expectedProjectVersion + - statement + - rationaleMarkdown + - consequences + - alternativesMarkdown + - targetVisibility + properties: + expectedProjectVersion: + type: integer + format: int64 + statement: + type: string + maxLength: 1000 + rationaleMarkdown: + type: string + consequences: + type: array + items: + type: string + alternativesMarkdown: + type: string + targetVisibility: + type: string + enum: + - PRIVATE + - PUBLIC + sourceQuestionId: + type: string + format: uuid + sourceCaseId: + type: string + format: uuid + isFeatured: + type: boolean + decidedAt: + type: string + format: date-time + SupersedeDecisionRequest: + type: object + required: + - expectedVersion + - supersededById + properties: + expectedVersion: + type: integer + format: int64 + supersededById: + type: string + format: uuid + ProjectActivityRequest: + type: object + required: + - expectedProjectVersion + - activityType + - title + - visibility + - occurredAt + properties: + expectedProjectVersion: + type: integer + format: int64 + activityType: + type: string + title: + type: string + maxLength: 180 + summary: + type: string + maxLength: 600 + visibility: + type: string + enum: + - PRIVATE + - PUBLIC + relatedResourceType: + type: string + relatedResourceId: + type: string + format: uuid + occurredAt: + type: string + format: date-time + ProjectActivityResponse: + type: object + required: + - id + - projectId + - activityType + - title + - visibility + - origin + - occurredAt + - version + properties: + id: + type: string + format: uuid + projectId: + type: string + format: uuid + activityType: + type: string + title: + type: string + summary: + type: string + visibility: + type: string + origin: + type: string + relatedResourceType: + type: string + relatedResourceId: + type: string + format: uuid + occurredAt: + type: string + format: date-time + version: + type: integer + format: int64 + UpdateProjectActivityRequest: + type: object + required: + - expectedVersion + - title + - visibility + - occurredAt + properties: + expectedVersion: + type: integer + format: int64 + title: + type: string + summary: + type: string + visibility: + type: string + enum: + - PRIVATE + - PUBLIC + occurredAt: + type: string + format: date-time