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