diff --git a/docs/registries/error-codes.yaml b/docs/registries/error-codes.yaml index 06f0b05..b7a5714 100644 --- a/docs/registries/error-codes.yaml +++ b/docs/registries/error-codes.yaml @@ -1363,4 +1363,43 @@ errors: log_level: INFO runbook_link: null compatibility_impact: additive + required_test: ManagementErrorRegistryTest + # source: studio-management-v1.yaml ApiError.code — RELEASE_NOT_FOUND (ManagementError.RELEASE_NOT_FOUND) + - code: RELEASE_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 — RELEASE_VERSION_TAKEN (ManagementError.RELEASE_VERSION_TAKEN) + - code: RELEASE_VERSION_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 — RELEASE_NOT_PUBLISHABLE (ManagementError.RELEASE_NOT_PUBLISHABLE) + - code: RELEASE_NOT_PUBLISHABLE + 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/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 index 979d5c5..fa5b9f6 100644 --- 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 @@ -3,8 +3,8 @@ package dev.caskeleton.adapter.inbound.web.techlog.management; import dev.caskeleton.application.techlog.error.ManagementError; /** - * code 별 고정 문구. 예외의 원문 메시지는 진단용이라 그대로 내보내지 않는다 — 저장소 제약 이름이나 - * SQL 조각이 새어 나갈 수 있고, 그건 클라이언트가 분기할 값도 아니다. + * code 별 고정 문구. 예외의 원문 메시지는 진단용이라 그대로 내보내지 않는다 — 저장소 제약 이름이나 SQL 조각이 새어 나갈 수 있고, 그건 클라이언트가 분기할 값도 + * 아니다. */ public final class ManagementClientSafeMessages { @@ -23,6 +23,9 @@ public final class ManagementClientSafeMessages { case PROJECT_NOT_FOUND -> "프로젝트를 찾을 수 없습니다"; case PROJECT_SLUG_TAKEN -> "같은 slug 의 프로젝트가 이미 있습니다"; case PROJECT_IN_USE -> "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다"; + case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다"; + case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다"; + case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다"; }; } } 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 index ebf7d41..e021cb9 100644 --- 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 @@ -18,9 +18,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; /** - * 관리 표면의 실패를 봉투로 옮긴다. 스코프를 {@code ...web.techlog.management} 로 좁히는 이유는 형제 표면들과 - * 같다 — 각 계약이 자기 {@code ApiError.code} 집합만 열거하고 있어서, 다른 표면의 코드가 새어 들어가면 그 - * 계약이 거짓이 된다. + * 관리 표면의 실패를 봉투로 옮긴다. 스코프를 {@code ...web.techlog.management} 로 좁히는 이유는 형제 표면들과 같다 — 각 계약이 자기 {@code + * ApiError.code} 집합만 열거하고 있어서, 다른 표면의 코드가 새어 들어가면 그 계약이 거짓이 된다. */ @Order(Ordered.HIGHEST_PRECEDENCE) @RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.management") @@ -41,9 +40,8 @@ public class ManagementExceptionHandler { } /** - * 본문을 못 읽는 경우(빈 본문, 깨진 JSON, enum 값 불일치). 그냥 두면 부모 처리기가 RFC 7807 을 만들고 - * {@code EnvelopeBodyAdvice} 의 미디어타입 검사에 걸려 봉투가 안 씌워진다 — ADR-006 이 쓰지 않기로 한 - * 모양이 그대로 나간다. + * 본문을 못 읽는 경우(빈 본문, 깨진 JSON, enum 값 불일치). 그냥 두면 부모 처리기가 RFC 7807 을 만들고 {@code EnvelopeBodyAdvice} + * 의 미디어타입 검사에 걸려 봉투가 안 씌워진다 — ADR-006 이 쓰지 않기로 한 모양이 그대로 나간다. */ @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity> handleUnreadableBody(HttpMessageNotReadableException ex) { @@ -63,8 +61,7 @@ public class ManagementExceptionHandler { } /** 계약의 {@code ValidationErrorDetails} — {@code field/code/message} 셋 다 required 다. */ - private static ResponseEntity> invalid( - String field, String code, String message) { + 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, 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 index bb7bae7..10582d1 100644 --- 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 @@ -5,8 +5,8 @@ import dev.caskeleton.application.techlog.error.ManagementError; import dev.caskeleton.application.techlog.error.ManagementException; /** - * 감사 컬럼에 남길 주체. {@code StudioPrincipals} 와 같은 일을 하되 이 표면의 error code 로 던진다 — - * 계약이 각자 code 집합을 열거하므로 예외까지 공유하면 한쪽 계약이 거짓이 된다. + * 감사 컬럼에 남길 주체. {@code StudioPrincipals} 와 같은 일을 하되 이 표면의 error code 로 던진다 — 계약이 각자 code 집합을 열거하므로 + * 예외까지 공유하면 한쪽 계약이 거짓이 된다. */ public final class ManagementPrincipals { diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementReleaseController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementReleaseController.java new file mode 100644 index 0000000..bae7b05 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementReleaseController.java @@ -0,0 +1,145 @@ +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.PublishResponse; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEditResponse; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseUpdateRequest; +import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper; +import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand; +import dev.caskeleton.application.techlog.management.command.ReleaseLifecycleCommand; +import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand; +import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase; +import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase; +import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase; +import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase; +import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase; +import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase; +import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase; +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; + +/** 릴리즈 관리. 계약의 release 7개. */ +@RestController +public class ManagementReleaseController { + + private final ListStudioReleasesUseCase listReleases; + private final GetReleaseForEditUseCase getRelease; + private final CreateReleaseUseCase createRelease; + private final UpdateReleaseUseCase updateRelease; + private final DeleteReleaseUseCase deleteRelease; + private final PublishReleaseUseCase publishRelease; + private final ArchiveReleaseUseCase archiveRelease; + + public ManagementReleaseController( + ListStudioReleasesUseCase listReleases, + GetReleaseForEditUseCase getRelease, + CreateReleaseUseCase createRelease, + UpdateReleaseUseCase updateRelease, + DeleteReleaseUseCase deleteRelease, + PublishReleaseUseCase publishRelease, + ArchiveReleaseUseCase archiveRelease) { + this.listReleases = listReleases; + this.getRelease = getRelease; + this.createRelease = createRelease; + this.updateRelease = updateRelease; + this.deleteRelease = deleteRelease; + this.publishRelease = publishRelease; + this.archiveRelease = archiveRelease; + } + + @GetMapping("/v1/studio/releases") + public ReleaseIndexPage listStudioReleases( + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + return ManagementResponseMapper.releases(listReleases.handle(page, size)); + } + + @GetMapping("/v1/studio/releases/{id}") + public ReleaseEditResponse getReleaseForEdit(@PathVariable("id") UUID id) { + return ManagementResponseMapper.release(getRelease.handle(id)); + } + + @PostMapping("/v1/studio/releases") + @ResponseStatus(HttpStatus.CREATED) + public CreateDraftResponse createRelease( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @RequestBody CreateDraftRequest body) { + return ManagementResponseMapper.releaseDraft( + createRelease.handle( + new CreateReleaseCommand(body.getTitle(), ManagementPrincipals.require(principal)))); + } + + @PutMapping("/v1/studio/releases/{id}") + public ReleaseEditResponse updateRelease( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @PathVariable("id") UUID id, + @RequestBody ReleaseUpdateRequest body) { + List changeTypes = + body.getChangeTypes() == null ? List.of() : List.copyOf(body.getChangeTypes()); + return ManagementResponseMapper.release( + updateRelease.handle( + new UpdateReleaseCommand( + id, + body.getExpectedVersion(), + body.getVersionLabel(), + body.getTitle(), + body.getSummary(), + body.getReleasedOn(), + changeTypes, + body.getReasonMarkdown(), + body.getChangesMarkdown(), + body.getUserImpactMarkdown(), + body.getImplementationImpactMarkdown(), + body.getVerificationMarkdown(), + body.getKnownLimitationsMarkdown(), + ManagementPrincipals.require(principal)))); + } + + @DeleteMapping("/v1/studio/releases/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteRelease( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @PathVariable("id") UUID id, + @RequestBody ExpectedVersionRequest body) { + deleteRelease.handle(lifecycle(id, body, principal)); + } + + @PostMapping("/v1/studio/releases/{id}/publish") + public PublishResponse publishRelease( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @PathVariable("id") UUID id, + @RequestBody ExpectedVersionRequest body) { + return ManagementResponseMapper.releasePublication( + publishRelease.handle(lifecycle(id, body, principal))); + } + + @PostMapping("/v1/studio/releases/{id}/archive") + public ReleaseEditResponse archiveRelease( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @PathVariable("id") UUID id, + @RequestBody ExpectedVersionRequest body) { + return ManagementResponseMapper.release(archiveRelease.handle(lifecycle(id, body, principal))); + } + + private static ReleaseLifecycleCommand lifecycle( + UUID id, ExpectedVersionRequest body, AuthenticatedPrincipal principal) { + return new ReleaseLifecycleCommand( + 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 index 8b66abe..953c915 100644 --- 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 @@ -24,11 +24,10 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; /** - * 주제 관리. 계약 {@code listStudioTopics}/{@code createTopic}/{@code updateTopic}/{@code - * deleteTopic}. + * 주제 관리. 계약 {@code listStudioTopics}/{@code createTopic}/{@code updateTopic}/{@code deleteTopic}. * - *

