feat: implement release authoring, so the changelog can be written
The public site has a Releases page and a footer link to the latest release, and both were empty — the read side has existed since the public surface landed, but nothing could ever create a row. The seven release operations were in the contract with no implementation, so the changelog was a page that could only ever be blank. The release model is not a blob of prose. It splits into six markdown sections because a release note answers fixed questions — why, what, what changes for a reader, what it leaves in the code, how it was verified, what is still missing — and a single text column cannot say which of those went unanswered. Publishing is the only thing that makes a release public: the public query filters on `workflow_status = 'PUBLISHED'` and nothing else. So publish is where the contract's required fields are actually enforced. Saving stays permissive — a draft you cannot save until it is complete is a draft you cannot write — and the two demands are deliberately different. `version_label` is NOT NULL UNIQUE but a draft has no version yet, so creation writes a placeholder derived from the row id and publication refuses to ship one. Relaxing the column instead would open a window where a published release is publicly visible with no version at all. A published release cannot be deleted, only archived: a public changelog entry that vanishes leaves everyone who linked it with no way to learn what happened. Also registers `adapter-outbound-objectstorage` as an app-bootstrap runtime member. It was added as a dependency when asset upload was fixed but never registered, and `verifyRuntimeModuleMembership` had not been run since.
This commit is contained in:
@@ -1364,3 +1364,42 @@ errors:
|
|||||||
runbook_link: null
|
runbook_link: null
|
||||||
compatibility_impact: additive
|
compatibility_impact: additive
|
||||||
required_test: ManagementErrorRegistryTest
|
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
|
||||||
+5
-2
@@ -3,8 +3,8 @@ package dev.caskeleton.adapter.inbound.web.techlog.management;
|
|||||||
import dev.caskeleton.application.techlog.error.ManagementError;
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* code 별 고정 문구. 예외의 원문 메시지는 진단용이라 그대로 내보내지 않는다 — 저장소 제약 이름이나
|
* code 별 고정 문구. 예외의 원문 메시지는 진단용이라 그대로 내보내지 않는다 — 저장소 제약 이름이나 SQL 조각이 새어 나갈 수 있고, 그건 클라이언트가 분기할 값도
|
||||||
* SQL 조각이 새어 나갈 수 있고, 그건 클라이언트가 분기할 값도 아니다.
|
* 아니다.
|
||||||
*/
|
*/
|
||||||
public final class ManagementClientSafeMessages {
|
public final class ManagementClientSafeMessages {
|
||||||
|
|
||||||
@@ -23,6 +23,9 @@ public final class ManagementClientSafeMessages {
|
|||||||
case PROJECT_NOT_FOUND -> "프로젝트를 찾을 수 없습니다";
|
case PROJECT_NOT_FOUND -> "프로젝트를 찾을 수 없습니다";
|
||||||
case PROJECT_SLUG_TAKEN -> "같은 slug 의 프로젝트가 이미 있습니다";
|
case PROJECT_SLUG_TAKEN -> "같은 slug 의 프로젝트가 이미 있습니다";
|
||||||
case PROJECT_IN_USE -> "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다";
|
case PROJECT_IN_USE -> "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다";
|
||||||
|
case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다";
|
||||||
|
case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다";
|
||||||
|
case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-8
@@ -18,9 +18,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
|||||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리 표면의 실패를 봉투로 옮긴다. 스코프를 {@code ...web.techlog.management} 로 좁히는 이유는 형제 표면들과
|
* 관리 표면의 실패를 봉투로 옮긴다. 스코프를 {@code ...web.techlog.management} 로 좁히는 이유는 형제 표면들과 같다 — 각 계약이 자기 {@code
|
||||||
* 같다 — 각 계약이 자기 {@code ApiError.code} 집합만 열거하고 있어서, 다른 표면의 코드가 새어 들어가면 그
|
* ApiError.code} 집합만 열거하고 있어서, 다른 표면의 코드가 새어 들어가면 그 계약이 거짓이 된다.
|
||||||
* 계약이 거짓이 된다.
|
|
||||||
*/
|
*/
|
||||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.management")
|
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.management")
|
||||||
@@ -41,9 +40,8 @@ public class ManagementExceptionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 본문을 못 읽는 경우(빈 본문, 깨진 JSON, enum 값 불일치). 그냥 두면 부모 처리기가 RFC 7807 을 만들고
|
* 본문을 못 읽는 경우(빈 본문, 깨진 JSON, enum 값 불일치). 그냥 두면 부모 처리기가 RFC 7807 을 만들고 {@code EnvelopeBodyAdvice}
|
||||||
* {@code EnvelopeBodyAdvice} 의 미디어타입 검사에 걸려 봉투가 안 씌워진다 — ADR-006 이 쓰지 않기로 한
|
* 의 미디어타입 검사에 걸려 봉투가 안 씌워진다 — ADR-006 이 쓰지 않기로 한 모양이 그대로 나간다.
|
||||||
* 모양이 그대로 나간다.
|
|
||||||
*/
|
*/
|
||||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||||
public ResponseEntity<Envelope<Void>> handleUnreadableBody(HttpMessageNotReadableException ex) {
|
public ResponseEntity<Envelope<Void>> handleUnreadableBody(HttpMessageNotReadableException ex) {
|
||||||
@@ -63,8 +61,7 @@ public class ManagementExceptionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 계약의 {@code ValidationErrorDetails} — {@code field/code/message} 셋 다 required 다. */
|
/** 계약의 {@code ValidationErrorDetails} — {@code field/code/message} 셋 다 required 다. */
|
||||||
private static ResponseEntity<Envelope<Void>> invalid(
|
private static ResponseEntity<Envelope<Void>> invalid(String field, String code, String message) {
|
||||||
String field, String code, String message) {
|
|
||||||
Map<String, Object> fieldError = Map.of("field", field, "code", code, "message", message);
|
Map<String, Object> fieldError = Map.of("field", field, "code", code, "message", message);
|
||||||
return ErrorResponseFactory.envelope(
|
return ErrorResponseFactory.envelope(
|
||||||
ManagementError.REQUEST_VALIDATION_FAILED,
|
ManagementError.REQUEST_VALIDATION_FAILED,
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@ import dev.caskeleton.application.techlog.error.ManagementError;
|
|||||||
import dev.caskeleton.application.techlog.error.ManagementException;
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 감사 컬럼에 남길 주체. {@code StudioPrincipals} 와 같은 일을 하되 이 표면의 error code 로 던진다 —
|
* 감사 컬럼에 남길 주체. {@code StudioPrincipals} 와 같은 일을 하되 이 표면의 error code 로 던진다 — 계약이 각자 code 집합을 열거하므로
|
||||||
* 계약이 각자 code 집합을 열거하므로 예외까지 공유하면 한쪽 계약이 거짓이 된다.
|
* 예외까지 공유하면 한쪽 계약이 거짓이 된다.
|
||||||
*/
|
*/
|
||||||
public final class ManagementPrincipals {
|
public final class ManagementPrincipals {
|
||||||
|
|
||||||
|
|||||||
+145
@@ -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<String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-4
@@ -24,11 +24,10 @@ import org.springframework.web.bind.annotation.ResponseStatus;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 주제 관리. 계약 {@code listStudioTopics}/{@code createTopic}/{@code updateTopic}/{@code
|
* 주제 관리. 계약 {@code listStudioTopics}/{@code createTopic}/{@code updateTopic}/{@code deleteTopic}.
|
||||||
* deleteTopic}.
|
|
||||||
*
|
*
|
||||||
* <p>쓰기 응답의 {@code id}/{@code version} 은 서버가 소유한다. 요청 본문에 실려 와도 무시하고 경로와
|
* <p>쓰기 응답의 {@code id}/{@code version} 은 서버가 소유한다. 요청 본문에 실려 와도 무시하고 경로와 저장소가 정한 값을 쓴다 — 그러지 않으면
|
||||||
* 저장소가 정한 값을 쓴다 — 그러지 않으면 클라이언트가 남의 행을 덮어쓸 수 있다.
|
* 클라이언트가 남의 행을 덮어쓸 수 있다.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
public class ManagementTopicController {
|
public class ManagementTopicController {
|
||||||
|
|||||||
+88
-15
@@ -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.ProjectIndexItem;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
|
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.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.adapter.inbound.web.techlog.management.api.model.TopicEdit;
|
||||||
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
||||||
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
|
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.model.TopicEditView;
|
||||||
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
|
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
@@ -43,18 +50,16 @@ public final class ManagementResponseMapper {
|
|||||||
return views.stream().map(ManagementResponseMapper::topic).toList();
|
return views.stream().map(ManagementResponseMapper::topic).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 발행 상태는 두 타임스탬프에서 파생한다 — 테이블에 상태 컬럼이 따로 없고, 그 둘이 사실의 출처이기 때문이다. */
|
||||||
* 발행 상태는 두 타임스탬프에서 파생한다 — 테이블에 상태 컬럼이 따로 없고, 그 둘이 사실의
|
|
||||||
* 출처이기 때문이다.
|
|
||||||
*/
|
|
||||||
private static PublicationStatus publication(Instant first, Instant last) {
|
private static PublicationStatus publication(Instant first, Instant last) {
|
||||||
// 상태 컬럼이 따로 없으므로 두 타임스탬프에서 파생한다. WITHDRAWN 은 발행 이력이 있는데
|
// 상태 컬럼이 따로 없으므로 두 타임스탬프에서 파생한다. WITHDRAWN 은 발행 이력이 있는데
|
||||||
// 지금은 내려간 상태인데, 그 구분은 unpublish 를 구현할 때 생긴다 — 지금은 그 경로가
|
// 지금은 내려간 상태인데, 그 구분은 unpublish 를 구현할 때 생긴다 — 지금은 그 경로가
|
||||||
// 없으므로 발행된 적이 있으면 ACTIVE 다.
|
// 없으므로 발행된 적이 있으면 ACTIVE 다.
|
||||||
PublicationStatus status =
|
PublicationStatus status =
|
||||||
new PublicationStatus(
|
new PublicationStatus(
|
||||||
first == null ? PublicationStatus.StateEnum.NEVER_PUBLISHED
|
first == null
|
||||||
: PublicationStatus.StateEnum.ACTIVE,
|
? PublicationStatus.StateEnum.NEVER_PUBLISHED
|
||||||
|
: PublicationStatus.StateEnum.ACTIVE,
|
||||||
false);
|
false);
|
||||||
status.setPublishedAt(at(last));
|
status.setPublishedAt(at(last));
|
||||||
return status;
|
return status;
|
||||||
@@ -107,15 +112,83 @@ public final class ManagementResponseMapper {
|
|||||||
return item;
|
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) {
|
public static ProjectIndexPage projects(ListStudioProjectsUseCase.Page page) {
|
||||||
PageMetadata meta =
|
return new ProjectIndexPage(
|
||||||
new PageMetadata(
|
page.items().stream().map(ManagementResponseMapper::indexItem).toList(),
|
||||||
page.number(),
|
pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
|
||||||
page.size(),
|
}
|
||||||
(long) page.totalElements(),
|
|
||||||
page.totalPages(),
|
/**
|
||||||
page.number() > 0,
|
* 릴리즈 응답은 {@code ReleaseUpdateRequest} 를 allOf 로 물고 있어 {@code expectedVersion} 을 함께 싣는다. 현재
|
||||||
page.number() + 1 < page.totalPages());
|
* version 을 그대로 넣는다 — 그래야 편집 화면이 받은 응답을 그대로 다음 저장 요청으로 되돌려보낼 수 있고, 두 값이 갈라질 이유가 없다.
|
||||||
return new ProjectIndexPage(page.items().stream().map(ManagementResponseMapper::indexItem).toList(), meta);
|
*/
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-7
@@ -14,15 +14,15 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
import tools.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project 편집 저장소.
|
* Project 편집 저장소.
|
||||||
*
|
*
|
||||||
* <p>{@code technology_labels} 는 jsonb 다. 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로
|
* <p>{@code technology_labels} 는 jsonb 다. 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 보내 타입이 어긋나므로, JSON
|
||||||
* 보내 타입이 어긋나므로, JSON 문자열로 직렬화해 {@code ::jsonb} 로 캐스팅한다.
|
* 문자열로 직렬화해 {@code ::jsonb} 로 캐스팅한다.
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
|
public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
|
||||||
@@ -51,12 +51,18 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<String> labels(String json) {
|
private List<String> labels(String json) {
|
||||||
if (json == null || json.isBlank()) return List.of();
|
if (json == null || json.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
JsonNode node = objectMapper.readTree(json);
|
JsonNode node = objectMapper.readTree(json);
|
||||||
if (!node.isArray()) return List.of();
|
if (!node.isArray()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
for (JsonNode item : node) out.add(item.asString(""));
|
for (JsonNode item : node) {
|
||||||
|
out.add(item.asString(""));
|
||||||
|
}
|
||||||
return List.copyOf(out);
|
return List.copyOf(out);
|
||||||
} catch (RuntimeException malformed) {
|
} catch (RuntimeException malformed) {
|
||||||
// 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다.
|
// 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다.
|
||||||
@@ -110,7 +116,9 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
|
|||||||
public List<ProjectIndexItemView> listAll(int limit, int offset) {
|
public List<ProjectIndexItemView> listAll(int limit, int offset) {
|
||||||
return jdbcClient
|
return jdbcClient
|
||||||
.sql(
|
.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")
|
+ " LIMIT :limit OFFSET :offset")
|
||||||
.param("limit", limit)
|
.param("limit", limit)
|
||||||
.param("offset", offset)
|
.param("offset", offset)
|
||||||
|
|||||||
+258
@@ -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 편집 저장소.
|
||||||
|
*
|
||||||
|
* <p>{@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<String> changeTypes(String json) {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(json);
|
||||||
|
if (!node.isArray()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> 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<String> 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<ReleaseIndexItemView> 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<ReleaseEditView> 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<ReleaseEditView> 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<ReleaseEditView> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-9
@@ -15,15 +15,13 @@ import org.springframework.stereotype.Repository;
|
|||||||
/**
|
/**
|
||||||
* Topic 편집 저장소.
|
* Topic 편집 저장소.
|
||||||
*
|
*
|
||||||
* <p>정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@code
|
* <p>정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@code uq_topic_normalized_name} 이 그 컬럼 위에 있으므로, 계산이 한
|
||||||
* uq_topic_normalized_name} 이 그 컬럼 위에 있으므로, 계산이 한 곳에만 있어야 사전 확인과 제약이
|
* 곳에만 있어야 사전 확인과 제약이 같은 값을 본다.
|
||||||
* 같은 값을 본다.
|
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort {
|
public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort {
|
||||||
|
|
||||||
private static final String COLUMNS =
|
private static final String COLUMNS = "id, name, slug, description, scope, status, version";
|
||||||
"id, name, slug, description, scope, status, version";
|
|
||||||
|
|
||||||
private final JdbcClient jdbcClient;
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
@@ -118,10 +116,7 @@ public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort {
|
|||||||
.update();
|
.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 참조 확인. 주제를 가리키는 곳이 늘어나면 여기도 늘어야 한다 — 빠뜨리면 외래키가 대신 막고 500 이 나간다. */
|
||||||
* 참조 확인. 주제를 가리키는 곳이 늘어나면 여기도 늘어야 한다 — 빠뜨리면 외래키가 대신 막고
|
|
||||||
* 500 이 나간다.
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public boolean isReferenced(UUID id) {
|
public boolean isReferenced(UUID id) {
|
||||||
return Boolean.TRUE.equals(
|
return Boolean.TRUE.equals(
|
||||||
|
|||||||
+47
-2
@@ -1,22 +1,30 @@
|
|||||||
package dev.caskeleton.bootstrap.techlog;
|
package dev.caskeleton.bootstrap.techlog;
|
||||||
|
|
||||||
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
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.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.CreateProjectUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
|
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.DeleteTopicUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
|
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.ListStudioProjectsUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
|
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.SaveTopicUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
|
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase;
|
||||||
import dev.caskeleton.application.transaction.TransactionPort;
|
import dev.caskeleton.application.transaction.TransactionPort;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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)
|
@Configuration(proxyBeanMethods = false)
|
||||||
public class TechLogManagementConfig {
|
public class TechLogManagementConfig {
|
||||||
@@ -62,4 +70,41 @@ public class TechLogManagementConfig {
|
|||||||
DeleteProjectUseCase deleteProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
|
DeleteProjectUseCase deleteProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
|
||||||
return new DeleteProjectUseCase(projects, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-8
@@ -21,12 +21,12 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.yaml.snakeyaml.Yaml;
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@code PublicErrorRegistryTest} 가 {@code PublicError} 에 대해 하는 일을 {@link ManagementError} 에
|
* {@code PublicErrorRegistryTest} 가 {@code PublicError} 에 대해 하는 일을 {@link ManagementError} 에 대해 한다
|
||||||
* 대해 한다 — row 존재 / 값 드리프트 / client-safe 문구, 그리고 계약 code 집합 대조.
|
* — row 존재 / 값 드리프트 / client-safe 문구, 그리고 계약 code 집합 대조.
|
||||||
*
|
*
|
||||||
* <p>계약은 12종을 열거하는데 enum 은 11종이다. 나머지 {@code INTERNAL_ERROR} 는 스켈레톤 공통 처리기가
|
* <p>계약은 12종을 열거하는데 enum 은 11종이다. 나머지 {@code INTERNAL_ERROR} 는 스켈레톤 공통 처리기가 소유하며({@link
|
||||||
* 소유하며({@link OperationalError#INTERNAL_ERROR}) 여기서 재선언하지 않는다 — 그쪽은 {@code
|
* OperationalError#INTERNAL_ERROR}) 여기서 재선언하지 않는다 — 그쪽은 {@code retryable=true} 라 같은 code 를 두 곳에서
|
||||||
* retryable=true} 라 같은 code 를 두 곳에서 선언하면 레지스트리가 어느 값을 따라야 할지 알 수 없다.
|
* 선언하면 레지스트리가 어느 값을 따라야 할지 알 수 없다.
|
||||||
*/
|
*/
|
||||||
class ManagementErrorRegistryTest {
|
class ManagementErrorRegistryTest {
|
||||||
|
|
||||||
@@ -75,11 +75,14 @@ class ManagementErrorRegistryTest {
|
|||||||
for (ManagementError error : ManagementError.values()) {
|
for (ManagementError error : ManagementError.values()) {
|
||||||
Map<String, Object> row = registryRowsByCode.get(error.code());
|
Map<String, Object> row = registryRowsByCode.get(error.code());
|
||||||
assertThat(row).as("registry row for %s", error.code()).isNotNull();
|
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());
|
.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());
|
.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());
|
.isEqualTo(error.retryable());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-7
@@ -7,13 +7,13 @@ import dev.caskeleton.shared.error.Category;
|
|||||||
* 관리 계약(`studio-management-v1.yaml`)의 `ApiError.code` enum. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과
|
* 관리 계약(`studio-management-v1.yaml`)의 `ApiError.code` enum. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과
|
||||||
* `docs/registries/error-codes.yaml`을 함께 고쳐야 한다.
|
* `docs/registries/error-codes.yaml`을 함께 고쳐야 한다.
|
||||||
*
|
*
|
||||||
* <p>{@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로
|
* <p>{@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로 낼 수 있게 되면 그
|
||||||
* 낼 수 있게 되면 그 순간 두 계약 모두 거짓이 된다.
|
* 순간 두 계약 모두 거짓이 된다.
|
||||||
*
|
*
|
||||||
* <p>계약의 {@code ApiError.code} 는 12종인데 여기는 11종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이
|
* <p>계약의 {@code ApiError.code} 는 15종인데 여기는 14종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통
|
||||||
* 아니라 스켈레톤 공통 처리기가 내는 코드({@code OperationalError.INTERNAL_ERROR}) 이고, 같은 code 를 두 enum 이
|
* 처리기가 내는 코드({@code OperationalError.INTERNAL_ERROR}) 이고, 같은 code 를 두 enum 이 각자 status 와 retryable
|
||||||
* 각자 status 와 retryable 을 달고 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다 — 실제로 그쪽은
|
* 을 달고 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다 — 실제로 그쪽은 {@code retryable=true} 다. {@code PublicError} 가 같은
|
||||||
* {@code retryable=true} 다. {@code PublicError} 가 같은 이유로 같은 선택을 했다.
|
* 이유로 같은 선택을 했다.
|
||||||
*/
|
*/
|
||||||
public enum ManagementError implements ApiErrorCode {
|
public enum ManagementError implements ApiErrorCode {
|
||||||
AUTHENTICATION_REQUIRED(Category.AUTH, 401, false),
|
AUTHENTICATION_REQUIRED(Category.AUTH, 401, false),
|
||||||
@@ -26,7 +26,10 @@ public enum ManagementError implements ApiErrorCode {
|
|||||||
TOPIC_IN_USE(Category.CONFLICT, 409, false),
|
TOPIC_IN_USE(Category.CONFLICT, 409, false),
|
||||||
PROJECT_NOT_FOUND(Category.NOT_FOUND, 404, false),
|
PROJECT_NOT_FOUND(Category.NOT_FOUND, 404, false),
|
||||||
PROJECT_SLUG_TAKEN(Category.CONFLICT, 409, 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 Category category;
|
||||||
private final int httpStatus;
|
private final int httpStatus;
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@ import dev.caskeleton.shared.error.ApiErrorCarrier;
|
|||||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리 use case 가 던지는 유일한 실패 표현. {@link StudioException} 과 같은 모양이되 code 집합만 다르다 — 전송 계층은
|
* 관리 use case 가 던지는 유일한 실패 표현. {@link StudioException} 과 같은 모양이되 code 집합만 다르다 — 전송 계층은 {@link
|
||||||
* {@link ApiErrorCarrier} 만 보므로 두 예외를 따로 처리할 필요가 없다.
|
* ApiErrorCarrier} 만 보므로 두 예외를 따로 처리할 필요가 없다.
|
||||||
*/
|
*/
|
||||||
public final class ManagementException extends RuntimeException implements ApiErrorCarrier {
|
public final class ManagementException extends RuntimeException implements ApiErrorCarrier {
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -1,6 +1,4 @@
|
|||||||
package dev.caskeleton.application.techlog.management.command;
|
package dev.caskeleton.application.techlog.management.command;
|
||||||
|
|
||||||
/**
|
/** 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다. */
|
||||||
* 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다.
|
|
||||||
*/
|
|
||||||
public record CreateProjectCommand(String title, String actor) {}
|
public record CreateProjectCommand(String title, String actor) {}
|
||||||
|
|||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.command;
|
||||||
|
|
||||||
|
/** 계약 {@code CreateDraftRequest}. 제목 하나로 초안을 연다. */
|
||||||
|
public record CreateReleaseCommand(String title, String actor) {}
|
||||||
+9
@@ -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) {}
|
||||||
+4
-5
@@ -3,12 +3,11 @@ package dev.caskeleton.application.techlog.management.command;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 생성과 수정이 같은 명령을 쓴다. 계약이 두 경우 모두 {@code TopicEdit} 를 본문으로 받기 때문이고,
|
* 생성과 수정이 같은 명령을 쓴다. 계약이 두 경우 모두 {@code TopicEdit} 를 본문으로 받기 때문이고, 구분은 {@code id} 의 유무다 — {@code
|
||||||
* 구분은 {@code id} 의 유무다 — {@code null} 이면 생성이다.
|
* null} 이면 생성이다.
|
||||||
*
|
*
|
||||||
* <p>{@code expectedVersion} 은 수정에서만 의미가 있다. 생성에 값이 와도 무시하는 대신 거절하지
|
* <p>{@code expectedVersion} 은 수정에서만 의미가 있다. 생성에 값이 와도 무시하는 대신 거절하지 않는 이유는, 계약이 그 필드를 optional 로 두고
|
||||||
* 않는 이유는, 계약이 그 필드를 optional 로 두고 있어 클라이언트가 보내는 것이 위반이 아니기
|
* 있어 클라이언트가 보내는 것이 위반이 아니기 때문이다.
|
||||||
* 때문이다.
|
|
||||||
*/
|
*/
|
||||||
public record SaveTopicCommand(
|
public record SaveTopicCommand(
|
||||||
UUID id,
|
UUID id,
|
||||||
|
|||||||
+27
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -7,8 +7,8 @@ import java.util.UUID;
|
|||||||
/**
|
/**
|
||||||
* 계약 {@code ProjectEditResponse}.
|
* 계약 {@code ProjectEditResponse}.
|
||||||
*
|
*
|
||||||
* <p>{@code topicIds}/{@code documentLinks}/{@code questionLinks} 는 링크 테이블이 소유한다. (가)
|
* <p>{@code topicIds}/{@code documentLinks}/{@code questionLinks} 는 링크 테이블이 소유한다. (가) 범위에서는 그 편집
|
||||||
* 범위에서는 그 편집 화면이 없으므로 항상 비어 있고, 링크를 다루는 화면이 생길 때 같은 뷰에 채운다.
|
* 화면이 없으므로 항상 비어 있고, 링크를 다루는 화면이 생길 때 같은 뷰에 채운다.
|
||||||
*/
|
*/
|
||||||
public record ProjectEditView(
|
public record ProjectEditView(
|
||||||
UUID id,
|
UUID id,
|
||||||
|
|||||||
+36
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>본문이 마크다운 여섯 구획으로 나뉘어 있는 것은 릴리즈 노트가 하나의 산문이 아니라 정해진 질문에 답하는 기록이기 때문이다 — 왜 바꿨나, 무엇을 바꿨나, 사용자에게
|
||||||
|
* 무엇이 달라지나, 구현에 무엇이 남나, 어떻게 검증했나, 무엇을 아직 못 했나. 한 덩어리 text 였다면 그중 무엇이 빠졌는지 아무도 알 수 없다.
|
||||||
|
*/
|
||||||
|
public record ReleaseEditView(
|
||||||
|
UUID id,
|
||||||
|
long version,
|
||||||
|
String versionLabel,
|
||||||
|
String title,
|
||||||
|
String summary,
|
||||||
|
LocalDate releasedOn,
|
||||||
|
List<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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) {}
|
||||||
+2
-3
@@ -6,9 +6,8 @@ import java.util.UUID;
|
|||||||
/**
|
/**
|
||||||
* 계약 {@code TopicEdit}.
|
* 계약 {@code TopicEdit}.
|
||||||
*
|
*
|
||||||
* <p>{@code featuredReferenceId}/{@code featuredCaseIds} 는 Reference/Case 가 존재해야 채워지는
|
* <p>{@code featuredReferenceId}/{@code featuredCaseIds} 는 Reference/Case 가 존재해야 채워지는 큐레이션 필드다. 그
|
||||||
* 큐레이션 필드다. 그 관리 화면이 아직 없으므로 지금은 항상 비어 있고, 계약이 요구하지 않으므로
|
* 관리 화면이 아직 없으므로 지금은 항상 비어 있고, 계약이 요구하지 않으므로 비어 있는 것이 정상이다.
|
||||||
* 비어 있는 것이 정상이다.
|
|
||||||
*/
|
*/
|
||||||
public record TopicEditView(
|
public record TopicEditView(
|
||||||
UUID id,
|
UUID id,
|
||||||
|
|||||||
+34
@@ -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<ReleaseIndexItemView> listAll(int limit, int offset);
|
||||||
|
|
||||||
|
int countAll();
|
||||||
|
|
||||||
|
Optional<ReleaseEditView> find(UUID id);
|
||||||
|
|
||||||
|
ReleaseEditView create(CreateReleaseCommand command);
|
||||||
|
|
||||||
|
Optional<ReleaseEditView> update(UpdateReleaseCommand command);
|
||||||
|
|
||||||
|
int delete(UUID id, long expectedVersion);
|
||||||
|
|
||||||
|
boolean versionLabelTaken(String versionLabel, UUID exceptId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code workflow_status} 를 옮기고 발행 타임스탬프를 갱신한다. {@code firstPublishedAt} 은 비어 있을 때만 채운다 — 재발행이 최초
|
||||||
|
* 발행 시각을 덮으면 "언제부터 공개된 기록인가"를 잃는다.
|
||||||
|
*/
|
||||||
|
Optional<ReleaseEditView> transition(
|
||||||
|
UUID id, long expectedVersion, String workflowStatus, boolean stampPublication, String actor);
|
||||||
|
}
|
||||||
+59
@@ -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())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -15,8 +15,8 @@ import dev.caskeleton.application.transaction.TransactionPort;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@code createProject}. 제목 하나로 초안을 연다 — slug 는 비워 둔다. 테이블이 slug 를 nullable 로 두고
|
* {@code createProject}. 제목 하나로 초안을 연다 — slug 는 비워 둔다. 테이블이 slug 를 nullable 로 두고 UNIQUE 만 걸어 두었기
|
||||||
* UNIQUE 만 걸어 두었기 때문에 빈 초안 여러 개가 공존할 수 있고, 발행 시점에 slug 가 요구된다.
|
* 때문에 빈 초안 여러 개가 공존할 수 있고, 발행 시점에 slug 가 요구된다.
|
||||||
*/
|
*/
|
||||||
@RequiresPermission(StudioPermissions.WRITE)
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
|
|||||||
+47
@@ -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}. 제목 하나로 초안을 연다.
|
||||||
|
*
|
||||||
|
* <p>{@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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-3
@@ -17,9 +17,8 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* {@code deleteTopic}.
|
* {@code deleteTopic}.
|
||||||
*
|
*
|
||||||
* <p>참조가 있으면 지우지 않고 {@code TOPIC_IN_USE} 로 거절한다. 외래키를 CASCADE 로 두지 않는 이유는,
|
* <p>참조가 있으면 지우지 않고 {@code TOPIC_IN_USE} 로 거절한다. 외래키를 CASCADE 로 두지 않는 이유는, 주제를 지웠다는 이유로 그 주제를 쓰던
|
||||||
* 주제를 지웠다는 이유로 그 주제를 쓰던 문서의 분류가 조용히 사라지면 안 되기 때문이다 — 지우려면
|
* 문서의 분류가 조용히 사라지면 안 되기 때문이다 — 지우려면 먼저 그 문서들을 옮기라는 뜻이다.
|
||||||
* 먼저 그 문서들을 옮기라는 뜻이다.
|
|
||||||
*/
|
*/
|
||||||
@RequiresPermission(StudioPermissions.WRITE)
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.capability.Idempotency;
|
||||||
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
|
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||||
|
import dev.caskeleton.application.security.RequiresPermission;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-7
@@ -22,8 +22,7 @@ public class ListStudioProjectsUseCase {
|
|||||||
private final ProjectRepositoryPort projects;
|
private final ProjectRepositoryPort projects;
|
||||||
private final TransactionPort transactions;
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
public ListStudioProjectsUseCase(
|
public ListStudioProjectsUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
|
||||||
ProjectRepositoryPort projects, TransactionPort transactions) {
|
|
||||||
this.projects = Objects.requireNonNull(projects, "projects");
|
this.projects = Objects.requireNonNull(projects, "projects");
|
||||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
}
|
}
|
||||||
@@ -42,9 +41,5 @@ public class ListStudioProjectsUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record Page(
|
public record Page(
|
||||||
List<ProjectIndexItemView> items,
|
List<ProjectIndexItemView> items, int number, int size, int totalElements, int totalPages) {}
|
||||||
int number,
|
|
||||||
int size,
|
|
||||||
int totalElements,
|
|
||||||
int totalPages) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.capability.Idempotency;
|
||||||
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
|
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||||
|
import dev.caskeleton.application.security.RequiresPermission;
|
||||||
|
import dev.caskeleton.application.techlog.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<ReleaseIndexItemView> 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<ReleaseIndexItemView> items, int number, int size, int totalElements, int totalPages) {}
|
||||||
|
}
|
||||||
+100
@@ -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
|
||||||
|
* 절), 이 전이가 곧 공개다.
|
||||||
|
*
|
||||||
|
* <p>그래서 발행 전에 계약이 필수로 선언한 것들이 실제로 채워져 있는지 여기서 확인한다. 저장은 초안을 비워 둔 채로도 허용해야 하고 — 아니면 한 번에 다 쓰지 않으면
|
||||||
|
* 저장을 못 한다 — 공개는 그럴 수 없다. 두 시점의 요구가 다르다.
|
||||||
|
*/
|
||||||
|
@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<String> 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<String> missingForPublication(ReleaseEditView release) {
|
||||||
|
List<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초안 릴리즈의 자리표시자 버전 라벨.
|
||||||
|
*
|
||||||
|
* <p>{@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-7
@@ -18,13 +18,12 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* {@code createTopic} / {@code updateTopic}.
|
* {@code createTopic} / {@code updateTopic}.
|
||||||
*
|
*
|
||||||
* <p>이름과 slug 의 중복은 DB 제약이 이미 막고 있다. 그래도 여기서 먼저 확인하는 이유는 계약이
|
* <p>이름과 slug 의 중복은 DB 제약이 이미 막고 있다. 그래도 여기서 먼저 확인하는 이유는 계약이 {@code TOPIC_NAME_TAKEN}/{@code
|
||||||
* {@code TOPIC_NAME_TAKEN}/{@code TOPIC_SLUG_TAKEN} 을 구분해서 요구하기 때문이다 — 제약 위반을
|
* TOPIC_SLUG_TAKEN} 을 구분해서 요구하기 때문이다 — 제약 위반을 잡아 코드로 되돌리면 어느 제약이었는지는 드라이버 메시지 문자열에서 읽어야 하고, 그건 벤더가
|
||||||
* 잡아 코드로 되돌리면 어느 제약이었는지는 드라이버 메시지 문자열에서 읽어야 하고, 그건 벤더가
|
|
||||||
* 바뀌면 조용히 깨진다.
|
* 바뀌면 조용히 깨진다.
|
||||||
*
|
*
|
||||||
* <p>이름 비교는 정규화(소문자·공백 정리) 후에 한다. 테이블의 {@code uq_topic_normalized_name} 이
|
* <p>이름 비교는 정규화(소문자·공백 정리) 후에 한다. 테이블의 {@code uq_topic_normalized_name} 이 같은 규칙이므로, 여기서만 다르게 정규화하면
|
||||||
* 같은 규칙이므로, 여기서만 다르게 정규화하면 사전 확인을 통과한 요청이 제약에서 터진다.
|
* 사전 확인을 통과한 요청이 제약에서 터진다.
|
||||||
*/
|
*/
|
||||||
@RequiresPermission(StudioPermissions.WRITE)
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
@@ -73,8 +72,7 @@ public class SaveTopicUseCase {
|
|||||||
.find(command.id())
|
.find(command.id())
|
||||||
.orElseThrow(
|
.orElseThrow(
|
||||||
() ->
|
() ->
|
||||||
ManagementException.of(
|
ManagementException.of(ManagementError.TOPIC_NOT_FOUND, "no such topic"));
|
||||||
ManagementError.TOPIC_NOT_FOUND, "no such topic"));
|
|
||||||
return topics
|
return topics
|
||||||
.update(command)
|
.update(command)
|
||||||
.orElseThrow(
|
.orElseThrow(
|
||||||
|
|||||||
+6
-1
@@ -26,7 +26,12 @@ public class UpdateProjectUseCase {
|
|||||||
/** 테이블의 CHECK 제약과 같은 집합. 여기서 먼저 거절해야 422 로 나가고, 아니면 DB 오류가 500 이 된다. */
|
/** 테이블의 CHECK 제약과 같은 집합. 여기서 먼저 거절해야 422 로 나가고, 아니면 DB 오류가 500 이 된다. */
|
||||||
private static final Set<String> PHASES =
|
private static final Set<String> PHASES =
|
||||||
Set.of(
|
Set.of(
|
||||||
"RESEARCH", "DESIGN", "IMPLEMENTATION", "VERIFICATION", "MAINTENANCE", "PAUSED",
|
"RESEARCH",
|
||||||
|
"DESIGN",
|
||||||
|
"IMPLEMENTATION",
|
||||||
|
"VERIFICATION",
|
||||||
|
"MAINTENANCE",
|
||||||
|
"PAUSED",
|
||||||
"COMPLETED");
|
"COMPLETED");
|
||||||
|
|
||||||
private static final Set<String> VISIBILITIES = Set.of("PRIVATE", "UNLISTED", "PUBLIC");
|
private static final Set<String> VISIBILITIES = Set.of("PRIVATE", "UNLISTED", "PUBLIC");
|
||||||
|
|||||||
+82
@@ -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<String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,7 +101,9 @@
|
|||||||
"application-core",
|
"application-core",
|
||||||
"shared-contract"
|
"shared-contract"
|
||||||
],
|
],
|
||||||
"runtime_memberships": []
|
"runtime_memberships": [
|
||||||
|
"app-bootstrap"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "adapter-outbound-cache-redis",
|
"id": "adapter-outbound-cache-redis",
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ info:
|
|||||||
description: |-
|
description: |-
|
||||||
⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다.
|
⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다.
|
||||||
|
|
||||||
topics 4개와 projects 5개는 studio-v1.yaml과 같은 방식으로 변환했다
|
topics 4개, projects 5개, releases 7개를 studio-v1.yaml과 같은 방식으로
|
||||||
(`application/json` + ErrorEnvelope / <Payload>Envelope). 그 9개가 첫 구현
|
변환했다 (`application/json` + ErrorEnvelope / <Payload>Envelope). 그 16개가
|
||||||
대상이자 첫 소비자이기 때문이다.
|
구현된 것이자 소비자가 있는 것이기 때문이다.
|
||||||
|
|
||||||
나머지 70개는 아직 bare payload + `application/problem+json` + ProblemDetails
|
나머지 63개는 아직 bare payload + `application/problem+json` + ProblemDetails
|
||||||
다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리
|
다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리
|
||||||
변환해 두면 검증되지 않은 모양이 계약에 고정된다.
|
변환해 두면 검증되지 않은 모양이 계약에 고정된다.
|
||||||
|
|
||||||
@@ -3630,49 +3630,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/CreateDraftResponse'
|
$ref: '#/components/schemas/CreateDraftResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3705,37 +3705,37 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ReleaseIndexPage'
|
$ref: '#/components/schemas/ReleaseIndexPageEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
security:
|
security:
|
||||||
- sessionCookie: []
|
- sessionCookie: []
|
||||||
/api/v1/studio/releases/{id}:
|
/api/v1/studio/releases/{id}:
|
||||||
@@ -3756,37 +3756,37 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ReleaseEditResponse'
|
$ref: '#/components/schemas/ReleaseEditResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
security:
|
security:
|
||||||
- sessionCookie: []
|
- sessionCookie: []
|
||||||
put:
|
put:
|
||||||
@@ -3807,49 +3807,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ReleaseEditResponse'
|
$ref: '#/components/schemas/ReleaseEditResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3876,45 +3876,45 @@ paths:
|
|||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3942,49 +3942,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/PublishResponse'
|
$ref: '#/components/schemas/PublishResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -4012,49 +4012,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ReleaseEditResponse'
|
$ref: '#/components/schemas/ReleaseEditResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: Forbidden
|
description: Forbidden
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: Not Found
|
description: Not Found
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: Conflict
|
description: Conflict
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -5267,6 +5267,9 @@ components:
|
|||||||
- PROJECT_NOT_FOUND
|
- PROJECT_NOT_FOUND
|
||||||
- PROJECT_SLUG_TAKEN
|
- PROJECT_SLUG_TAKEN
|
||||||
- PROJECT_IN_USE
|
- PROJECT_IN_USE
|
||||||
|
- RELEASE_NOT_FOUND
|
||||||
|
- RELEASE_VERSION_TAKEN
|
||||||
|
- RELEASE_NOT_PUBLISHABLE
|
||||||
- INTERNAL_ERROR
|
- INTERNAL_ERROR
|
||||||
description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.'
|
description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.'
|
||||||
category:
|
category:
|
||||||
@@ -5392,6 +5395,51 @@ components:
|
|||||||
$ref: '#/components/schemas/ProjectIndexPage'
|
$ref: '#/components/schemas/ProjectIndexPage'
|
||||||
meta:
|
meta:
|
||||||
$ref: '#/components/schemas/ResponseMeta'
|
$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:
|
ProjectEditResponseEnvelope:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
Reference in New Issue
Block a user