쓰기 응답의 {@code id}/{@code version} 은 서버가 소유한다. 요청 본문에 실려 와도 무시하고 경로와 - * 저장소가 정한 값을 쓴다 — 그러지 않으면 클라이언트가 남의 행을 덮어쓸 수 있다. + *

쓰기 응답의 {@code id}/{@code version} 은 서버가 소유한다. 요청 본문에 실려 와도 무시하고 경로와 저장소가 정한 값을 쓴다 — 그러지 않으면 + * 클라이언트가 남의 행을 덮어쓸 수 있다. */ @RestController public class ManagementTopicController { 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 index 938e685..b2baa43 100644 --- 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 @@ -6,11 +6,18 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEd 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.PublishResponse; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEditResponse; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexItem; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage; 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.ReleaseEditView; +import dev.caskeleton.application.techlog.management.model.ReleaseIndexItemView; import dev.caskeleton.application.techlog.management.model.TopicEditView; import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase; +import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; @@ -43,18 +50,16 @@ public final class ManagementResponseMapper { 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, + first == null + ? PublicationStatus.StateEnum.NEVER_PUBLISHED + : PublicationStatus.StateEnum.ACTIVE, false); status.setPublishedAt(at(last)); return status; @@ -107,15 +112,83 @@ public final class ManagementResponseMapper { return item; } + private static PageMetadata pageMetadata( + int number, int size, int totalElements, int totalPages) { + return new PageMetadata( + number, size, (long) totalElements, totalPages, number > 0, number + 1 < totalPages); + } + 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); + return new ProjectIndexPage( + page.items().stream().map(ManagementResponseMapper::indexItem).toList(), + pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages())); + } + + /** + * 릴리즈 응답은 {@code ReleaseUpdateRequest} 를 allOf 로 물고 있어 {@code expectedVersion} 을 함께 싣는다. 현재 + * version 을 그대로 넣는다 — 그래야 편집 화면이 받은 응답을 그대로 다음 저장 요청으로 되돌려보낼 수 있고, 두 값이 갈라질 이유가 없다. + */ + public static ReleaseEditResponse release(ReleaseEditView view) { + ReleaseEditResponse model = + new ReleaseEditResponse( + view.version(), + view.versionLabel(), + view.title(), + nullToEmpty(view.summary()), + List.copyOf(view.changeTypes()), + nullToEmpty(view.changesMarkdown()), + nullToEmpty(view.verificationMarkdown()), + view.id(), + view.workflowStatus(), + view.version(), + publication(view.firstPublishedAt(), view.lastPublishedAt())); + model.setReleasedOn(view.releasedOn()); + model.setReasonMarkdown(view.reasonMarkdown()); + model.setUserImpactMarkdown(view.userImpactMarkdown()); + model.setImplementationImpactMarkdown(view.implementationImpactMarkdown()); + model.setKnownLimitationsMarkdown(view.knownLimitationsMarkdown()); + // 관련 자료 링크는 별도 테이블이 소유하고 그 편집 화면이 아직 없다. + model.setRelatedResources(List.of()); + return model; + } + + public static CreateDraftResponse releaseDraft(ReleaseEditView view) { + return new CreateDraftResponse( + view.id(), CreateDraftResponse.StatusEnum.DRAFT, view.version(), at(view.updatedAt())); + } + + /** 릴리즈에는 자체 공개 경로가 있다 — 공개 화면이 {@code version_label} 로 조회하므로 canonical path 도 그것으로 만든다. */ + public static PublishResponse releasePublication(ReleaseEditView view) { + return new PublishResponse( + view.id(), + PublishResponse.StatusEnum.PUBLISHED, + PublishResponse.VisibilityEnum.PUBLIC, + "/releases/" + view.versionLabel(), + at(view.lastPublishedAt()), + view.version()); + } + + private static ReleaseIndexItem releaseIndexItem(ReleaseIndexItemView view) { + ReleaseIndexItem item = + new ReleaseIndexItem( + view.id(), + view.versionLabel(), + view.title(), + view.workflowStatus(), + at(view.updatedAt()), + view.version(), + publication(view.firstPublishedAt(), view.lastPublishedAt())); + item.setReleasedOn(view.releasedOn()); + return item; + } + + public static ReleaseIndexPage releases(ListStudioReleasesUseCase.Page page) { + return new ReleaseIndexPage( + page.items().stream().map(ManagementResponseMapper::releaseIndexItem).toList(), + pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages())); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; } } 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 index 2da8aad..c40837d 100644 --- 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 @@ -14,15 +14,15 @@ import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; -import org.springframework.stereotype.Repository; /** * Project 편집 저장소. * - *

{@code technology_labels} 는 jsonb 다. 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 - * 보내 타입이 어긋나므로, JSON 문자열로 직렬화해 {@code ::jsonb} 로 캐스팅한다. + *

{@code technology_labels} 는 jsonb 다. 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 보내 타입이 어긋나므로, JSON + * 문자열로 직렬화해 {@code ::jsonb} 로 캐스팅한다. */ @Repository public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort { @@ -51,12 +51,18 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort { } private List labels(String json) { - if (json == null || json.isBlank()) return List.of(); + if (json == null || json.isBlank()) { + return List.of(); + } try { JsonNode node = objectMapper.readTree(json); - if (!node.isArray()) return List.of(); + if (!node.isArray()) { + return List.of(); + } List out = new ArrayList<>(); - for (JsonNode item : node) out.add(item.asString("")); + for (JsonNode item : node) { + out.add(item.asString("")); + } return List.copyOf(out); } catch (RuntimeException malformed) { // 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다. @@ -110,7 +116,9 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort { public List listAll(int limit, int offset) { return jdbcClient .sql( - "SELECT " + INDEX_COLUMNS + " FROM project ORDER BY updated_at DESC, id" + "SELECT " + + INDEX_COLUMNS + + " FROM project ORDER BY updated_at DESC, id" + " LIMIT :limit OFFSET :offset") .param("limit", limit) .param("offset", offset) diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcReleaseRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcReleaseRepositoryAdapter.java new file mode 100644 index 0000000..d32683b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcReleaseRepositoryAdapter.java @@ -0,0 +1,258 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.management; + +import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand; +import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand; +import dev.caskeleton.application.techlog.management.model.ReleaseEditView; +import dev.caskeleton.application.techlog.management.model.ReleaseIndexItemView; +import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; +import dev.caskeleton.application.techlog.management.service.ReleaseDrafts; +import java.sql.Date; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * Release 편집 저장소. + * + *

{@code change_types} 는 jsonb 다 — 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 보내 타입이 어긋나므로, JSON 으로 직렬화해 + * {@code ::jsonb} 로 캐스팅한다. + */ +@Repository +public class JdbcReleaseRepositoryAdapter implements ReleaseRepositoryPort { + + private static final String EDIT_COLUMNS = + "id, version, version_label, title, summary, released_on, change_types, reason_markdown," + + " changes_markdown, user_impact_markdown, implementation_impact_markdown," + + " verification_markdown, known_limitations_markdown, workflow_status," + + " first_published_at, last_published_at, updated_at"; + + private static final String INDEX_COLUMNS = + "id, version_label, title, released_on, workflow_status, updated_at, version," + + " first_published_at, last_published_at"; + + private final JdbcClient jdbcClient; + private final ObjectMapper objectMapper; + + public JdbcReleaseRepositoryAdapter(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 static LocalDate date(ResultSet rs, String column) throws SQLException { + Date d = rs.getDate(column); + return d == null ? null : d.toLocalDate(); + } + + private List changeTypes(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + JsonNode node = objectMapper.readTree(json); + if (!node.isArray()) { + return List.of(); + } + List out = new ArrayList<>(); + for (JsonNode item : node) { + out.add(item.asString("")); + } + return List.copyOf(out); + } catch (RuntimeException malformed) { + // 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다. + // 편집 화면 전체를 막는 대신 빈 목록으로 두고 나머지 필드를 보여준다. + return List.of(); + } + } + + private String changeTypesJson(List values) { + return objectMapper.writeValueAsString(values == null ? List.of() : values); + } + + private ReleaseEditView mapEdit(ResultSet rs, int rowNum) throws SQLException { + return new ReleaseEditView( + rs.getObject("id", UUID.class), + rs.getLong("version"), + rs.getString("version_label"), + rs.getString("title"), + rs.getString("summary"), + date(rs, "released_on"), + changeTypes(rs.getString("change_types")), + rs.getString("reason_markdown"), + rs.getString("changes_markdown"), + rs.getString("user_impact_markdown"), + rs.getString("implementation_impact_markdown"), + rs.getString("verification_markdown"), + rs.getString("known_limitations_markdown"), + rs.getString("workflow_status"), + instant(rs, "first_published_at"), + instant(rs, "last_published_at"), + instant(rs, "updated_at")); + } + + private static ReleaseIndexItemView mapIndex(ResultSet rs, int rowNum) throws SQLException { + return new ReleaseIndexItemView( + rs.getObject("id", UUID.class), + rs.getString("version_label"), + rs.getString("title"), + date(rs, "released_on"), + rs.getString("workflow_status"), + 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) { + // 발행된 것이 위로, 그다음 최근 수정 순. 변경 기록은 버전 순서가 의미를 갖지만 + // version_label 은 자유 문자열이라 정렬 키로 쓸 수 없다. + return jdbcClient + .sql( + "SELECT " + + INDEX_COLUMNS + + " FROM release" + + " ORDER BY released_on DESC NULLS LAST, updated_at DESC, id" + + " LIMIT :limit OFFSET :offset") + .param("limit", limit) + .param("offset", offset) + .query(JdbcReleaseRepositoryAdapter::mapIndex) + .list(); + } + + @Override + public int countAll() { + return Optional.ofNullable( + jdbcClient.sql("SELECT COUNT(*) FROM release").query(Integer.class).single()) + .orElse(0); + } + + @Override + public Optional find(UUID id) { + return jdbcClient + .sql("SELECT " + EDIT_COLUMNS + " FROM release WHERE id = :id") + .param("id", id) + .query(this::mapEdit) + .optional(); + } + + @Override + public ReleaseEditView create(CreateReleaseCommand command) { + UUID id = UUID.randomUUID(); + // version_label 은 NOT NULL UNIQUE 이고 초안에는 아직 버전이 없다. id 에서 파생한 자리표시자를 + // 넣어 유일성을 만족시키고, 발행이 그것이 실제 버전으로 바뀌었는지 확인한다. + String placeholder = ReleaseDrafts.PLACEHOLDER_PREFIX + id.toString().substring(0, 8); + jdbcClient + .sql( + "INSERT INTO release (id, version_label, title, created_by, updated_by)" + + " VALUES (:id, :label, :title, :actor, :actor)") + .param("id", id) + .param("label", placeholder) + .param("title", command.title().trim()) + .param("actor", command.actor()) + .update(); + return find(id).orElseThrow(); + } + + @Override + public Optional update(UpdateReleaseCommand command) { + int updated = + jdbcClient + .sql( + "UPDATE release SET version_label = :label, title = :title, summary = :summary," + + " released_on = :releasedOn," + + " change_types = CAST(:changeTypes AS jsonb)," + + " reason_markdown = :reasonMd, changes_markdown = :changesMd," + + " user_impact_markdown = :userImpactMd," + + " implementation_impact_markdown = :implImpactMd," + + " verification_markdown = :verificationMd," + + " known_limitations_markdown = :limitationsMd," + + " version = version + 1, updated_at = now(), updated_by = :actor" + + " WHERE id = :id AND version = :expected") + .param("id", command.id()) + .param("expected", command.expectedVersion()) + .param("label", command.versionLabel().trim()) + .param("title", command.title().trim()) + .param("summary", nullToEmpty(command.summary())) + .param( + "releasedOn", + command.releasedOn() == null ? null : Date.valueOf(command.releasedOn())) + .param("changeTypes", changeTypesJson(command.changeTypes())) + .param("reasonMd", nullToEmpty(command.reasonMarkdown())) + .param("changesMd", nullToEmpty(command.changesMarkdown())) + .param("userImpactMd", nullToEmpty(command.userImpactMarkdown())) + .param("implImpactMd", nullToEmpty(command.implementationImpactMarkdown())) + .param("verificationMd", nullToEmpty(command.verificationMarkdown())) + .param("limitationsMd", nullToEmpty(command.knownLimitationsMarkdown())) + .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 release WHERE id = :id AND version = :expected") + .param("id", id) + .param("expected", expectedVersion) + .update(); + } + + @Override + public boolean versionLabelTaken(String versionLabel, UUID exceptId) { + return Boolean.TRUE.equals( + jdbcClient + .sql( + "SELECT EXISTS (SELECT 1 FROM release WHERE version_label = :label" + + " AND (CAST(:except AS uuid) IS NULL OR id <> CAST(:except AS uuid)))") + .param("label", versionLabel) + .param("except", exceptId) + .query(Boolean.class) + .single()); + } + + @Override + public Optional transition( + UUID id, + long expectedVersion, + String workflowStatus, + boolean stampPublication, + String actor) { + // COALESCE 가 최초 발행 시각을 지킨다 — 재발행은 last_published_at 만 옮긴다. + String publicationClause = + stampPublication + ? " first_published_at = COALESCE(first_published_at, now()), last_published_at = now()," + : ""; + int updated = + jdbcClient + .sql( + "UPDATE release SET workflow_status = :status," + + publicationClause + + " version = version + 1, updated_at = now(), updated_by = :actor" + + " WHERE id = :id AND version = :expected") + .param("id", id) + .param("expected", expectedVersion) + .param("status", workflowStatus) + .param("actor", actor) + .update(); + return updated == 0 ? Optional.empty() : find(id); + } +} 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 index 647d90a..51758f3 100644 --- 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 @@ -15,15 +15,13 @@ import org.springframework.stereotype.Repository; /** * Topic 편집 저장소. * - *

정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@code - * uq_topic_normalized_name} 이 그 컬럼 위에 있으므로, 계산이 한 곳에만 있어야 사전 확인과 제약이 - * 같은 값을 본다. + *

정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@code uq_topic_normalized_name} 이 그 컬럼 위에 있으므로, 계산이 한 + * 곳에만 있어야 사전 확인과 제약이 같은 값을 본다. */ @Repository public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort { - private static final String COLUMNS = - "id, name, slug, description, scope, status, version"; + private static final String COLUMNS = "id, name, slug, description, scope, status, version"; private final JdbcClient jdbcClient; @@ -118,10 +116,7 @@ public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort { .update(); } - /** - * 참조 확인. 주제를 가리키는 곳이 늘어나면 여기도 늘어야 한다 — 빠뜨리면 외래키가 대신 막고 - * 500 이 나간다. - */ + /** 참조 확인. 주제를 가리키는 곳이 늘어나면 여기도 늘어야 한다 — 빠뜨리면 외래키가 대신 막고 500 이 나간다. */ @Override public boolean isReferenced(UUID id) { return Boolean.TRUE.equals( 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 index cbd0ef8..32102d9 100644 --- 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 @@ -1,22 +1,30 @@ package dev.caskeleton.bootstrap.techlog; import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort; +import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort; +import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase; import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase; +import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase; import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase; +import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase; import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase; import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase; +import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase; +import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase; +import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase; import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase; import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase; +import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase; 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개만 있다 — - * 그 둘이 문서 작성을 막고 있던 선행 조건이기 때문이다. + * 관리 표면(`studio-management-v1.yaml`)의 use case 배선. 지금은 topics 4개와 projects 5개만 있다 — 그 둘이 문서 작성을 막고 + * 있던 선행 조건이기 때문이다. */ @Configuration(proxyBeanMethods = false) public class TechLogManagementConfig { @@ -62,4 +70,41 @@ public class TechLogManagementConfig { DeleteProjectUseCase deleteProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) { return new DeleteProjectUseCase(projects, tx); } + + @Bean + ListStudioReleasesUseCase listStudioReleasesUseCase( + ReleaseRepositoryPort releases, TransactionPort tx) { + return new ListStudioReleasesUseCase(releases, tx); + } + + @Bean + GetReleaseForEditUseCase getReleaseForEditUseCase( + ReleaseRepositoryPort releases, TransactionPort tx) { + return new GetReleaseForEditUseCase(releases, tx); + } + + @Bean + CreateReleaseUseCase createReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) { + return new CreateReleaseUseCase(releases, tx); + } + + @Bean + UpdateReleaseUseCase updateReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) { + return new UpdateReleaseUseCase(releases, tx); + } + + @Bean + DeleteReleaseUseCase deleteReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) { + return new DeleteReleaseUseCase(releases, tx); + } + + @Bean + PublishReleaseUseCase publishReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) { + return new PublishReleaseUseCase(releases, tx); + } + + @Bean + ArchiveReleaseUseCase archiveReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) { + return new ArchiveReleaseUseCase(releases, 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 index 555fe02..e7b146e 100644 --- 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 @@ -21,12 +21,12 @@ import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; /** - * {@code PublicErrorRegistryTest} 가 {@code PublicError} 에 대해 하는 일을 {@link ManagementError} 에 - * 대해 한다 — row 존재 / 값 드리프트 / client-safe 문구, 그리고 계약 code 집합 대조. + * {@code PublicErrorRegistryTest} 가 {@code PublicError} 에 대해 하는 일을 {@link ManagementError} 에 대해 한다 + * — row 존재 / 값 드리프트 / client-safe 문구, 그리고 계약 code 집합 대조. * - *

계약은 12종을 열거하는데 enum 은 11종이다. 나머지 {@code INTERNAL_ERROR} 는 스켈레톤 공통 처리기가 - * 소유하며({@link OperationalError#INTERNAL_ERROR}) 여기서 재선언하지 않는다 — 그쪽은 {@code - * retryable=true} 라 같은 code 를 두 곳에서 선언하면 레지스트리가 어느 값을 따라야 할지 알 수 없다. + *

계약은 12종을 열거하는데 enum 은 11종이다. 나머지 {@code INTERNAL_ERROR} 는 스켈레톤 공통 처리기가 소유하며({@link + * OperationalError#INTERNAL_ERROR}) 여기서 재선언하지 않는다 — 그쪽은 {@code retryable=true} 라 같은 code 를 두 곳에서 + * 선언하면 레지스트리가 어느 값을 따라야 할지 알 수 없다. */ class ManagementErrorRegistryTest { @@ -75,11 +75,14 @@ class ManagementErrorRegistryTest { 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()) + 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()) + 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()) + assertThat(row.get("retryable")) + .as("retryable of %s", error.code()) .isEqualTo(error.retryable()); } } 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 index 44726c1..52b9b86 100644 --- 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 @@ -7,13 +7,13 @@ import dev.caskeleton.shared.error.Category; * 관리 계약(`studio-management-v1.yaml`)의 `ApiError.code` enum. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과 * `docs/registries/error-codes.yaml`을 함께 고쳐야 한다. * - *

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

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

계약의 {@code ApiError.code} 는 12종인데 여기는 11종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 - * 아니라 스켈레톤 공통 처리기가 내는 코드({@code OperationalError.INTERNAL_ERROR}) 이고, 같은 code 를 두 enum 이 - * 각자 status 와 retryable 을 달고 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다 — 실제로 그쪽은 - * {@code retryable=true} 다. {@code PublicError} 가 같은 이유로 같은 선택을 했다. + *

계약의 {@code ApiError.code} 는 15종인데 여기는 14종이다. 나머지 하나 {@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), @@ -26,7 +26,10 @@ public enum ManagementError implements ApiErrorCode { 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); + PROJECT_IN_USE(Category.CONFLICT, 409, false), + RELEASE_NOT_FOUND(Category.NOT_FOUND, 404, false), + RELEASE_VERSION_TAKEN(Category.CONFLICT, 409, false), + RELEASE_NOT_PUBLISHABLE(Category.CONFLICT, 409, false); private final Category category; private final int httpStatus; 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 index c40fc97..fe0820e 100644 --- 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 @@ -4,8 +4,8 @@ import dev.caskeleton.shared.error.ApiErrorCarrier; import dev.caskeleton.shared.error.ApiErrorCode; /** - * 관리 use case 가 던지는 유일한 실패 표현. {@link StudioException} 과 같은 모양이되 code 집합만 다르다 — 전송 계층은 - * {@link ApiErrorCarrier} 만 보므로 두 예외를 따로 처리할 필요가 없다. + * 관리 use case 가 던지는 유일한 실패 표현. {@link StudioException} 과 같은 모양이되 code 집합만 다르다 — 전송 계층은 {@link + * ApiErrorCarrier} 만 보므로 두 예외를 따로 처리할 필요가 없다. */ public final class ManagementException extends RuntimeException implements ApiErrorCarrier { 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 index 4da712e..7d741a8 100644 --- 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 @@ -1,6 +1,4 @@ package dev.caskeleton.application.techlog.management.command; -/** - * 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다. - */ +/** 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다. */ public record CreateProjectCommand(String title, String actor) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateReleaseCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateReleaseCommand.java new file mode 100644 index 0000000..7c503d0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateReleaseCommand.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.management.command; + +/** 계약 {@code CreateDraftRequest}. 제목 하나로 초안을 연다. */ +public record CreateReleaseCommand(String title, String actor) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/ReleaseLifecycleCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/ReleaseLifecycleCommand.java new file mode 100644 index 0000000..b813067 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/ReleaseLifecycleCommand.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.techlog.management.command; + +import java.util.UUID; + +/** + * 계약 {@code ExpectedVersionRequest}. delete / publish / archive 가 같은 모양이라 하나로 둔다 — 셋 다 "이 버전의 이 + * 릴리즈에 상태 전이를 건다"는 같은 요청이고, 세 개의 동일한 record 를 두면 셋이 갈라졌을 때 어느 것이 옳은지 알 수 없다. + */ +public record ReleaseLifecycleCommand(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 index 3676886..ef0be53 100644 --- 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 @@ -3,12 +3,11 @@ package dev.caskeleton.application.techlog.management.command; import java.util.UUID; /** - * 생성과 수정이 같은 명령을 쓴다. 계약이 두 경우 모두 {@code TopicEdit} 를 본문으로 받기 때문이고, - * 구분은 {@code id} 의 유무다 — {@code null} 이면 생성이다. + * 생성과 수정이 같은 명령을 쓴다. 계약이 두 경우 모두 {@code TopicEdit} 를 본문으로 받기 때문이고, 구분은 {@code id} 의 유무다 — {@code + * null} 이면 생성이다. * - *

{@code expectedVersion} 은 수정에서만 의미가 있다. 생성에 값이 와도 무시하는 대신 거절하지 - * 않는 이유는, 계약이 그 필드를 optional 로 두고 있어 클라이언트가 보내는 것이 위반이 아니기 - * 때문이다. + *

{@code expectedVersion} 은 수정에서만 의미가 있다. 생성에 값이 와도 무시하는 대신 거절하지 않는 이유는, 계약이 그 필드를 optional 로 두고 + * 있어 클라이언트가 보내는 것이 위반이 아니기 때문이다. */ public record SaveTopicCommand( UUID id, diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateReleaseCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateReleaseCommand.java new file mode 100644 index 0000000..4573a15 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateReleaseCommand.java @@ -0,0 +1,27 @@ +package dev.caskeleton.application.techlog.management.command; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +/** 계약 {@code ReleaseUpdateRequest}. */ +public record UpdateReleaseCommand( + UUID id, + long expectedVersion, + String versionLabel, + String title, + String summary, + LocalDate releasedOn, + List changeTypes, + String reasonMarkdown, + String changesMarkdown, + String userImpactMarkdown, + String implementationImpactMarkdown, + String verificationMarkdown, + String knownLimitationsMarkdown, + String actor) { + + public UpdateReleaseCommand { + changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes); + } +} 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 index 08fce2f..ddd4bd7 100644 --- 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 @@ -7,8 +7,8 @@ import java.util.UUID; /** * 계약 {@code ProjectEditResponse}. * - *

{@code topicIds}/{@code documentLinks}/{@code questionLinks} 는 링크 테이블이 소유한다. (가) - * 범위에서는 그 편집 화면이 없으므로 항상 비어 있고, 링크를 다루는 화면이 생길 때 같은 뷰에 채운다. + *

{@code topicIds}/{@code documentLinks}/{@code questionLinks} 는 링크 테이블이 소유한다. (가) 범위에서는 그 편집 + * 화면이 없으므로 항상 비어 있고, 링크를 다루는 화면이 생길 때 같은 뷰에 채운다. */ public record ProjectEditView( UUID id, diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ReleaseEditView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ReleaseEditView.java new file mode 100644 index 0000000..26802e1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ReleaseEditView.java @@ -0,0 +1,36 @@ +package dev.caskeleton.application.techlog.management.model; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +/** + * 계약 {@code ReleaseEditResponse}. + * + *

본문이 마크다운 여섯 구획으로 나뉘어 있는 것은 릴리즈 노트가 하나의 산문이 아니라 정해진 질문에 답하는 기록이기 때문이다 — 왜 바꿨나, 무엇을 바꿨나, 사용자에게 + * 무엇이 달라지나, 구현에 무엇이 남나, 어떻게 검증했나, 무엇을 아직 못 했나. 한 덩어리 text 였다면 그중 무엇이 빠졌는지 아무도 알 수 없다. + */ +public record ReleaseEditView( + UUID id, + long version, + String versionLabel, + String title, + String summary, + LocalDate releasedOn, + List changeTypes, + String reasonMarkdown, + String changesMarkdown, + String userImpactMarkdown, + String implementationImpactMarkdown, + String verificationMarkdown, + String knownLimitationsMarkdown, + String workflowStatus, + Instant firstPublishedAt, + Instant lastPublishedAt, + Instant updatedAt) { + + public ReleaseEditView { + changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ReleaseIndexItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ReleaseIndexItemView.java new file mode 100644 index 0000000..f94eb11 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ReleaseIndexItemView.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.techlog.management.model; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +/** 계약 {@code ReleaseIndexItem}. 목록 행은 본문 마크다운을 싣지 않는다. */ +public record ReleaseIndexItemView( + UUID id, + String versionLabel, + String title, + LocalDate releasedOn, + String workflowStatus, + 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 index 6bfcc16..0b84b70 100644 --- 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 @@ -6,9 +6,8 @@ import java.util.UUID; /** * 계약 {@code TopicEdit}. * - *

{@code featuredReferenceId}/{@code featuredCaseIds} 는 Reference/Case 가 존재해야 채워지는 - * 큐레이션 필드다. 그 관리 화면이 아직 없으므로 지금은 항상 비어 있고, 계약이 요구하지 않으므로 - * 비어 있는 것이 정상이다. + *

{@code featuredReferenceId}/{@code featuredCaseIds} 는 Reference/Case 가 존재해야 채워지는 큐레이션 필드다. 그 + * 관리 화면이 아직 없으므로 지금은 항상 비어 있고, 계약이 요구하지 않으므로 비어 있는 것이 정상이다. */ public record TopicEditView( UUID id, diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ReleaseRepositoryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ReleaseRepositoryPort.java new file mode 100644 index 0000000..1576933 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ReleaseRepositoryPort.java @@ -0,0 +1,34 @@ +package dev.caskeleton.application.techlog.management.port.out; + +import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand; +import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand; +import dev.caskeleton.application.techlog.management.model.ReleaseEditView; +import dev.caskeleton.application.techlog.management.model.ReleaseIndexItemView; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Release 의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */ +public interface ReleaseRepositoryPort { + + List listAll(int limit, int offset); + + int countAll(); + + Optional find(UUID id); + + ReleaseEditView create(CreateReleaseCommand command); + + Optional update(UpdateReleaseCommand command); + + int delete(UUID id, long expectedVersion); + + boolean versionLabelTaken(String versionLabel, UUID exceptId); + + /** + * {@code workflow_status} 를 옮기고 발행 타임스탬프를 갱신한다. {@code firstPublishedAt} 은 비어 있을 때만 채운다 — 재발행이 최초 + * 발행 시각을 덮으면 "언제부터 공개된 기록인가"를 잃는다. + */ + Optional transition( + UUID id, long expectedVersion, String workflowStatus, boolean stampPublication, String actor); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ArchiveReleaseUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ArchiveReleaseUseCase.java new file mode 100644 index 0000000..05ec5c4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ArchiveReleaseUseCase.java @@ -0,0 +1,59 @@ +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.ReleaseLifecycleCommand; +import dev.caskeleton.application.techlog.management.model.ReleaseEditView; +import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; +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 archiveRelease}. 공개에서 내린다. 발행 타임스탬프는 건드리지 않는다 — 언제 공개됐던 기록인지는 내린 뒤에도 사실로 남는다. */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class ArchiveReleaseUseCase { + + private final ReleaseRepositoryPort releases; + private final TransactionPort transactions; + + public ArchiveReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) { + this.releases = Objects.requireNonNull(releases, "releases"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public ReleaseEditView handle(ReleaseLifecycleCommand command) { + Objects.requireNonNull(command, "command"); + return transactions.inWrite( + () -> { + ReleaseEditView current = + releases + .find(command.id()) + .orElseThrow( + () -> + ManagementException.of( + ManagementError.RELEASE_NOT_FOUND, "no such release")); + if ("ARCHIVED".equals(current.workflowStatus())) { + throw ManagementException.of( + ManagementError.RELEASE_NOT_PUBLISHABLE, "the release is already archived"); + } + return releases + .transition( + command.id(), command.expectedVersion(), "ARCHIVED", false, command.actor()) + .orElseThrow( + () -> + ManagementException.withDetails( + ManagementError.VERSION_CONFLICT, + "the release changed since it was loaded", + new SaveTopicUseCase.VersionConflict(current.version()))); + }); + } +} 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 index 9a05d37..97d0363 100644 --- 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 @@ -15,8 +15,8 @@ import dev.caskeleton.application.transaction.TransactionPort; import java.util.Objects; /** - * {@code createProject}. 제목 하나로 초안을 연다 — slug 는 비워 둔다. 테이블이 slug 를 nullable 로 두고 - * UNIQUE 만 걸어 두었기 때문에 빈 초안 여러 개가 공존할 수 있고, 발행 시점에 slug 가 요구된다. + * {@code createProject}. 제목 하나로 초안을 연다 — slug 는 비워 둔다. 테이블이 slug 를 nullable 로 두고 UNIQUE 만 걸어 두었기 + * 때문에 빈 초안 여러 개가 공존할 수 있고, 발행 시점에 slug 가 요구된다. */ @RequiresPermission(StudioPermissions.WRITE) @UseCaseCapability( diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateReleaseUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateReleaseUseCase.java new file mode 100644 index 0000000..2089e1f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateReleaseUseCase.java @@ -0,0 +1,47 @@ +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.CreateReleaseCommand; +import dev.caskeleton.application.techlog.management.model.ReleaseEditView; +import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; +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 createRelease}. 제목 하나로 초안을 연다. + * + *

{@code version_label} 은 NOT NULL UNIQUE 인데 초안에는 아직 버전이 없다. 어댑터가 자리표시자를 넣고, 발행이 그것을 실제 버전으로 바꿔 + * 놓았는지 확인한다 ({@link PublishReleaseUseCase}) — 열을 nullable 로 푸는 대신 이렇게 하는 이유는, 그러면 발행된 릴리즈가 버전 없이 + * 공개될 수 있는 창이 열리기 때문이다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class CreateReleaseUseCase { + + private final ReleaseRepositoryPort releases; + private final TransactionPort transactions; + + public CreateReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) { + this.releases = Objects.requireNonNull(releases, "releases"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public ReleaseEditView handle(CreateReleaseCommand 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(() -> releases.create(command)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteReleaseUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteReleaseUseCase.java new file mode 100644 index 0000000..c562254 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteReleaseUseCase.java @@ -0,0 +1,61 @@ +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.ReleaseLifecycleCommand; +import dev.caskeleton.application.techlog.management.model.ReleaseEditView; +import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; +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 deleteRelease}. 발행된 릴리즈는 지우지 않는다 — 공개된 변경 기록이 조용히 사라지면 그것을 읽고 링크한 쪽에서 무슨 일이 있었는지 알 방법이 없다. + * 내리려면 archive 다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class DeleteReleaseUseCase { + + private final ReleaseRepositoryPort releases; + private final TransactionPort transactions; + + public DeleteReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) { + this.releases = Objects.requireNonNull(releases, "releases"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public void handle(ReleaseLifecycleCommand command) { + Objects.requireNonNull(command, "command"); + transactions.inWrite( + () -> { + ReleaseEditView current = + releases + .find(command.id()) + .orElseThrow( + () -> + ManagementException.of( + ManagementError.RELEASE_NOT_FOUND, "no such release")); + if ("PUBLISHED".equals(current.workflowStatus())) { + throw ManagementException.of( + ManagementError.RELEASE_NOT_PUBLISHABLE, + "a published release cannot be deleted; archive it instead"); + } + if (releases.delete(command.id(), command.expectedVersion()) == 0) { + throw ManagementException.withDetails( + ManagementError.VERSION_CONFLICT, + "the release 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 index 41cdb40..cffa9cf 100644 --- 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 @@ -17,9 +17,8 @@ import java.util.Objects; /** * {@code deleteTopic}. * - *

참조가 있으면 지우지 않고 {@code TOPIC_IN_USE} 로 거절한다. 외래키를 CASCADE 로 두지 않는 이유는, - * 주제를 지웠다는 이유로 그 주제를 쓰던 문서의 분류가 조용히 사라지면 안 되기 때문이다 — 지우려면 - * 먼저 그 문서들을 옮기라는 뜻이다. + *

참조가 있으면 지우지 않고 {@code TOPIC_IN_USE} 로 거절한다. 외래키를 CASCADE 로 두지 않는 이유는, 주제를 지웠다는 이유로 그 주제를 쓰던 + * 문서의 분류가 조용히 사라지면 안 되기 때문이다 — 지우려면 먼저 그 문서들을 옮기라는 뜻이다. */ @RequiresPermission(StudioPermissions.WRITE) @UseCaseCapability( diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetReleaseForEditUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetReleaseForEditUseCase.java new file mode 100644 index 0000000..c105352 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetReleaseForEditUseCase.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.ReleaseEditView; +import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; +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 GetReleaseForEditUseCase { + + private final ReleaseRepositoryPort releases; + private final TransactionPort transactions; + + public GetReleaseForEditUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) { + this.releases = Objects.requireNonNull(releases, "releases"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public ReleaseEditView handle(UUID id) { + return transactions.inRead( + () -> + releases + .find(id) + .orElseThrow( + () -> + ManagementException.of( + ManagementError.RELEASE_NOT_FOUND, "no such release"))); + } +} 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 index 2b9541c..c2bba47 100644 --- 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 @@ -22,8 +22,7 @@ public class ListStudioProjectsUseCase { private final ProjectRepositoryPort projects; private final TransactionPort transactions; - public ListStudioProjectsUseCase( - ProjectRepositoryPort projects, TransactionPort transactions) { + public ListStudioProjectsUseCase(ProjectRepositoryPort projects, TransactionPort transactions) { this.projects = Objects.requireNonNull(projects, "projects"); this.transactions = Objects.requireNonNull(transactions, "transactions"); } @@ -42,9 +41,5 @@ public class ListStudioProjectsUseCase { } public record Page( - List items, - int number, - int size, - int totalElements, - int totalPages) {} + 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/ListStudioReleasesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioReleasesUseCase.java new file mode 100644 index 0000000..7138b9c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioReleasesUseCase.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.management.model.ReleaseIndexItemView; +import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; +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 ListStudioReleasesUseCase { + + private final ReleaseRepositoryPort releases; + private final TransactionPort transactions; + + public ListStudioReleasesUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) { + this.releases = Objects.requireNonNull(releases, "releases"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + 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 = releases.countAll(); + List items = releases.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/PublishReleaseUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/PublishReleaseUseCase.java new file mode 100644 index 0000000..e482f05 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/PublishReleaseUseCase.java @@ -0,0 +1,100 @@ +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.ReleaseLifecycleCommand; +import dev.caskeleton.application.techlog.management.model.ReleaseEditView; +import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; +import dev.caskeleton.application.techlog.studio.service.StudioPermissions; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * {@code publishRelease}. {@code workflow_status = 'PUBLISHED'} 가 공개 조회의 유일한 조건이므로 (공개 어댑터의 WHERE + * 절), 이 전이가 곧 공개다. + * + *

그래서 발행 전에 계약이 필수로 선언한 것들이 실제로 채워져 있는지 여기서 확인한다. 저장은 초안을 비워 둔 채로도 허용해야 하고 — 아니면 한 번에 다 쓰지 않으면 + * 저장을 못 한다 — 공개는 그럴 수 없다. 두 시점의 요구가 다르다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class PublishReleaseUseCase { + + private final ReleaseRepositoryPort releases; + private final TransactionPort transactions; + + public PublishReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) { + this.releases = Objects.requireNonNull(releases, "releases"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public ReleaseEditView handle(ReleaseLifecycleCommand command) { + Objects.requireNonNull(command, "command"); + return transactions.inWrite( + () -> { + ReleaseEditView current = + releases + .find(command.id()) + .orElseThrow( + () -> + ManagementException.of( + ManagementError.RELEASE_NOT_FOUND, "no such release")); + List missing = missingForPublication(current); + if (!missing.isEmpty()) { + throw ManagementException.of( + ManagementError.RELEASE_NOT_PUBLISHABLE, + "the release is not ready to publish; fill in " + missing); + } + return releases + .transition( + command.id(), command.expectedVersion(), "PUBLISHED", true, command.actor()) + .orElseThrow( + () -> + ManagementException.withDetails( + ManagementError.VERSION_CONFLICT, + "the release changed since it was loaded", + new SaveTopicUseCase.VersionConflict(current.version()))); + }); + } + + /** 계약 {@code ReleaseUpdateRequest} 의 required 목록과 같다. */ + private static List missingForPublication(ReleaseEditView release) { + List missing = new ArrayList<>(); + if (ReleaseDrafts.isPlaceholder(release.versionLabel())) { + missing.add("versionLabel"); + } + if (isBlank(release.title())) { + missing.add("title"); + } + if (isBlank(release.summary())) { + missing.add("summary"); + } + if (release.changeTypes().isEmpty()) { + missing.add("changeTypes"); + } + if (isBlank(release.changesMarkdown())) { + missing.add("changesMarkdown"); + } + if (isBlank(release.verificationMarkdown())) { + missing.add("verificationMarkdown"); + } + if (release.releasedOn() == null) { + missing.add("releasedOn"); + } + return List.copyOf(missing); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ReleaseDrafts.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ReleaseDrafts.java new file mode 100644 index 0000000..1d08ad3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ReleaseDrafts.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.management.service; + +/** + * 초안 릴리즈의 자리표시자 버전 라벨. + * + *

{@code release.version_label} 은 NOT NULL UNIQUE 이고 초안에는 아직 버전이 없다. 어댑터는 이 접두사로 시작하는 라벨을 넣고, + * 발행은 그것이 실제 버전으로 바뀌었는지 확인한다. 두 곳이 같은 문자열을 각자 적어 두면 한쪽만 고쳐졌을 때 자리표시자가 그대로 공개된다. + */ +public final class ReleaseDrafts { + + public static final String PLACEHOLDER_PREFIX = "draft-"; + + private ReleaseDrafts() {} + + public static boolean isPlaceholder(String versionLabel) { + return versionLabel == null || versionLabel.startsWith(PLACEHOLDER_PREFIX); + } +} 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 index cd2554c..b84f3ae 100644 --- 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 @@ -18,13 +18,12 @@ import java.util.Objects; /** * {@code createTopic} / {@code updateTopic}. * - *

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

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

이름 비교는 정규화(소문자·공백 정리) 후에 한다. 테이블의 {@code uq_topic_normalized_name} 이 - * 같은 규칙이므로, 여기서만 다르게 정규화하면 사전 확인을 통과한 요청이 제약에서 터진다. + *

이름 비교는 정규화(소문자·공백 정리) 후에 한다. 테이블의 {@code uq_topic_normalized_name} 이 같은 규칙이므로, 여기서만 다르게 정규화하면 + * 사전 확인을 통과한 요청이 제약에서 터진다. */ @RequiresPermission(StudioPermissions.WRITE) @UseCaseCapability( @@ -73,8 +72,7 @@ public class SaveTopicUseCase { .find(command.id()) .orElseThrow( () -> - ManagementException.of( - ManagementError.TOPIC_NOT_FOUND, "no such topic")); + ManagementException.of(ManagementError.TOPIC_NOT_FOUND, "no such topic")); return topics .update(command) .orElseThrow( 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 index 2a033bd..cc0e5fd 100644 --- 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 @@ -26,7 +26,12 @@ public class UpdateProjectUseCase { /** 테이블의 CHECK 제약과 같은 집합. 여기서 먼저 거절해야 422 로 나가고, 아니면 DB 오류가 500 이 된다. */ private static final Set PHASES = Set.of( - "RESEARCH", "DESIGN", "IMPLEMENTATION", "VERIFICATION", "MAINTENANCE", "PAUSED", + "RESEARCH", + "DESIGN", + "IMPLEMENTATION", + "VERIFICATION", + "MAINTENANCE", + "PAUSED", "COMPLETED"); private static final Set VISIBILITIES = Set.of("PRIVATE", "UNLISTED", "PUBLIC"); diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateReleaseUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateReleaseUseCase.java new file mode 100644 index 0000000..be8cb8a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateReleaseUseCase.java @@ -0,0 +1,82 @@ +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.UpdateReleaseCommand; +import dev.caskeleton.application.techlog.management.model.ReleaseEditView; +import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; +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 updateRelease}. */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class UpdateReleaseUseCase { + + /** 변경 유형. 계약은 자유 문자열이지만 목록 화면이 이 값으로 묶어 보여주므로, 열린 집합으로 두면 같은 뜻의 라벨이 여러 개 생겨 묶임이 무의미해진다. */ + private static final Set CHANGE_TYPES = + Set.of("FEATURE", "FIX", "REFACTOR", "DOCS", "INFRA", "BREAKING"); + + private final ReleaseRepositoryPort releases; + private final TransactionPort transactions; + + public UpdateReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort transactions) { + this.releases = Objects.requireNonNull(releases, "releases"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public ReleaseEditView handle(UpdateReleaseCommand command) { + Objects.requireNonNull(command, "command"); + requireText(command.versionLabel(), "versionLabel"); + requireText(command.title(), "title"); + for (String changeType : command.changeTypes()) { + if (!CHANGE_TYPES.contains(changeType)) { + throw ManagementException.of( + ManagementError.REQUEST_VALIDATION_FAILED, + "changeTypes must each be one of " + CHANGE_TYPES.stream().sorted().toList()); + } + } + + return transactions.inWrite( + () -> { + ReleaseEditView current = + releases + .find(command.id()) + .orElseThrow( + () -> + ManagementException.of( + ManagementError.RELEASE_NOT_FOUND, "no such release")); + String label = command.versionLabel().trim(); + if (releases.versionLabelTaken(label, command.id())) { + throw ManagementException.of( + ManagementError.RELEASE_VERSION_TAKEN, + "another release already uses this version label"); + } + return releases + .update(command) + .orElseThrow( + () -> + ManagementException.withDetails( + ManagementError.VERSION_CONFLICT, + "the release 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"); + } + } +} diff --git a/src/config/architecture/modules.json b/src/config/architecture/modules.json index 5e22ca5..b8c65b0 100644 --- a/src/config/architecture/modules.json +++ b/src/config/architecture/modules.json @@ -101,7 +101,9 @@ "application-core", "shared-contract" ], - "runtime_memberships": [] + "runtime_memberships": [ + "app-bootstrap" + ] }, { "id": "adapter-outbound-cache-redis", diff --git a/src/config/openapi/studio-management-v1.yaml b/src/config/openapi/studio-management-v1.yaml index 0a65f79..46e7563 100644 --- a/src/config/openapi/studio-management-v1.yaml +++ b/src/config/openapi/studio-management-v1.yaml @@ -5,11 +5,11 @@ info: description: |- ⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다. - topics 4개와 projects 5개는 studio-v1.yaml과 같은 방식으로 변환했다 - (`application/json` + ErrorEnvelope / Envelope). 그 9개가 첫 구현 - 대상이자 첫 소비자이기 때문이다. + topics 4개, projects 5개, releases 7개를 studio-v1.yaml과 같은 방식으로 + 변환했다 (`application/json` + ErrorEnvelope / Envelope). 그 16개가 + 구현된 것이자 소비자가 있는 것이기 때문이다. - 나머지 70개는 아직 bare payload + `application/problem+json` + ProblemDetails + 나머지 63개는 아직 bare payload + `application/problem+json` + ProblemDetails 다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리 변환해 두면 검증되지 않은 모양이 계약에 고정된다. @@ -3630,49 +3630,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CreateDraftResponse' + $ref: '#/components/schemas/CreateDraftResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3705,37 +3705,37 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseIndexPage' + $ref: '#/components/schemas/ReleaseIndexPageEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' security: - sessionCookie: [] /api/v1/studio/releases/{id}: @@ -3756,37 +3756,37 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseEditResponse' + $ref: '#/components/schemas/ReleaseEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' security: - sessionCookie: [] put: @@ -3807,49 +3807,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseEditResponse' + $ref: '#/components/schemas/ReleaseEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3876,45 +3876,45 @@ paths: '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3942,49 +3942,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PublishResponse' + $ref: '#/components/schemas/PublishResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -4012,49 +4012,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseEditResponse' + $ref: '#/components/schemas/ReleaseEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -5267,6 +5267,9 @@ components: - PROJECT_NOT_FOUND - PROJECT_SLUG_TAKEN - PROJECT_IN_USE + - RELEASE_NOT_FOUND + - RELEASE_VERSION_TAKEN + - RELEASE_NOT_PUBLISHABLE - INTERNAL_ERROR description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' category: @@ -5392,6 +5395,51 @@ components: $ref: '#/components/schemas/ProjectIndexPage' meta: $ref: '#/components/schemas/ResponseMeta' + ReleaseIndexPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseIndexPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReleaseEditResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseEditResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + PublishResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/PublishResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' ProjectEditResponseEnvelope: type: object additionalProperties: false