Compare commits
10
Commits
23d82bd250
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb0890dc8 | ||
|
|
c6d9d2d675 | ||
|
|
a5f93b9b75 | ||
|
|
f1fd56fcb5 | ||
|
|
bd66fb3610 | ||
|
|
a7e2b7d7fe | ||
|
|
6aa140077d | ||
|
|
ca63d7d3ec | ||
|
|
4c14f1eb8f | ||
|
|
561d02ae3a |
@@ -112,6 +112,22 @@ env_keys:
|
|||||||
compatibility_impact: behavior-change
|
compatibility_impact: behavior-change
|
||||||
required_test: env-contract:server-shutdown-timeout-aligned
|
required_test: env-contract:server-shutdown-timeout-aligned
|
||||||
|
|
||||||
|
- name: APP_SESSION_TIMEOUT
|
||||||
|
# source: Studio 작성 세션. Spring 기본 30분 유휴 만료는 한 기록을 여러 번 저장하며
|
||||||
|
# 오래 머무는 작성 리듬보다 짧아, 저장하지 않은 편집을 잃는 원인이 되었다.
|
||||||
|
# 늘릴수록 훔친 session cookie 가 유효한 창도 같이 늘어나므로 Keycloak realm 의
|
||||||
|
# SSO idle 과 함께 맞춘다.
|
||||||
|
type: duration
|
||||||
|
default: 8h
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: feature-env-driven-runtime-configuration
|
||||||
|
validation: spring_duration_shorthand
|
||||||
|
compatibility_impact: behavior-change
|
||||||
|
required_test: env-contract:session-timeout
|
||||||
|
|
||||||
- name: APP_SERVER_FORWARD_HEADERS_STRATEGY
|
- name: APP_SERVER_FORWARD_HEADERS_STRATEGY
|
||||||
# source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification
|
# source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification
|
||||||
# (trust X-Forwarded-* when behind LB/proxy)
|
# (trust X-Forwarded-* when behind LB/proxy)
|
||||||
|
|||||||
@@ -960,7 +960,12 @@ errors:
|
|||||||
compatibility_impact: additive
|
compatibility_impact: additive
|
||||||
required_test: StudioErrorTest
|
required_test: StudioErrorTest
|
||||||
|
|
||||||
# source: studio-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (StudioError.DOCUMENT_NOT_FOUND)
|
# source: studio-v1.yaml + studio-management-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND
|
||||||
|
# (StudioError.DOCUMENT_NOT_FOUND, ManagementError.DOCUMENT_NOT_FOUND)
|
||||||
|
#
|
||||||
|
# 두 표면이 같은 코드를 쓴다. 이 레지스트리의 식별자는 code 하나뿐이므로 행도 하나다 —
|
||||||
|
# 한때 표면마다 행을 두고 문구를 다르게 적었고("작업본을 찾을 수 없습니다"), 그 중복이
|
||||||
|
# 레지스트리 로딩 자체를 깨뜨렸다. 같은 코드는 같은 말을 해야 한다.
|
||||||
- code: DOCUMENT_NOT_FOUND
|
- code: DOCUMENT_NOT_FOUND
|
||||||
category: NOT_FOUND
|
category: NOT_FOUND
|
||||||
http_status: 404
|
http_status: 404
|
||||||
@@ -1403,19 +1408,6 @@ 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 — DOCUMENT_NOT_FOUND (ManagementError.DOCUMENT_NOT_FOUND)
|
|
||||||
- code: DOCUMENT_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 — DOCUMENT_PUBLISHED (ManagementError.DOCUMENT_PUBLISHED)
|
# source: studio-management-v1.yaml ApiError.code — DOCUMENT_PUBLISHED (ManagementError.DOCUMENT_PUBLISHED)
|
||||||
- code: DOCUMENT_PUBLISHED
|
- code: DOCUMENT_PUBLISHED
|
||||||
category: CONFLICT
|
category: CONFLICT
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ public final class ManagementClientSafeMessages {
|
|||||||
case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다";
|
case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다";
|
||||||
case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다";
|
case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다";
|
||||||
case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다";
|
case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다";
|
||||||
case DOCUMENT_NOT_FOUND -> "작업본을 찾을 수 없습니다";
|
case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다";
|
||||||
case DOCUMENT_PUBLISHED -> "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요";
|
case DOCUMENT_PUBLISHED -> "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요";
|
||||||
case DOCUMENT_IN_USE -> "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다";
|
case DOCUMENT_IN_USE -> "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다";
|
||||||
case QUESTION_NOT_FOUND -> "질문을 찾을 수 없습니다";
|
case QUESTION_NOT_FOUND -> "질문을 찾을 수 없습니다";
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
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.HomeFocusRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.GetHomeFocusUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈 focus 설정. 계약 {@code getHomeFocus}/{@code updateHomeFocus}.
|
||||||
|
*
|
||||||
|
* <p>단일 행이라 경로에 id 가 없다. 낙관적 잠금은 본문의 {@code expectedVersion} 으로만 한다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ManagementHomeFocusController {
|
||||||
|
|
||||||
|
private final GetHomeFocusUseCase getHomeFocus;
|
||||||
|
private final UpdateHomeFocusUseCase updateHomeFocus;
|
||||||
|
|
||||||
|
public ManagementHomeFocusController(
|
||||||
|
GetHomeFocusUseCase getHomeFocus, UpdateHomeFocusUseCase updateHomeFocus) {
|
||||||
|
this.getHomeFocus = getHomeFocus;
|
||||||
|
this.updateHomeFocus = updateHomeFocus;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/home/focus")
|
||||||
|
public HomeFocusResponse getHomeFocus() {
|
||||||
|
return ManagementResponseMapper.homeFocus(getHomeFocus.handle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/v1/studio/home/focus")
|
||||||
|
public HomeFocusResponse updateHomeFocus(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@RequestBody HomeFocusRequest body) {
|
||||||
|
return ManagementResponseMapper.homeFocus(
|
||||||
|
updateHomeFocus.handle(
|
||||||
|
new UpdateHomeFocusCommand(
|
||||||
|
body.getExpectedVersion(),
|
||||||
|
body.getDefaultType() == null ? null : body.getDefaultType().getValue(),
|
||||||
|
body.getCurrentProjectId(),
|
||||||
|
body.getOpenQuestionId(),
|
||||||
|
body.getRecentDecisionId(),
|
||||||
|
ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.UpdateProjectActivityRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.CreateProjectActivityUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.DeleteProjectActivityUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioProjectActivitiesUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UpdateProjectActivityUseCase;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 활동. 계약 {@code listStudioProjectActivities}/{@code createProjectActivity}/{@code
|
||||||
|
* updateProjectActivity}/{@code deleteProjectActivity}.
|
||||||
|
*
|
||||||
|
* <p>공개 프로젝트 화면의 "활동" 은 {@code project_activity} 를 직접 읽는다 — 투영을 거치지 않으므로 여기서 만든 줄이 곧 그 화면이다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ManagementProjectActivityController {
|
||||||
|
|
||||||
|
private final ListStudioProjectActivitiesUseCase listActivities;
|
||||||
|
private final CreateProjectActivityUseCase createActivity;
|
||||||
|
private final UpdateProjectActivityUseCase updateActivity;
|
||||||
|
private final DeleteProjectActivityUseCase deleteActivity;
|
||||||
|
|
||||||
|
public ManagementProjectActivityController(
|
||||||
|
ListStudioProjectActivitiesUseCase listActivities,
|
||||||
|
CreateProjectActivityUseCase createActivity,
|
||||||
|
UpdateProjectActivityUseCase updateActivity,
|
||||||
|
DeleteProjectActivityUseCase deleteActivity) {
|
||||||
|
this.listActivities = listActivities;
|
||||||
|
this.createActivity = createActivity;
|
||||||
|
this.updateActivity = updateActivity;
|
||||||
|
this.deleteActivity = deleteActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/projects/{id}/activities")
|
||||||
|
public List<ProjectActivityResponse> listStudioProjectActivities(@PathVariable("id") UUID id) {
|
||||||
|
return ManagementResponseMapper.projectActivities(listActivities.handle(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/projects/{id}/activities")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public ProjectActivityResponse createProjectActivity(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ProjectActivityRequest body) {
|
||||||
|
return ManagementResponseMapper.projectActivity(
|
||||||
|
createActivity.handle(
|
||||||
|
new CreateProjectActivityCommand(
|
||||||
|
id,
|
||||||
|
body.getExpectedProjectVersion(),
|
||||||
|
body.getActivityType(),
|
||||||
|
body.getTitle(),
|
||||||
|
body.getSummary(),
|
||||||
|
body.getVisibility() == null ? null : body.getVisibility().getValue(),
|
||||||
|
body.getRelatedResourceType(),
|
||||||
|
body.getRelatedResourceId(),
|
||||||
|
instant(body.getOccurredAt()),
|
||||||
|
ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/v1/studio/projects/{id}/activities/{activityId}")
|
||||||
|
public ProjectActivityResponse updateProjectActivity(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@PathVariable("activityId") UUID activityId,
|
||||||
|
@RequestBody UpdateProjectActivityRequest body) {
|
||||||
|
return ManagementResponseMapper.projectActivity(
|
||||||
|
updateActivity.handle(
|
||||||
|
new UpdateProjectActivityCommand(
|
||||||
|
id,
|
||||||
|
activityId,
|
||||||
|
body.getExpectedVersion(),
|
||||||
|
body.getTitle(),
|
||||||
|
body.getSummary(),
|
||||||
|
body.getVisibility() == null ? null : body.getVisibility().getValue(),
|
||||||
|
instant(body.getOccurredAt()),
|
||||||
|
ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/projects/{id}/activities/{activityId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteProjectActivity(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@PathVariable("activityId") UUID activityId,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
deleteActivity.handle(
|
||||||
|
id, activityId, body.getExpectedVersion(), ManagementPrincipals.require(principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.time.Instant instant(OffsetDateTime value) {
|
||||||
|
return value == null ? null : value.toInstant();
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-1
@@ -8,6 +8,8 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedV
|
|||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectUpdateRequest;
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectUpdateRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||||
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
|
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
|
||||||
import dev.caskeleton.application.techlog.management.command.DeleteProjectCommand;
|
import dev.caskeleton.application.techlog.management.command.DeleteProjectCommand;
|
||||||
@@ -16,6 +18,8 @@ import dev.caskeleton.application.techlog.management.service.CreateProjectUseCas
|
|||||||
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
|
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
|
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
|
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
|
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -41,17 +45,24 @@ public class ManagementProjectController {
|
|||||||
private final UpdateProjectUseCase updateProject;
|
private final UpdateProjectUseCase updateProject;
|
||||||
private final DeleteProjectUseCase deleteProject;
|
private final DeleteProjectUseCase deleteProject;
|
||||||
|
|
||||||
|
private final PublishProjectUseCase publishProject;
|
||||||
|
private final UnpublishProjectUseCase unpublishProject;
|
||||||
|
|
||||||
public ManagementProjectController(
|
public ManagementProjectController(
|
||||||
ListStudioProjectsUseCase listProjects,
|
ListStudioProjectsUseCase listProjects,
|
||||||
GetProjectForEditUseCase getProject,
|
GetProjectForEditUseCase getProject,
|
||||||
CreateProjectUseCase createProject,
|
CreateProjectUseCase createProject,
|
||||||
UpdateProjectUseCase updateProject,
|
UpdateProjectUseCase updateProject,
|
||||||
DeleteProjectUseCase deleteProject) {
|
DeleteProjectUseCase deleteProject,
|
||||||
|
PublishProjectUseCase publishProject,
|
||||||
|
UnpublishProjectUseCase unpublishProject) {
|
||||||
this.listProjects = listProjects;
|
this.listProjects = listProjects;
|
||||||
this.getProject = getProject;
|
this.getProject = getProject;
|
||||||
this.createProject = createProject;
|
this.createProject = createProject;
|
||||||
this.updateProject = updateProject;
|
this.updateProject = updateProject;
|
||||||
this.deleteProject = deleteProject;
|
this.deleteProject = deleteProject;
|
||||||
|
this.publishProject = publishProject;
|
||||||
|
this.unpublishProject = unpublishProject;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/v1/studio/projects")
|
@GetMapping("/v1/studio/projects")
|
||||||
@@ -98,6 +109,7 @@ public class ManagementProjectController {
|
|||||||
body.getCurrentObjective(),
|
body.getCurrentObjective(),
|
||||||
body.getNextStep(),
|
body.getNextStep(),
|
||||||
labels,
|
labels,
|
||||||
|
body.getTopicIds() == null ? List.of() : List.copyOf(body.getTopicIds()),
|
||||||
body.getTargetVisibility().getValue(),
|
body.getTargetVisibility().getValue(),
|
||||||
body.getFeaturedOrder(),
|
body.getFeaturedOrder(),
|
||||||
ManagementPrincipals.require(principal))));
|
ManagementPrincipals.require(principal))));
|
||||||
@@ -113,4 +125,27 @@ public class ManagementProjectController {
|
|||||||
new DeleteProjectCommand(
|
new DeleteProjectCommand(
|
||||||
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
|
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/projects/{id}/publish")
|
||||||
|
public PublishResponse publishProject(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody PublishRequest body) {
|
||||||
|
return ManagementResponseMapper.projectPublication(
|
||||||
|
publishProject.handle(
|
||||||
|
id,
|
||||||
|
body.getExpectedVersion(),
|
||||||
|
body.getVisibility() == null ? null : body.getVisibility().getValue(),
|
||||||
|
ManagementPrincipals.require(principal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/projects/{id}/unpublish")
|
||||||
|
public ProjectEditResponse unpublishProject(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
return ManagementResponseMapper.project(
|
||||||
|
unpublishProject.handle(
|
||||||
|
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-1
@@ -1,7 +1,9 @@
|
|||||||
package dev.caskeleton.adapter.inbound.web.techlog.management.mapper;
|
package dev.caskeleton.adapter.inbound.web.techlog.management.mapper;
|
||||||
|
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PageMetadata;
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PageMetadata;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityResponse;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem;
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
|
||||||
@@ -11,6 +13,8 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEd
|
|||||||
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexItem;
|
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.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.HomeFocusConfigView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
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.ReleaseEditView;
|
||||||
@@ -85,7 +89,7 @@ public final class ManagementResponseMapper {
|
|||||||
model.setNextStep(view.nextStep());
|
model.setNextStep(view.nextStep());
|
||||||
model.setTechnologyLabels(List.copyOf(view.technologyLabels()));
|
model.setTechnologyLabels(List.copyOf(view.technologyLabels()));
|
||||||
model.setFeaturedOrder(view.featuredOrder());
|
model.setFeaturedOrder(view.featuredOrder());
|
||||||
model.setTopicIds(List.of());
|
model.setTopicIds(List.copyOf(view.topicIds()));
|
||||||
model.setDocumentLinks(List.of());
|
model.setDocumentLinks(List.of());
|
||||||
model.setQuestionLinks(List.of());
|
model.setQuestionLinks(List.of());
|
||||||
return model;
|
return model;
|
||||||
@@ -188,6 +192,52 @@ public final class ManagementResponseMapper {
|
|||||||
pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
|
pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 홈 focus 설정. 세 슬롯은 비어 있을 수 있고, 비어 있음이 곧 "고르지 않았다"는 뜻이다. */
|
||||||
|
public static HomeFocusResponse homeFocus(HomeFocusConfigView view) {
|
||||||
|
HomeFocusResponse response = new HomeFocusResponse(view.id(), view.version());
|
||||||
|
response.setDefaultType(view.defaultType());
|
||||||
|
response.setCurrentProjectId(view.currentProjectId());
|
||||||
|
response.setOpenQuestionId(view.openQuestionId());
|
||||||
|
response.setRecentDecisionId(view.recentDecisionId());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로젝트 게시 결과. canonical path 는 공개 조회가 쓰는 {@code /projects/<slug>} 와 같은 규칙으로 만든다. */
|
||||||
|
public static PublishResponse projectPublication(ProjectEditView view) {
|
||||||
|
return new PublishResponse(
|
||||||
|
view.id(),
|
||||||
|
PublishResponse.StatusEnum.PUBLISHED,
|
||||||
|
"UNLISTED".equals(view.targetVisibility())
|
||||||
|
? PublishResponse.VisibilityEnum.UNLISTED
|
||||||
|
: PublishResponse.VisibilityEnum.PUBLIC,
|
||||||
|
"/projects/" + view.slug(),
|
||||||
|
at(view.lastPublishedAt()),
|
||||||
|
view.version());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로젝트 활동 한 줄. {@code origin} 을 그대로 실어 보낸다 — 화면이 지울 수 있는 줄과 아닌 줄을 그것으로 가른다. */
|
||||||
|
public static ProjectActivityResponse projectActivity(ProjectActivityView view) {
|
||||||
|
ProjectActivityResponse response =
|
||||||
|
new ProjectActivityResponse(
|
||||||
|
view.id(),
|
||||||
|
view.projectId(),
|
||||||
|
view.activityType(),
|
||||||
|
view.title(),
|
||||||
|
view.visibility(),
|
||||||
|
view.origin(),
|
||||||
|
at(view.occurredAt()),
|
||||||
|
view.version());
|
||||||
|
response.setSummary(view.summary());
|
||||||
|
response.setRelatedResourceType(view.relatedResourceType());
|
||||||
|
response.setRelatedResourceId(view.relatedResourceId());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static java.util.List<ProjectActivityResponse> projectActivities(
|
||||||
|
java.util.List<ProjectActivityView> views) {
|
||||||
|
return views.stream().map(ManagementResponseMapper::projectActivity).toList();
|
||||||
|
}
|
||||||
|
|
||||||
private static String nullToEmpty(String value) {
|
private static String nullToEmpty(String value) {
|
||||||
return value == null ? "" : value;
|
return value == null ? "" : value;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -11,6 +11,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPo
|
|||||||
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionUpdatePublic;
|
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionUpdatePublic;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse;
|
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReference;
|
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReference;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReferenceRulesInner;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseRelations;
|
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseRelations;
|
||||||
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
|
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
|
||||||
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
|
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
|
||||||
@@ -36,6 +37,7 @@ public final class DocumentResponseMapper {
|
|||||||
PublishedDocumentView doc = view.document();
|
PublishedDocumentView doc = view.document();
|
||||||
CaseDetailResponseCase body = new CaseDetailResponseCase();
|
CaseDetailResponseCase body = new CaseDetailResponseCase();
|
||||||
body.setTitle(doc.title());
|
body.setTitle(doc.title());
|
||||||
|
body.setSummary(doc.summary());
|
||||||
body.setProblemSummary(doc.primarySummary());
|
body.setProblemSummary(doc.primarySummary());
|
||||||
body.setConclusionSummary(doc.secondarySummary());
|
body.setConclusionSummary(doc.secondarySummary());
|
||||||
body.setEnvironmentSummary(doc.environmentSummary());
|
body.setEnvironmentSummary(doc.environmentSummary());
|
||||||
@@ -71,9 +73,17 @@ public final class DocumentResponseMapper {
|
|||||||
PublishedDocumentView doc = view.document();
|
PublishedDocumentView doc = view.document();
|
||||||
ReferenceDetailResponseReference body = new ReferenceDetailResponseReference();
|
ReferenceDetailResponseReference body = new ReferenceDetailResponseReference();
|
||||||
body.setTitle(doc.title());
|
body.setTitle(doc.title());
|
||||||
|
body.setSummary(doc.summary());
|
||||||
body.setScopeSummary(doc.primarySummary());
|
body.setScopeSummary(doc.primarySummary());
|
||||||
body.setAppliesTo(doc.appliesTo());
|
body.setAppliesTo(doc.appliesTo());
|
||||||
body.setExcludedScope(doc.excludedScope());
|
body.setExcludedScope(doc.excludedScope());
|
||||||
|
// Reference 의 본문은 `content` 가 아니라 여기 있다 — 이것을 빼면 공개 화면에 판단 기준과
|
||||||
|
// 예시가 통째로 빠진다.
|
||||||
|
body.setRules(
|
||||||
|
doc.rules().stream()
|
||||||
|
.map(rule -> new ReferenceDetailResponseReferenceRulesInner(rule.title(), rule.body()))
|
||||||
|
.toList());
|
||||||
|
body.setExamples(doc.examples());
|
||||||
body.setFreshnessStatus(
|
body.setFreshnessStatus(
|
||||||
ReferenceDetailResponseReference.FreshnessStatusEnum.fromValue(doc.freshnessStatus()));
|
ReferenceDetailResponseReference.FreshnessStatusEnum.fromValue(doc.freshnessStatus()));
|
||||||
body.setContent(doc.content());
|
body.setContent(doc.content());
|
||||||
|
|||||||
+3
@@ -9,6 +9,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDet
|
|||||||
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListItem;
|
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListItem;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse;
|
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage;
|
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary;
|
||||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
|
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
|
||||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
|
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
|
||||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
|
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
|
||||||
@@ -56,6 +57,8 @@ public final class ProjectResponseMapper {
|
|||||||
body.setNextStep(p.nextStep());
|
body.setNextStep(p.nextStep());
|
||||||
body.setSystemOverviewMarkdown(p.systemOverviewMarkdown());
|
body.setSystemOverviewMarkdown(p.systemOverviewMarkdown());
|
||||||
body.setTechnologies(p.technologies());
|
body.setTechnologies(p.technologies());
|
||||||
|
body.setTopics(
|
||||||
|
p.topics().stream().map(topic -> new TopicSummary(topic.name(), topic.slug())).toList());
|
||||||
body.setUpdatedAt(PublicResponseMapper.at(p.updatedAt()));
|
body.setUpdatedAt(PublicResponseMapper.at(p.updatedAt()));
|
||||||
|
|
||||||
ProjectDetailResponse dto = new ProjectDetailResponse();
|
ProjectDetailResponse dto = new ProjectDetailResponse();
|
||||||
|
|||||||
-1
@@ -28,7 +28,6 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|||||||
+55
-8
@@ -8,10 +8,12 @@ import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell
|
|||||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn;
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow;
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ImageBlock;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem;
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock;
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ThematicBreakBlock;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -24,6 +26,7 @@ import org.commonmark.node.BlockQuote;
|
|||||||
import org.commonmark.node.BulletList;
|
import org.commonmark.node.BulletList;
|
||||||
import org.commonmark.node.FencedCodeBlock;
|
import org.commonmark.node.FencedCodeBlock;
|
||||||
import org.commonmark.node.Heading;
|
import org.commonmark.node.Heading;
|
||||||
|
import org.commonmark.node.Image;
|
||||||
import org.commonmark.node.IndentedCodeBlock;
|
import org.commonmark.node.IndentedCodeBlock;
|
||||||
import org.commonmark.node.Node;
|
import org.commonmark.node.Node;
|
||||||
import org.commonmark.node.OrderedList;
|
import org.commonmark.node.OrderedList;
|
||||||
@@ -38,10 +41,13 @@ import org.commonmark.node.ThematicBreak;
|
|||||||
*/
|
*/
|
||||||
final class BlockRenderer {
|
final class BlockRenderer {
|
||||||
|
|
||||||
/** 계약 {@code HeadingBlock.level} 은 2..4 다. Markdown 의 h1/h5/h6 는 이 범위로 접는다. */
|
/**
|
||||||
private static final int MIN_HEADING_LEVEL = 2;
|
* 계약 {@code HeadingBlock.level} 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 였고 h1/h5/h6 를 이 범위로 접었는데,
|
||||||
|
* 그러면 작성자가 쓴 위계가 화면에서 달라진다. 지금은 그대로 담는다.
|
||||||
|
*/
|
||||||
|
private static final int MIN_HEADING_LEVEL = 1;
|
||||||
|
|
||||||
private static final int MAX_HEADING_LEVEL = 4;
|
private static final int MAX_HEADING_LEVEL = 6;
|
||||||
|
|
||||||
private final HeadingIds headingIds;
|
private final HeadingIds headingIds;
|
||||||
private final List<String> warnings;
|
private final List<String> warnings;
|
||||||
@@ -67,18 +73,18 @@ final class BlockRenderer {
|
|||||||
private CaseRenderBlock renderBlock(Node node) {
|
private CaseRenderBlock renderBlock(Node node) {
|
||||||
return switch (node) {
|
return switch (node) {
|
||||||
case Heading value -> heading(value);
|
case Heading value -> heading(value);
|
||||||
case Paragraph value -> paragraph(InlineRenderer.render(value));
|
case Paragraph value -> paragraphOrImage(value);
|
||||||
case BlockQuote value -> blockquote(value);
|
case BlockQuote value -> blockquote(value);
|
||||||
case BulletList value -> bulletList(value);
|
case BulletList value -> bulletList(value);
|
||||||
case OrderedList value -> orderedList(value);
|
case OrderedList value -> orderedList(value);
|
||||||
case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo());
|
case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo());
|
||||||
case IndentedCodeBlock value -> code(value.getLiteral(), null);
|
case IndentedCodeBlock value -> code(value.getLiteral(), null);
|
||||||
case TableBlock value -> table(value);
|
case TableBlock value -> table(value);
|
||||||
|
// 계약에 수평선 타입이 생겼다(studio-v1 3.1.0). 예전에는 담을 곳이 없어 버리고 경고했다.
|
||||||
case ThematicBreak ignored -> {
|
case ThematicBreak ignored -> {
|
||||||
// 계약의 CaseRenderBlock 에 수평선 타입이 없다. 다른 블록으로 바꿔 넣으면 원문에 없던
|
ThematicBreakBlock block = new ThematicBreakBlock();
|
||||||
// 구조가 생기므로 버리고 경고한다.
|
block.setType(ThematicBreakBlock.TypeEnum.THEMATIC_BREAK);
|
||||||
warnings.add("THEMATIC_BREAK_NOT_RENDERABLE");
|
yield block;
|
||||||
yield null;
|
|
||||||
}
|
}
|
||||||
default -> {
|
default -> {
|
||||||
List<Inline> content = InlineRenderer.render(node);
|
List<Inline> content = InlineRenderer.render(node);
|
||||||
@@ -87,6 +93,47 @@ final class BlockRenderer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
|
||||||
|
*
|
||||||
|
* <p>Markdown 에서 {@code } 는 문단 안의 inline 인데 계약의 Inline union 에는 그림이 없다. 예전에는
|
||||||
|
* 그래서 대체 텍스트만 남기고 그림을 버렸다. 계약에 블록이 생겼으므로 (studio-v1 3.1.0) 문단이 그림 하나로만 이루어진 경우를 블록으로 올린다.
|
||||||
|
*
|
||||||
|
* <p>주소는 상대 경로이거나 http(s) 여야 한다 — {@code javascript:} 같은 스킴이 화면에 그대로 실리면 안 된다.
|
||||||
|
*/
|
||||||
|
private CaseRenderBlock paragraphOrImage(Paragraph value) {
|
||||||
|
Node only = value.getFirstChild();
|
||||||
|
if (only instanceof Image image && only == value.getLastChild()) {
|
||||||
|
String source = image.getDestination();
|
||||||
|
if (isRenderableImageSource(source)) {
|
||||||
|
ImageBlock block = new ImageBlock();
|
||||||
|
block.setType(ImageBlock.TypeEnum.IMAGE);
|
||||||
|
block.setSrc(source);
|
||||||
|
block.setAlt(InlineRenderer.plainText(image));
|
||||||
|
block.setTitle(image.getTitle());
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
warnings.add("IMAGE_SOURCE_NOT_RENDERABLE");
|
||||||
|
}
|
||||||
|
return paragraph(InlineRenderer.render(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isRenderableImageSource(String source) {
|
||||||
|
if (source == null || source.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 프로토콜 상대 주소(`//host/…`)는 앱 경로가 아니다. 아래의 단일 슬래시 검사보다 먼저 걸러야
|
||||||
|
// 한다 — 순서를 바꾸면 외부 호스트를 내부 경로로 오인한다.
|
||||||
|
if (source.startsWith("//")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (source.startsWith("/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String lower = source.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
return lower.startsWith("http://") || lower.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
private CaseRenderBlock heading(Heading value) {
|
private CaseRenderBlock heading(Heading value) {
|
||||||
HeadingBlock block = new HeadingBlock();
|
HeadingBlock block = new HeadingBlock();
|
||||||
block.setType(HeadingBlock.TypeEnum.HEADING);
|
block.setType(HeadingBlock.TypeEnum.HEADING);
|
||||||
|
|||||||
+4
-3
@@ -57,9 +57,10 @@ class StudioContentRendererTest {
|
|||||||
List<CaseRenderBlock> blocks = rendered.blocks();
|
List<CaseRenderBlock> blocks = rendered.blocks();
|
||||||
assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance);
|
assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance);
|
||||||
|
|
||||||
// 계약의 HeadingBlock.level 은 2..4 다. h1 과 h6 를 그대로 내보내면 계약 위반이다.
|
// 계약의 HeadingBlock.level 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 라 h1/h6 를 접었고,
|
||||||
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(2);
|
// 그래서 작성자가 쓴 위계가 화면에서 달라졌다. 지금은 그대로 내보낸다.
|
||||||
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(4);
|
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(1);
|
||||||
|
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(6);
|
||||||
|
|
||||||
assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow");
|
assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow");
|
||||||
assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론");
|
assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론");
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈 focus 설정 저장소.
|
||||||
|
*
|
||||||
|
* <p>{@code home_focus_config} 는 PK 가 고정 UUID 로 CHECK 되어 있는 단일 행 테이블이다. 그래서 조회에 WHERE 가 없고, 갱신은 그
|
||||||
|
* 고정 id 를 그대로 쓴다 — 행이 여러 개일 수 없으므로 "어느 행" 을 고를 일이 없다.
|
||||||
|
*
|
||||||
|
* <p>V9 마이그레이션이 세 슬롯이 모두 비어 있는 행을 미리 넣는다. 따라서 {@link #load()} 는 항상 값을 돌려주며, 빈 결과는 스키마가 깨진 경우뿐이라 그때는
|
||||||
|
* 예외가 맞다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcHomeFocusConfigAdapter implements HomeFocusConfigPort {
|
||||||
|
|
||||||
|
private static final UUID ROW_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||||
|
|
||||||
|
private static final String COLUMNS =
|
||||||
|
"id, version, default_focus_type, current_project_id, open_question_id, recent_decision_id";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcHomeFocusConfigAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HomeFocusConfigView map(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new HomeFocusConfigView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getLong("version"),
|
||||||
|
rs.getString("default_focus_type"),
|
||||||
|
rs.getObject("current_project_id", UUID.class),
|
||||||
|
rs.getObject("open_question_id", UUID.class),
|
||||||
|
rs.getObject("recent_decision_id", UUID.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HomeFocusConfigView load() {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT " + COLUMNS + " FROM home_focus_config")
|
||||||
|
.query(JdbcHomeFocusConfigAdapter::map)
|
||||||
|
.optional()
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
new IllegalStateException("home_focus_config has no row; migration V9 seeds one"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<HomeFocusConfigView> update(UpdateHomeFocusCommand command) {
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE home_focus_config SET default_focus_type = :defaultType,"
|
||||||
|
+ " current_project_id = :projectId, open_question_id = :questionId,"
|
||||||
|
+ " recent_decision_id = :decisionId, version = version + 1,"
|
||||||
|
+ " updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", ROW_ID)
|
||||||
|
.param("expected", command.expectedVersion())
|
||||||
|
.param("defaultType", command.defaultType())
|
||||||
|
.param("projectId", command.currentProjectId())
|
||||||
|
.param("questionId", command.openQuestionId())
|
||||||
|
.param("decisionId", command.recentDecisionId())
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return updated == 0 ? Optional.empty() : Optional.of(load());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean projectExists(UUID id) {
|
||||||
|
return exists("project", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean questionExists(UUID id) {
|
||||||
|
return exists("open_question", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean decisionExists(UUID id) {
|
||||||
|
return exists("project_decision", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 테이블 이름은 이 클래스 안의 상수 셋에서만 온다 — 호출자가 문자열을 넘길 수 없으므로 연결해도 주입 경로가 없다.
|
||||||
|
*/
|
||||||
|
private boolean exists(String table, UUID id) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT 1 FROM " + table + " WHERE id = :id")
|
||||||
|
.param("id", id)
|
||||||
|
.query(Integer.class)
|
||||||
|
.optional()
|
||||||
|
.isPresent();
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 활동 편집 저장소.
|
||||||
|
*
|
||||||
|
* <p>{@code origin} 은 이 경로에서 언제나 {@code MANUAL} 이다. 이 어댑터로 들어오는 것은 작성자가 손으로 적은 줄뿐이고, {@code AUTO} 는
|
||||||
|
* 게시 파이프라인이 남기는 몫이다. 클라이언트가 값을 정하게 두면 손으로 적은 줄에 {@code AUTO} 를 붙여 삭제 금지를 우회할 수 있다.
|
||||||
|
*
|
||||||
|
* <p>모든 쓰기에 {@code project_id} 조건이 붙는다. 경로가 프로젝트와 활동을 함께 요구하므로, 활동 id 만으로 수정하면 남의 프로젝트에 달린 줄을 고칠 수
|
||||||
|
* 있다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcProjectActivityRepositoryAdapter implements ProjectActivityRepositoryPort {
|
||||||
|
|
||||||
|
private static final String COLUMNS =
|
||||||
|
"id, project_id, activity_type, title, summary, visibility, origin,"
|
||||||
|
+ " related_resource_type, related_resource_id, occurred_at, version";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 생성자는 하나뿐이어야 한다. 한때 테스트용 id 생성기를 받는 두 번째 생성자가 있었고, 그러면
|
||||||
|
* 컴포넌트 스캔이 어느 것을 쓸지 정하지 못해 기본 생성자를 찾다가 실패한다 — 컴파일도 테스트도
|
||||||
|
* 통과하고 운영에서 기동만 못 한다. id 는 여기서 만들면 되고, 통합 테스트는 그 값을 볼 필요가 없다.
|
||||||
|
*/
|
||||||
|
public JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProjectActivityView map(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new ProjectActivityView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getObject("project_id", UUID.class),
|
||||||
|
rs.getString("activity_type"),
|
||||||
|
rs.getString("title"),
|
||||||
|
rs.getString("summary"),
|
||||||
|
rs.getString("visibility"),
|
||||||
|
rs.getString("origin"),
|
||||||
|
rs.getString("related_resource_type"),
|
||||||
|
rs.getObject("related_resource_id", UUID.class),
|
||||||
|
rs.getTimestamp("occurred_at").toInstant(),
|
||||||
|
rs.getLong("version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProjectActivityView> listByProject(UUID projectId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT "
|
||||||
|
+ COLUMNS
|
||||||
|
+ " FROM project_activity WHERE project_id = :projectId"
|
||||||
|
+ " ORDER BY occurred_at DESC, id DESC")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.query(JdbcProjectActivityRepositoryAdapter::map)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ProjectActivityView> find(UUID projectId, UUID activityId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT "
|
||||||
|
+ COLUMNS
|
||||||
|
+ " FROM project_activity"
|
||||||
|
+ " WHERE project_id = :projectId AND id = :id")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.param("id", activityId)
|
||||||
|
.query(JdbcProjectActivityRepositoryAdapter::map)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ProjectActivityView create(CreateProjectActivityCommand command) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
|
||||||
|
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
|
||||||
|
+ " created_by, updated_by)"
|
||||||
|
+ " VALUES (:id, :projectId, :type, :title, :summary, :visibility, 'MANUAL',"
|
||||||
|
+ " :relatedType, :relatedId, :occurredAt, :actor, :actor)")
|
||||||
|
.param("id", id)
|
||||||
|
.param("projectId", command.projectId())
|
||||||
|
.param("type", command.activityType())
|
||||||
|
.param("title", command.title().trim())
|
||||||
|
.param("summary", command.summary())
|
||||||
|
.param("visibility", command.visibility())
|
||||||
|
.param("relatedType", command.relatedResourceType())
|
||||||
|
.param("relatedId", command.relatedResourceId())
|
||||||
|
.param("occurredAt", java.sql.Timestamp.from(command.occurredAt()))
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return find(command.projectId(), id)
|
||||||
|
.orElseThrow(
|
||||||
|
() -> new IllegalStateException("activity vanished right after insert: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ProjectActivityView> update(UpdateProjectActivityCommand command) {
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE project_activity SET title = :title, summary = :summary,"
|
||||||
|
+ " visibility = :visibility, occurred_at = :occurredAt,"
|
||||||
|
+ " version = version + 1, updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND project_id = :projectId AND version = :expected")
|
||||||
|
.param("id", command.activityId())
|
||||||
|
.param("projectId", command.projectId())
|
||||||
|
.param("expected", command.expectedVersion())
|
||||||
|
.param("title", command.title().trim())
|
||||||
|
.param("summary", command.summary())
|
||||||
|
.param("visibility", command.visibility())
|
||||||
|
.param("occurredAt", java.sql.Timestamp.from(command.occurredAt()))
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return updated == 0 ? Optional.empty() : find(command.projectId(), command.activityId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(UUID projectId, UUID activityId, long expectedVersion) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"DELETE FROM project_activity"
|
||||||
|
+ " WHERE id = :id AND project_id = :projectId AND version = :expected")
|
||||||
|
.param("id", activityId)
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
-1
@@ -5,11 +5,15 @@ import dev.caskeleton.application.techlog.management.command.UpdateProjectComman
|
|||||||
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.port.out.ProjectRepositoryPort;
|
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -89,6 +93,7 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
|
|||||||
rs.getString("current_objective"),
|
rs.getString("current_objective"),
|
||||||
rs.getString("next_step"),
|
rs.getString("next_step"),
|
||||||
labels(rs.getString("technology_labels")),
|
labels(rs.getString("technology_labels")),
|
||||||
|
topicIdsOf(rs.getObject("id", UUID.class)),
|
||||||
rs.getString("workflow_status"),
|
rs.getString("workflow_status"),
|
||||||
rs.getString("target_visibility"),
|
rs.getString("target_visibility"),
|
||||||
(Integer) rs.getObject("featured_order"),
|
(Integer) rs.getObject("featured_order"),
|
||||||
@@ -186,7 +191,44 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
|
|||||||
.param("featured", command.featuredOrder())
|
.param("featured", command.featuredOrder())
|
||||||
.param("actor", command.actor())
|
.param("actor", command.actor())
|
||||||
.update();
|
.update();
|
||||||
return updated == 0 ? Optional.empty() : find(command.id());
|
if (updated == 0) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
replaceTopics(command.id(), command.topicIds());
|
||||||
|
return find(command.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 편집기가 고른 주제. 순서가 곧 화면 순서이므로 목록의 자리를 {@code display_order} 에 그대로 적는다. */
|
||||||
|
private List<UUID> topicIdsOf(UUID projectId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT topic_id FROM project_topic WHERE project_id = :projectId"
|
||||||
|
+ " ORDER BY display_order")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.query(UUID.class)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 통째로 교체한다. 부분 수정으로 두면 "주제를 전부 뗀다" 를 표현할 방법이 없고, 화면도 목록 하나를
|
||||||
|
* 한 번에 저장하므로 그쪽과도 맞는다.
|
||||||
|
*/
|
||||||
|
private void replaceTopics(UUID projectId, List<UUID> topicIds) {
|
||||||
|
jdbcClient
|
||||||
|
.sql("DELETE FROM project_topic WHERE project_id = :projectId")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.update();
|
||||||
|
int order = 0;
|
||||||
|
for (UUID topicId : topicIds) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO project_topic (project_id, topic_id, display_order)"
|
||||||
|
+ " VALUES (:projectId, :topicId, :order)")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.param("topicId", topicId)
|
||||||
|
.param("order", order++)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String nullToEmpty(String value) {
|
private static String nullToEmpty(String value) {
|
||||||
@@ -230,4 +272,142 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
|
|||||||
.query(Boolean.class)
|
.query(Boolean.class)
|
||||||
.single());
|
.single());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시는 세 곳을 한 트랜잭션 안에서 함께 세운다 — 원본의 상태, 공개 투영, canonical route. 어느 하나가 빠지면 증상이 제각각이다: 투영이 없으면 화면이
|
||||||
|
* 조용히 비고, route 가 없으면 주소만 404 가 되며, 원본 상태가 안 바뀌면 Studio 가 계속 "초안" 이라고 말한다.
|
||||||
|
*
|
||||||
|
* <p>낙관적 잠금이 먼저다. 버전이 어긋나면 아래 두 문장은 아예 실행하지 않는다.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<ProjectEditView> publish(
|
||||||
|
UUID id, long expectedVersion, String visibility, String actor) {
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE project SET workflow_status = 'PUBLISHED', target_visibility = :visibility,"
|
||||||
|
+ " first_published_at = COALESCE(first_published_at, now()),"
|
||||||
|
+ " last_published_at = now(), version = version + 1,"
|
||||||
|
+ " updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.param("visibility", visibility)
|
||||||
|
.param("actor", actor)
|
||||||
|
.update();
|
||||||
|
if (updated == 0) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectEditView project =
|
||||||
|
find(id)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("project vanished mid-publish: " + id));
|
||||||
|
upsertProjection(project, visibility);
|
||||||
|
replaceRoute(project);
|
||||||
|
return Optional.of(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ProjectEditView> unpublish(UUID id, long expectedVersion, String actor) {
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE project SET workflow_status = 'DRAFT', target_visibility = 'PRIVATE',"
|
||||||
|
+ " version = version + 1, updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.param("actor", actor)
|
||||||
|
.update();
|
||||||
|
if (updated == 0) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
|
||||||
|
+ " updated_at = now() WHERE resource_type = 'PROJECT' AND resource_id = :id")
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
return find(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 조회들은 실제 내용을 {@code project} 테이블에서 직접 읽고 이 행은 가시성 관문·주소·정렬 시각으로만 쓴다. 그래서 {@code payload} 는 빈
|
||||||
|
* 객체로 둔다 — 여기에 사본을 두면 원본이 바뀔 때마다 두 곳이 어긋난다.
|
||||||
|
*
|
||||||
|
* <p>{@code search_text} 만은 예외다. 검색은 이 테이블 하나만 훑으므로 여기에 없는 낱말은 영영 찾을 수 없다.
|
||||||
|
*/
|
||||||
|
private void upsertProjection(ProjectEditView project, String visibility) {
|
||||||
|
String searchText =
|
||||||
|
String.join(
|
||||||
|
" ",
|
||||||
|
nullToEmpty(project.name()),
|
||||||
|
nullToEmpty(project.oneLinePurpose()),
|
||||||
|
nullToEmpty(project.purposeMarkdown()),
|
||||||
|
nullToEmpty(project.currentObjective()),
|
||||||
|
nullToEmpty(project.nextStep()));
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
|
||||||
|
+ " publication_state, visibility, title, summary, state_code,"
|
||||||
|
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
|
||||||
|
+ " published_at, updated_at, navigation_path)"
|
||||||
|
+ " VALUES ('PROJECT', :id, :version, 'ACTIVE', :visibility, :title, :summary,"
|
||||||
|
+ " :stateCode, 1, '{}'::jsonb, :bodyPlainText, :searchText, :contentHash,"
|
||||||
|
+ " now(), now(), :navigationPath)"
|
||||||
|
+ " ON CONFLICT (resource_type, resource_id) DO UPDATE SET"
|
||||||
|
+ " source_version = EXCLUDED.source_version,"
|
||||||
|
+ " publication_state = 'ACTIVE',"
|
||||||
|
+ " visibility = EXCLUDED.visibility,"
|
||||||
|
+ " title = EXCLUDED.title,"
|
||||||
|
+ " summary = EXCLUDED.summary,"
|
||||||
|
+ " state_code = EXCLUDED.state_code,"
|
||||||
|
+ " body_plain_text = EXCLUDED.body_plain_text,"
|
||||||
|
+ " search_text = EXCLUDED.search_text,"
|
||||||
|
+ " content_hash = EXCLUDED.content_hash,"
|
||||||
|
+ " updated_at = now(),"
|
||||||
|
+ " navigation_path = EXCLUDED.navigation_path")
|
||||||
|
.param("id", project.id())
|
||||||
|
.param("version", project.version())
|
||||||
|
.param("visibility", visibility)
|
||||||
|
.param("title", project.name())
|
||||||
|
.param("summary", project.oneLinePurpose())
|
||||||
|
.param("stateCode", project.phase())
|
||||||
|
.param("bodyPlainText", nullToEmpty(project.purposeMarkdown()))
|
||||||
|
.param("searchText", searchText)
|
||||||
|
.param("contentHash", sha256(searchText))
|
||||||
|
.param("navigationPath", "/projects/" + project.slug())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이전 slug 의 route 는 alias 로 남긴다 — 지우면 이미 공개된 링크가 끊긴다. */
|
||||||
|
private void replaceRoute(ProjectEditView project) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE public_route SET route_role = 'ALIAS'"
|
||||||
|
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id AND slug <> :slug")
|
||||||
|
.param("id", project.id())
|
||||||
|
.param("slug", project.slug())
|
||||||
|
.update();
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
|
||||||
|
+ " VALUES ('PROJECT', :slug, :id, 'CANONICAL')"
|
||||||
|
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
|
||||||
|
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
|
||||||
|
.param("id", project.id())
|
||||||
|
.param("slug", project.slug())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sha256(String value) {
|
||||||
|
try {
|
||||||
|
return HexFormat.of()
|
||||||
|
.formatHex(
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-7
@@ -87,7 +87,8 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
|
|||||||
"SELECT d.id, d.title, d.body_markdown, d.content_format,"
|
"SELECT d.id, d.title, d.body_markdown, d.content_format,"
|
||||||
+ " d.content_format_version, d.cover_asset_id,"
|
+ " d.content_format_version, d.cover_asset_id,"
|
||||||
// `environment_items` 는 V7 이 만든 jsonb 배열이고, Studio 가 쓰는 것은 V8 이
|
// `environment_items` 는 V7 이 만든 jsonb 배열이고, Studio 가 쓰는 것은 V8 이
|
||||||
// 따로 만든 `environment`/`reproduction` 문자열이다(V8__techlog_studio_working_copy.sql:23-26).
|
// 따로 만든 `environment`/`reproduction`
|
||||||
|
// 문자열이다(V8__techlog_studio_working_copy.sql:23-26).
|
||||||
// 공개 조회가 배열 쪽을 읽는 동안 그 컬럼을 채우는 코드는 어디에도 없었고 —
|
// 공개 조회가 배열 쪽을 읽는 동안 그 컬럼을 채우는 코드는 어디에도 없었고 —
|
||||||
// 작성자가 검증 환경과 재현 조건을 채워도 공개 화면의 두 칸은 늘 비어 있었다.
|
// 작성자가 검증 환경과 재현 조건을 채워도 공개 화면의 두 칸은 늘 비어 있었다.
|
||||||
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
|
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
|
||||||
@@ -96,6 +97,8 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
|
|||||||
// 컬럼(`public_resource_projection.last_verified_at`)은 `upsertProjection` 이
|
// 컬럼(`public_resource_projection.last_verified_at`)은 `upsertProjection` 이
|
||||||
// 채우지 않아 언제나 null 이었다.
|
// 채우지 않아 언제나 null 이었다.
|
||||||
+ " p.navigation_path, p.published_at, p.updated_at, d.last_verified_at,"
|
+ " p.navigation_path, p.published_at, p.updated_at, d.last_verified_at,"
|
||||||
|
+ " d.summary,"
|
||||||
|
+ " r.rules, r.examples,"
|
||||||
+ " t.name AS topic_name, t.slug AS topic_slug,"
|
+ " t.name AS topic_name, t.slug AS topic_slug,"
|
||||||
+ " pr.name AS project_name, pr.slug AS project_slug,"
|
+ " pr.name AS project_name, pr.slug AS project_slug,"
|
||||||
+ " a.content_type AS cover_content_type, a.alt_text AS cover_alt,"
|
+ " a.content_type AS cover_content_type, a.alt_text AS cover_alt,"
|
||||||
@@ -125,12 +128,16 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
|
|||||||
type,
|
type,
|
||||||
rs.getString("navigation_path"),
|
rs.getString("navigation_path"),
|
||||||
rs.getString("title"),
|
rs.getString("title"),
|
||||||
|
rs.getString("summary"),
|
||||||
// Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다.
|
// Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다.
|
||||||
isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"),
|
isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"),
|
||||||
isCase ? rs.getString("conclusion_summary") : null,
|
isCase ? rs.getString("conclusion_summary") : null,
|
||||||
isCase ? environment(rs) : List.of(),
|
isCase ? environment(rs) : List.of(),
|
||||||
isCase ? List.of() : json.strings(rs.getString("applies_to")),
|
isCase ? List.of() : json.strings(rs.getString("applies_to")),
|
||||||
isCase ? List.of() : json.strings(rs.getString("excluded_scope")),
|
isCase ? List.of() : json.strings(rs.getString("excluded_scope")),
|
||||||
|
// Reference 의 본문은 body_markdown 이 아니라 규칙과 예시에 있다.
|
||||||
|
isCase ? List.of() : json.referenceRules(rs.getString("rules")),
|
||||||
|
isCase ? List.of() : json.strings(rs.getString("examples")),
|
||||||
isCase ? null : rs.getString("freshness_status"),
|
isCase ? null : rs.getString("freshness_status"),
|
||||||
rs.getString("body_markdown"),
|
rs.getString("body_markdown"),
|
||||||
rs.getString("content_format"),
|
rs.getString("content_format"),
|
||||||
@@ -229,8 +236,8 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
|
|||||||
/**
|
/**
|
||||||
* 본문이 {@code :::evidence key="..."} 로 가리키는 Asset.
|
* 본문이 {@code :::evidence key="..."} 로 가리키는 Asset.
|
||||||
*
|
*
|
||||||
* <p>{@code PUBLISHED} scope 의 참조만 읽는다 — 게시 이후 작업본이 Asset 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야
|
* <p>{@code PUBLISHED} scope 의 참조만 읽는다 — 게시 이후 작업본이 Asset 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야 하기
|
||||||
* 하기 때문이다. {@code WORKING} scope 는 Studio 의 것이다.
|
* 때문이다. {@code WORKING} scope 는 Studio 의 것이다.
|
||||||
*
|
*
|
||||||
* <p>{@code READY} 가 아닌 Asset 은 뺀다. {@code /media/{assetId}} 가 그것만 서빙하므로, 넣어 두면 공개 화면이 404 나는 주소를
|
* <p>{@code READY} 가 아닌 Asset 은 뺀다. {@code /media/{assetId}} 가 그것만 서빙하므로, 넣어 두면 공개 화면이 404 나는 주소를
|
||||||
* 가리키게 된다.
|
* 가리키게 된다.
|
||||||
@@ -275,15 +282,16 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
|
|||||||
/**
|
/**
|
||||||
* 검증 환경과 재현 조건을 공개 계약의 목록 자리에 담는다.
|
* 검증 환경과 재현 조건을 공개 계약의 목록 자리에 담는다.
|
||||||
*
|
*
|
||||||
* <p>계약은 `environmentSummary` 를 문자열 배열로 두는데 Studio 가 채우는 것은 두 개의 문자열
|
* <p>계약은 `environmentSummary` 를 문자열 배열로 두는데 Studio 가 채우는 것은 두 개의 문자열 (`environment`,
|
||||||
* (`environment`, `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 — 줄 단위로
|
* `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 — 줄 단위로 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다.
|
||||||
* 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다.
|
|
||||||
*/
|
*/
|
||||||
static List<String> environment(ResultSet rs) throws SQLException {
|
static List<String> environment(ResultSet rs) throws SQLException {
|
||||||
List<String> items = new ArrayList<>(2);
|
List<String> items = new ArrayList<>(2);
|
||||||
for (String column : List.of("environment", "reproduction")) {
|
for (String column : List.of("environment", "reproduction")) {
|
||||||
String value = rs.getString(column);
|
String value = rs.getString(column);
|
||||||
if (value != null && !value.isBlank()) items.add(value);
|
if (value != null && !value.isBlank()) {
|
||||||
|
items.add(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return List.copyOf(items);
|
return List.copyOf(items);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -10,6 +10,7 @@ import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
|
|||||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
|
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
|
||||||
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
|
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
|
||||||
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
|
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
|
||||||
|
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
|
||||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
|
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
|
||||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
|
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
|
||||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
|
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
|
||||||
@@ -88,6 +89,7 @@ public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
|
|||||||
rs.getString("next_step"),
|
rs.getString("next_step"),
|
||||||
rs.getString("system_overview_markdown"),
|
rs.getString("system_overview_markdown"),
|
||||||
json.strings(rs.getString("technology_labels")),
|
json.strings(rs.getString("technology_labels")),
|
||||||
|
topicsOf(projectId),
|
||||||
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at"));
|
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at"));
|
||||||
return new ProjectDetailView(
|
return new ProjectDetailView(
|
||||||
rs.getString("navigation_path"),
|
rs.getString("navigation_path"),
|
||||||
@@ -327,4 +329,22 @@ public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
|
|||||||
.query(UUID.class)
|
.query(UUID.class)
|
||||||
.optional();
|
.optional();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트가 다루는 주제. 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||||
|
*
|
||||||
|
* <p>{@code ACTIVE} 인 주제만 내보낸다 — 보관된 주제를 링크로 내보내면 따라간 곳이 비어 있다. 순서는 편집기가 정한 {@code display_order}
|
||||||
|
* 를 그대로 따른다.
|
||||||
|
*/
|
||||||
|
private List<TopicSummaryView> topicsOf(UUID projectId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT t.name, t.slug FROM project_topic pt"
|
||||||
|
+ " JOIN topic t ON t.id = pt.topic_id"
|
||||||
|
+ " WHERE pt.project_id = :projectId AND t.status = 'ACTIVE'"
|
||||||
|
+ " ORDER BY pt.display_order, t.name")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.query((rs, rowNum) -> new TopicSummaryView(rs.getString("name"), rs.getString("slug")))
|
||||||
|
.list();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -180,8 +180,8 @@ public class JdbcPublicSiteQueryAdapter implements PublicSiteQueryPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만
|
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / QUESTION / PROJECT_ACTIVITY /
|
||||||
* 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
|
* RELEASE} 다섯 값을 허용한다. projection 에는 {@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
|
||||||
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
|
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
|
||||||
*
|
*
|
||||||
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
|
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
|
||||||
|
|||||||
+11
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
|||||||
|
|
||||||
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
|
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
|
||||||
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
|
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
|
||||||
|
import dev.caskeleton.application.techlog.publicsite.model.ReferenceRuleView;
|
||||||
import dev.caskeleton.shared.error.MappingException;
|
import dev.caskeleton.shared.error.MappingException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -32,6 +33,16 @@ final class PublicJson {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reference 의 판단 기준. 설계의 컬럼은 {@code {title, body, order}} 배열이다. */
|
||||||
|
List<ReferenceRuleView> referenceRules(String json) {
|
||||||
|
List<ReferenceRuleView> out = new ArrayList<>();
|
||||||
|
for (JsonNode node : array(json)) {
|
||||||
|
out.add(
|
||||||
|
new ReferenceRuleView(node.path("title").asString(""), node.path("body").asString("")));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
List<ContactLinkView> contacts(String json) {
|
List<ContactLinkView> contacts(String json) {
|
||||||
List<ContactLinkView> out = new ArrayList<>();
|
List<ContactLinkView> out = new ArrayList<>();
|
||||||
for (JsonNode node : array(json)) {
|
for (JsonNode node : array(json)) {
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ final class PublicSql {
|
|||||||
* 조건도 한 곳에서 정의한다.
|
* 조건도 한 곳에서 정의한다.
|
||||||
*/
|
*/
|
||||||
static final String LATEST_ENTRY_TYPES =
|
static final String LATEST_ENTRY_TYPES =
|
||||||
" p.resource_type IN ('CASE', 'REFERENCE', 'PROJECT_ACTIVITY') ";
|
" p.resource_type IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_ACTIVITY') ";
|
||||||
|
|
||||||
private PublicSql() {}
|
private PublicSql() {}
|
||||||
|
|
||||||
|
|||||||
+105
-5
@@ -13,11 +13,21 @@ import org.springframework.stereotype.Repository;
|
|||||||
/**
|
/**
|
||||||
* catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4).
|
* catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4).
|
||||||
*
|
*
|
||||||
* <p>RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 이는 계약상 유효한 응답이다.
|
* <p>RELATION 과 EVIDENCE 는 같은 기록을 서로 다른 시점에서 본다. RELATION 은 <em>작성 중</em>에 고르는 것이라 아직 게시되지 않은 작업본까지
|
||||||
|
* 포함한다 — 두 문서를 같이 쓰면서 서로 잇는 것이 정상적인 순서이고, 게시된 것만 보이면 그 순서를 쓸 수 없다. EVIDENCE 는 공개된 기록을 근거로 인용하는 것이므로
|
||||||
|
* 공개 투영에 살아 있는 행만 포함한다. 읽는 사람이 따라갈 수 없는 근거는 근거가 아니다.
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
||||||
|
|
||||||
|
/** 공개 노출 조건. {@code PublicSql.ACTIVE} 와 같은 정의다 — 그쪽은 package-private 이라 여기에 다시 적는다. */
|
||||||
|
private static final String PUBLICLY_VISIBLE =
|
||||||
|
" p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' ";
|
||||||
|
|
||||||
|
/** 연결·근거 대상이 될 수 있는 기록 유형. PROJECT 는 관계가 아니라 소속이라 여기에 없다. */
|
||||||
|
private static final String LINKABLE_TYPES =
|
||||||
|
" ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION') ";
|
||||||
|
|
||||||
private final JdbcClient jdbcClient;
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
|
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
|
||||||
@@ -32,7 +42,8 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case TOPIC -> searchTopics(pattern, limit);
|
case TOPIC -> searchTopics(pattern, limit);
|
||||||
case PROJECT -> searchProjects(pattern, limit);
|
case PROJECT -> searchProjects(pattern, limit);
|
||||||
case RELATION, EVIDENCE -> List.of();
|
case RELATION -> searchRelations(pattern, limit);
|
||||||
|
case EVIDENCE -> searchEvidence(pattern, limit);
|
||||||
};
|
};
|
||||||
return new CatalogPageView(items, null);
|
return new CatalogPageView(items, null);
|
||||||
}
|
}
|
||||||
@@ -57,11 +68,25 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
|||||||
.list();
|
.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트의 공개 경로를 함께 싣는다.
|
||||||
|
*
|
||||||
|
* <p>여기서 {@code publicPath} 를 늘 null 로 두었더니 Decision 의 즉시 미리보기가 어떤 문서에서도 열리지 않았다. Decision 의 공개
|
||||||
|
* 주소는 자기 slug 가 아니라 {@code <프로젝트 경로>/decisions#<slug>} 라, 렌더 모델이 프로젝트 경로를 요구한다 — 그것이 비어 있으면
|
||||||
|
* "PROJECT public path is required" 로 미리보기 전체가 멈춘다. 화면에는 무엇이 모자란지 나오지 않는다.
|
||||||
|
*
|
||||||
|
* <p>게시되지 않은 프로젝트는 여전히 null 이다. 그때는 공개 주소가 실제로 없고, 없는 주소를 지어내면 미리보기가 보여 주는 링크가 게시 뒤에 달라진다.
|
||||||
|
*/
|
||||||
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
|
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
|
||||||
return jdbcClient
|
return jdbcClient
|
||||||
.sql(
|
.sql(
|
||||||
"SELECT id, name, updated_at FROM project "
|
"SELECT pr.id, pr.name, pr.updated_at, p.navigation_path"
|
||||||
+ "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit")
|
+ " FROM project pr"
|
||||||
|
+ " LEFT JOIN public_resource_projection p"
|
||||||
|
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
|
||||||
|
+ " AND "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " WHERE lower(pr.name) LIKE :pattern ORDER BY pr.name LIMIT :limit")
|
||||||
.param("pattern", pattern)
|
.param("pattern", pattern)
|
||||||
.param("limit", limit)
|
.param("limit", limit)
|
||||||
.query(
|
.query(
|
||||||
@@ -71,8 +96,83 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
|||||||
CatalogEntryType.PROJECT,
|
CatalogEntryType.PROJECT,
|
||||||
rs.getString("name"),
|
rs.getString("name"),
|
||||||
"PROJECT",
|
"PROJECT",
|
||||||
null,
|
rs.getString("navigation_path"),
|
||||||
"project:" + rs.getTimestamp("updated_at").toInstant()))
|
"project:" + rs.getTimestamp("updated_at").toInstant()))
|
||||||
.list();
|
.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세 원천 테이블을 하나의 목록으로 합친다. {@code document} 는 {@code document_type} 이 그대로 계약의 {@code kind} 이고, 나머지
|
||||||
|
* 둘은 테이블 자체가 유형을 정한다.
|
||||||
|
*
|
||||||
|
* <p>공개 경로는 게시된 것에만 있으므로 LEFT JOIN 이다. 작업본은 {@code publicPath} 가 null 이고, 이는 "아직 공개 주소가 없다"는 뜻이지
|
||||||
|
* "고를 수 없다"는 뜻이 아니다.
|
||||||
|
*/
|
||||||
|
private List<CatalogEntryView> searchRelations(String pattern, int limit) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT id, kind, label, public_path, updated_at FROM ("
|
||||||
|
+ " SELECT d.id AS id, d.document_type AS kind, d.title AS label,"
|
||||||
|
+ " p.navigation_path AS public_path, d.updated_at AS updated_at"
|
||||||
|
+ " FROM document d"
|
||||||
|
+ " LEFT JOIN public_resource_projection p"
|
||||||
|
+ " ON p.resource_type = d.document_type AND p.resource_id = d.id"
|
||||||
|
+ " AND "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " WHERE lower(d.title) LIKE :pattern"
|
||||||
|
+ " UNION ALL"
|
||||||
|
+ " SELECT q.id, 'QUESTION', q.question,"
|
||||||
|
+ " p.navigation_path, q.updated_at"
|
||||||
|
+ " FROM open_question q"
|
||||||
|
+ " LEFT JOIN public_resource_projection p"
|
||||||
|
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
|
||||||
|
+ " AND "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " WHERE lower(q.question) LIKE :pattern"
|
||||||
|
+ " UNION ALL"
|
||||||
|
+ " SELECT pd.id, 'PROJECT_DECISION', pd.title,"
|
||||||
|
+ " p.navigation_path, pd.updated_at"
|
||||||
|
+ " FROM project_decision pd"
|
||||||
|
+ " LEFT JOIN public_resource_projection p"
|
||||||
|
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = pd.id"
|
||||||
|
+ " AND "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " WHERE lower(pd.title) LIKE :pattern"
|
||||||
|
+ ") linkable ORDER BY label LIMIT :limit")
|
||||||
|
.param("pattern", pattern)
|
||||||
|
.param("limit", limit)
|
||||||
|
.query((rs, rowNum) -> entry(rs, CatalogEntryType.RELATION, "relation"))
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공개 투영이 곧 "읽는 사람이 따라갈 수 있는 기록"의 정의다. 여기서는 그 테이블 하나면 충분하다. */
|
||||||
|
private List<CatalogEntryView> searchEvidence(String pattern, int limit) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT p.resource_id AS id, p.resource_type AS kind, p.title AS label,"
|
||||||
|
+ " p.navigation_path AS public_path, p.updated_at AS updated_at"
|
||||||
|
+ " FROM public_resource_projection p"
|
||||||
|
+ " WHERE "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " AND p.resource_type IN "
|
||||||
|
+ LINKABLE_TYPES
|
||||||
|
+ " AND lower(p.title) LIKE :pattern"
|
||||||
|
+ " ORDER BY label LIMIT :limit")
|
||||||
|
.param("pattern", pattern)
|
||||||
|
.param("limit", limit)
|
||||||
|
.query((rs, rowNum) -> entry(rs, CatalogEntryType.EVIDENCE, "evidence"))
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CatalogEntryView entry(
|
||||||
|
java.sql.ResultSet rs, CatalogEntryType type, String revisionPrefix)
|
||||||
|
throws java.sql.SQLException {
|
||||||
|
return new CatalogEntryView(
|
||||||
|
UUID.fromString(rs.getString("id")),
|
||||||
|
type,
|
||||||
|
rs.getString("label"),
|
||||||
|
rs.getString("kind"),
|
||||||
|
rs.getString("public_path"),
|
||||||
|
revisionPrefix + ":" + rs.getTimestamp("updated_at").toInstant());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
@@ -164,9 +164,56 @@ public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
|
|||||||
// 18. Document publish metadata
|
// 18. Document publish metadata
|
||||||
markSourcePublished(request);
|
markSourcePublished(request);
|
||||||
|
|
||||||
|
// 19. 프로젝트 활동 로그
|
||||||
|
recordProjectActivity(request);
|
||||||
|
|
||||||
return result(publicationId, eventId);
|
return result(publicationId, eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 활동은 손으로 적는 것이 아니라 게시가 남기는 로그다.
|
||||||
|
*
|
||||||
|
* <p>한동안 이 줄을 Studio 에서 직접 써야 했다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다.
|
||||||
|
* 게시가 곧 사건이므로 게시가 기록한다.
|
||||||
|
*
|
||||||
|
* <p>{@code operation_key} 로 문서마다 한 줄만 남긴다. 재게시는 새로 올린 것이 아니라 같은 글을 고친 것이므로 타임라인에 다시 나타나지 않아야 한다
|
||||||
|
* — {@code uq_project_activity_operation_key} 가 그것을 보장하고, 여기서는 충돌을 무시한다.
|
||||||
|
*
|
||||||
|
* <p>프로젝트에 매달리지 않은 기록은 남길 자리가 없다. 그때는 아무것도 하지 않는다.
|
||||||
|
*/
|
||||||
|
private void recordProjectActivity(PublishRequest request) {
|
||||||
|
if (request.projectId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
|
||||||
|
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
|
||||||
|
+ " operation_key, created_by, updated_by)"
|
||||||
|
+ " VALUES (:id, :projectId, :type, :title, '', 'PUBLIC', 'AUTO',"
|
||||||
|
+ " :resourceType, :resourceId, now(), :operationKey, :actor, :actor)"
|
||||||
|
+ " ON CONFLICT (project_id, operation_key) DO NOTHING")
|
||||||
|
.param("id", idGenerator.get())
|
||||||
|
.param("projectId", request.projectId())
|
||||||
|
.param("type", activityTypeOf(request.kind()))
|
||||||
|
.param("title", request.title())
|
||||||
|
.param("resourceType", request.kind().name())
|
||||||
|
.param("resourceId", request.documentId())
|
||||||
|
.param("operationKey", "publication:" + request.documentId())
|
||||||
|
.param("actor", request.principal())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code project_activity_activity_type_check} 가 허용하는 값으로 옮긴다. */
|
||||||
|
private static String activityTypeOf(RecordKind kind) {
|
||||||
|
return switch (kind) {
|
||||||
|
case CASE -> "CASE_PUBLISHED";
|
||||||
|
case REFERENCE -> "REFERENCE_PUBLISHED";
|
||||||
|
case QUESTION -> "QUESTION_OPENED";
|
||||||
|
case PROJECT_DECISION -> "DECISION_ACCEPTED";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PublishResultView unpublish(UnpublishRequest request) {
|
public PublishResultView unpublish(UnpublishRequest request) {
|
||||||
requireTransaction("unpublish");
|
requireTransaction("unpublish");
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
-- 프로젝트 활동을 게시 로그로 되돌린다.
|
||||||
|
--
|
||||||
|
-- 이 표는 원래 손으로 적는 자리였다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로
|
||||||
|
-- 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 이제 게시가 이 줄을 남긴다
|
||||||
|
-- (JdbcPublicationWriterAdapter 19단계).
|
||||||
|
--
|
||||||
|
-- 이 마이그레이션은 그 규칙을 이미 게시된 것들에 소급 적용한다. 게시는 있었는데 로그가 없는
|
||||||
|
-- 상태를 남겨 두면, 이 변경 이전에 올린 글은 타임라인에서 영영 빠진다.
|
||||||
|
--
|
||||||
|
-- occurred_at 은 최초 PUBLISHED 사건의 시각이다 -- now() 를 쓰면 옛 게시가 전부 오늘 올린 것처럼
|
||||||
|
-- 보인다. operation_key 는 애플리케이션이 쓰는 것과 같은 규칙이라, 나중에 같은 문서를 재게시해도
|
||||||
|
-- 줄이 늘지 않는다.
|
||||||
|
INSERT INTO project_activity (
|
||||||
|
id, project_id, activity_type, title, summary, visibility, origin,
|
||||||
|
related_resource_type, related_resource_id, occurred_at,
|
||||||
|
operation_key, created_by, updated_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
link.project_id,
|
||||||
|
CASE publication.source_kind
|
||||||
|
WHEN 'CASE' THEN 'CASE_PUBLISHED'
|
||||||
|
WHEN 'REFERENCE' THEN 'REFERENCE_PUBLISHED'
|
||||||
|
WHEN 'QUESTION' THEN 'QUESTION_OPENED'
|
||||||
|
ELSE 'DECISION_ACCEPTED'
|
||||||
|
END,
|
||||||
|
projection.title,
|
||||||
|
'',
|
||||||
|
'PUBLIC',
|
||||||
|
'AUTO',
|
||||||
|
publication.source_kind,
|
||||||
|
publication.source_id,
|
||||||
|
first_published.occurred_at,
|
||||||
|
'publication:' || publication.source_id,
|
||||||
|
'system:migration',
|
||||||
|
'system:migration'
|
||||||
|
FROM publication
|
||||||
|
JOIN public_resource_project_link link
|
||||||
|
ON link.resource_type = publication.source_kind
|
||||||
|
AND link.resource_id = publication.source_id
|
||||||
|
AND link.relation_type = 'PRIMARY'
|
||||||
|
JOIN public_resource_projection projection
|
||||||
|
ON projection.resource_type = publication.source_kind
|
||||||
|
AND projection.resource_id = publication.source_id
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT min(event.occurred_at) AS occurred_at
|
||||||
|
FROM publication_event event
|
||||||
|
WHERE event.publication_id = publication.publication_id
|
||||||
|
AND event.event_type = 'PUBLISHED'
|
||||||
|
) first_published ON true
|
||||||
|
WHERE publication.status = 'PUBLISHED'
|
||||||
|
AND first_published.occurred_at IS NOT NULL
|
||||||
|
ON CONFLICT (project_id, operation_key) DO NOTHING;
|
||||||
+9
-1
@@ -43,8 +43,16 @@ class PostgreSqlMigrationIntegrationTest {
|
|||||||
.load()
|
.load()
|
||||||
.migrate();
|
.migrate();
|
||||||
|
|
||||||
|
/*
|
||||||
|
TechLog 가 들어오면서 8·9·10 이 붙었는데 이 목록은 7 에서 멈춰 있었다. 마이그레이션을 더한
|
||||||
|
사람이 여기를 같이 고치지 않으면 이 테스트만 빨개지고, 그 빨간색은 "스키마가 잘못됐다" 가
|
||||||
|
아니라 "목록을 안 고쳤다" 를 뜻한다 — 정확히 그 상태로 두 번 지나갔다.
|
||||||
|
|
||||||
|
목록을 고정해 두는 이유는 남아 있다: 마이그레이션이 순서대로, 빠짐없이 적용되는지 확인한다.
|
||||||
|
그래서 개수를 세는 것이 아니라 버전을 그대로 적는다.
|
||||||
|
*/
|
||||||
assertThat(appliedVersions(postgres, "flyway_schema_history"))
|
assertThat(appliedVersions(postgres, "flyway_schema_history"))
|
||||||
.containsExactly("1", "3", "4", "5", "6", "7");
|
.containsExactly("1", "3", "4", "5", "6", "7", "8", "9", "10");
|
||||||
|
|
||||||
Flyway coreStream =
|
Flyway coreStream =
|
||||||
Flyway.configure()
|
Flyway.configure()
|
||||||
|
|||||||
+419
@@ -4,6 +4,18 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
import com.zaxxer.hikari.HikariConfig;
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
import com.zaxxer.hikari.HikariDataSource;
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapter;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.flywaydb.core.Flyway;
|
import org.flywaydb.core.Flyway;
|
||||||
import org.junit.jupiter.api.AfterAll;
|
import org.junit.jupiter.api.AfterAll;
|
||||||
@@ -12,6 +24,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
import org.testcontainers.DockerClientFactory;
|
import org.testcontainers.DockerClientFactory;
|
||||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업본 삭제 SQL 을 실제 PostgreSQL 위에서 돌린다.
|
* 작업본 삭제 SQL 을 실제 PostgreSQL 위에서 돌린다.
|
||||||
@@ -32,6 +45,10 @@ class ManagementPersistenceIntegrationTest {
|
|||||||
private static HikariDataSource dataSource;
|
private static HikariDataSource dataSource;
|
||||||
private static JdbcClient jdbcClient;
|
private static JdbcClient jdbcClient;
|
||||||
private static JdbcDocumentDeletionAdapter deletion;
|
private static JdbcDocumentDeletionAdapter deletion;
|
||||||
|
private static JdbcProjectRepositoryAdapter projects;
|
||||||
|
private static JdbcHomeFocusConfigAdapter homeFocus;
|
||||||
|
private static JdbcCatalogQueryAdapter catalog;
|
||||||
|
private static JdbcProjectActivityRepositoryAdapter activities;
|
||||||
|
|
||||||
@BeforeAll
|
@BeforeAll
|
||||||
static void migrate() {
|
static void migrate() {
|
||||||
@@ -61,6 +78,10 @@ class ManagementPersistenceIntegrationTest {
|
|||||||
|
|
||||||
jdbcClient = JdbcClient.create(dataSource);
|
jdbcClient = JdbcClient.create(dataSource);
|
||||||
deletion = new JdbcDocumentDeletionAdapter(jdbcClient);
|
deletion = new JdbcDocumentDeletionAdapter(jdbcClient);
|
||||||
|
projects = new JdbcProjectRepositoryAdapter(jdbcClient, new ObjectMapper());
|
||||||
|
homeFocus = new JdbcHomeFocusConfigAdapter(jdbcClient);
|
||||||
|
catalog = new JdbcCatalogQueryAdapter(jdbcClient);
|
||||||
|
activities = new JdbcProjectActivityRepositoryAdapter(jdbcClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterAll
|
@AfterAll
|
||||||
@@ -396,4 +417,402 @@ class ManagementPersistenceIntegrationTest {
|
|||||||
assertThat(deletion.deleteDecision(id, 0L)).isEqualTo(1);
|
assertThat(deletion.deleteDecision(id, 0L)).isEqualTo(1);
|
||||||
assertThat(deletion.findDecision(projectId, id)).isEmpty();
|
assertThat(deletion.findDecision(projectId, id)).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 프로젝트 게시
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 게시는 원본 상태·공개 투영·canonical route 세 곳을 함께 세운다. 어느 하나가 빠지면 증상이 제각각이라 (투영이 없으면 화면이 조용히 비고,
|
||||||
|
* route 가 없으면 주소만 404) 세 곳을 한꺼번에 확인한다.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void publishingAProjectRaisesTheProjection() {
|
||||||
|
ProjectEditView draft = projects.create(new CreateProjectCommand("Publish Me", "test"));
|
||||||
|
jdbcClient
|
||||||
|
.sql("UPDATE project SET slug = 'publish-me' WHERE id = :id")
|
||||||
|
.param("id", draft.id())
|
||||||
|
.update();
|
||||||
|
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
|
||||||
|
|
||||||
|
ProjectEditView published =
|
||||||
|
projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow();
|
||||||
|
|
||||||
|
assertThat(published.targetVisibility()).isEqualTo("PUBLIC");
|
||||||
|
assertThat(published.firstPublishedAt()).isNotNull();
|
||||||
|
assertThat(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT navigation_path FROM public_resource_projection"
|
||||||
|
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id"
|
||||||
|
+ " AND publication_state = 'ACTIVE' AND visibility = 'PUBLIC'")
|
||||||
|
.param("id", loaded.id())
|
||||||
|
.query(String.class)
|
||||||
|
.optional())
|
||||||
|
.contains("/projects/publish-me");
|
||||||
|
assertThat(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT route_role FROM public_route"
|
||||||
|
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
|
||||||
|
.param("id", loaded.id())
|
||||||
|
.query(String.class)
|
||||||
|
.list())
|
||||||
|
.containsExactly("CANONICAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 취소는 투영 행을 지우지 않고 내린다 — 지우면 다시 게시할 때 이력이 끊긴다. */
|
||||||
|
@Test
|
||||||
|
void unpublishingWithdrawsTheProjectionInsteadOfDeletingIt() {
|
||||||
|
ProjectEditView draft = projects.create(new CreateProjectCommand("Withdraw Me", "test"));
|
||||||
|
jdbcClient
|
||||||
|
.sql("UPDATE project SET slug = 'withdraw-me' WHERE id = :id")
|
||||||
|
.param("id", draft.id())
|
||||||
|
.update();
|
||||||
|
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
|
||||||
|
ProjectEditView published =
|
||||||
|
projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow();
|
||||||
|
|
||||||
|
assertThat(projects.unpublish(published.id(), published.version(), "test")).isPresent();
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT publication_state FROM public_resource_projection"
|
||||||
|
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
|
||||||
|
.param("id", published.id())
|
||||||
|
.query(String.class)
|
||||||
|
.optional())
|
||||||
|
.contains("WITHDRAWN");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 낙관적 잠금. 어긋난 버전으로는 아무것도 세우지 않는다. */
|
||||||
|
@Test
|
||||||
|
void publishingWithAStaleVersionChangesNothing() {
|
||||||
|
ProjectEditView draft = projects.create(new CreateProjectCommand("Stale", "test"));
|
||||||
|
jdbcClient
|
||||||
|
.sql("UPDATE project SET slug = 'stale' WHERE id = :id")
|
||||||
|
.param("id", draft.id())
|
||||||
|
.update();
|
||||||
|
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(projects.publish(loaded.id(), loaded.version() + 99, "PUBLIC", "test")).isEmpty();
|
||||||
|
assertThat(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT count(*) FROM public_resource_projection"
|
||||||
|
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
|
||||||
|
.param("id", loaded.id())
|
||||||
|
.query(Integer.class)
|
||||||
|
.single())
|
||||||
|
.isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 홈 focus
|
||||||
|
|
||||||
|
/** V9 가 빈 행 하나를 넣어 둔다. 조회가 실패하면 그 전제가 깨진 것이다. */
|
||||||
|
@Test
|
||||||
|
void homeFocusStartsAsASingleEmptyRow() {
|
||||||
|
HomeFocusConfigView current = homeFocus.load();
|
||||||
|
assertThat(current.currentProjectId()).isNull();
|
||||||
|
assertThat(current.openQuestionId()).isNull();
|
||||||
|
assertThat(current.recentDecisionId()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void homeFocusRoundTripsAndRefusesAStaleVersion() {
|
||||||
|
ProjectEditView project = projects.create(new CreateProjectCommand("Focus Target", "test"));
|
||||||
|
HomeFocusConfigView before = homeFocus.load();
|
||||||
|
|
||||||
|
HomeFocusConfigView saved =
|
||||||
|
homeFocus
|
||||||
|
.update(
|
||||||
|
new UpdateHomeFocusCommand(
|
||||||
|
before.version(), "CURRENT_WORK", project.id(), null, null, "test"))
|
||||||
|
.orElseThrow();
|
||||||
|
|
||||||
|
assertThat(saved.currentProjectId()).isEqualTo(project.id());
|
||||||
|
assertThat(saved.defaultType()).isEqualTo("CURRENT_WORK");
|
||||||
|
assertThat(saved.version()).isEqualTo(before.version() + 1);
|
||||||
|
assertThat(homeFocus.projectExists(project.id())).isTrue();
|
||||||
|
assertThat(homeFocus.projectExists(UUID.randomUUID())).isFalse();
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
homeFocus.update(
|
||||||
|
new UpdateHomeFocusCommand(before.version(), null, null, null, null, "test")))
|
||||||
|
.isEmpty();
|
||||||
|
|
||||||
|
// null 은 "이 슬롯을 비운다" 다 — 부분 수정이 아니라 전체 교체.
|
||||||
|
HomeFocusConfigView cleared =
|
||||||
|
homeFocus
|
||||||
|
.update(new UpdateHomeFocusCommand(saved.version(), null, null, null, null, "test"))
|
||||||
|
.orElseThrow();
|
||||||
|
assertThat(cleared.currentProjectId()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- catalog
|
||||||
|
|
||||||
|
/** RELATION 은 작성 중에 고르는 것이므로 게시 여부와 무관하게 작업본까지 포함한다. */
|
||||||
|
@Test
|
||||||
|
void relationCatalogIncludesUnpublishedWorkingCopies() {
|
||||||
|
UUID caseId = insertCase("Relation Target Case");
|
||||||
|
UUID questionId = insertQuestion("Relation Target Question");
|
||||||
|
|
||||||
|
CatalogPageView page = catalog.search(CatalogEntryType.RELATION, null, null, 50);
|
||||||
|
|
||||||
|
assertThat(page.items()).extracting("id").contains(caseId, questionId);
|
||||||
|
assertThat(page.items())
|
||||||
|
.filteredOn(entry -> entry.id().equals(caseId))
|
||||||
|
.singleElement()
|
||||||
|
.satisfies(
|
||||||
|
entry -> {
|
||||||
|
assertThat(entry.kind()).isEqualTo("CASE");
|
||||||
|
assertThat(entry.publicPath()).isNull();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다. */
|
||||||
|
@Test
|
||||||
|
void evidenceCatalogIncludesOnlyPubliclyVisibleRecords() {
|
||||||
|
UUID publishedId = insertCase("Evidence Published");
|
||||||
|
UUID draftId = insertCase("Evidence Draft");
|
||||||
|
UUID withdrawnId = insertCase("Evidence Withdrawn");
|
||||||
|
insertProjection("CASE", publishedId, "ACTIVE", "Evidence Published");
|
||||||
|
insertProjection("CASE", withdrawnId, "WITHDRAWN", "Evidence Withdrawn");
|
||||||
|
|
||||||
|
CatalogPageView page = catalog.search(CatalogEntryType.EVIDENCE, "evidence", null, 50);
|
||||||
|
|
||||||
|
assertThat(page.items()).extracting("id").contains(publishedId);
|
||||||
|
assertThat(page.items()).extracting("id").doesNotContain(draftId, withdrawnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검색어는 대소문자를 가리지 않는다. */
|
||||||
|
@Test
|
||||||
|
void relationCatalogSearchIsCaseInsensitive() {
|
||||||
|
UUID caseId = insertCase("MixedCase Needle");
|
||||||
|
|
||||||
|
assertThat(catalog.search(CatalogEntryType.RELATION, "mixedcase", null, 50).items())
|
||||||
|
.extracting("id")
|
||||||
|
.contains(caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 프로젝트 활동
|
||||||
|
|
||||||
|
private static ProjectActivityView addActivity(UUID projectId, String title, String visibility) {
|
||||||
|
return activities.create(
|
||||||
|
new CreateProjectActivityCommand(
|
||||||
|
projectId,
|
||||||
|
0L,
|
||||||
|
"MILESTONE_REACHED",
|
||||||
|
title,
|
||||||
|
"요약",
|
||||||
|
visibility,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Instant.parse("2026-08-23T00:00:00Z"),
|
||||||
|
"test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 프로젝트 화면의 "활동" 은 이 테이블을 투영 없이 직접 읽는다. 그래서 여기서 만든 줄이 곧 그 화면이고, 편집기가 읽는 목록과 화면이 읽는 목록이 같은 정렬·같은
|
||||||
|
* 조건을 써야 한다.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void createdActivitiesAreVisibleToTheEditorAndToThePublicQuery() {
|
||||||
|
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Host", "test"));
|
||||||
|
ProjectActivityView pub = addActivity(project.id(), "공개 활동", "PUBLIC");
|
||||||
|
addActivity(project.id(), "비공개 활동", "PRIVATE");
|
||||||
|
|
||||||
|
assertThat(pub.origin()).isEqualTo("MANUAL");
|
||||||
|
assertThat(activities.listByProject(project.id())).hasSize(2);
|
||||||
|
|
||||||
|
// 공개 조회가 쓰는 조건 그대로.
|
||||||
|
assertThat(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT title FROM project_activity"
|
||||||
|
+ " WHERE project_id = :id AND visibility = 'PUBLIC'"
|
||||||
|
+ " ORDER BY occurred_at DESC, id DESC")
|
||||||
|
.param("id", project.id())
|
||||||
|
.query(String.class)
|
||||||
|
.list())
|
||||||
|
.containsExactly("공개 활동");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activityUpdatesRoundTripAndRefuseAStaleVersion() {
|
||||||
|
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Edit", "test"));
|
||||||
|
ProjectActivityView created = addActivity(project.id(), "처음 제목", "PRIVATE");
|
||||||
|
|
||||||
|
ProjectActivityView updated =
|
||||||
|
activities
|
||||||
|
.update(
|
||||||
|
new UpdateProjectActivityCommand(
|
||||||
|
project.id(),
|
||||||
|
created.id(),
|
||||||
|
created.version(),
|
||||||
|
"고친 제목",
|
||||||
|
"고친 요약",
|
||||||
|
"PUBLIC",
|
||||||
|
Instant.parse("2026-08-24T00:00:00Z"),
|
||||||
|
"test"))
|
||||||
|
.orElseThrow();
|
||||||
|
|
||||||
|
assertThat(updated.title()).isEqualTo("고친 제목");
|
||||||
|
assertThat(updated.visibility()).isEqualTo("PUBLIC");
|
||||||
|
assertThat(updated.version()).isEqualTo(created.version() + 1);
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
activities.update(
|
||||||
|
new UpdateProjectActivityCommand(
|
||||||
|
project.id(),
|
||||||
|
created.id(),
|
||||||
|
created.version(),
|
||||||
|
"다시",
|
||||||
|
null,
|
||||||
|
"PUBLIC",
|
||||||
|
Instant.parse("2026-08-24T00:00:00Z"),
|
||||||
|
"test")))
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 경로가 프로젝트와 활동을 함께 요구하므로 저장소도 둘을 함께 본다 — 남의 프로젝트 줄을 고치거나 지울 수 없어야 한다. */
|
||||||
|
@Test
|
||||||
|
void activityWritesAreScopedToTheirOwnProject() {
|
||||||
|
ProjectEditView mine = projects.create(new CreateProjectCommand("Mine", "test"));
|
||||||
|
ProjectEditView theirs = projects.create(new CreateProjectCommand("Theirs", "test"));
|
||||||
|
ProjectActivityView activity = addActivity(theirs.id(), "남의 활동", "PUBLIC");
|
||||||
|
|
||||||
|
assertThat(activities.find(mine.id(), activity.id())).isEmpty();
|
||||||
|
assertThat(activities.delete(mine.id(), activity.id(), activity.version())).isZero();
|
||||||
|
assertThat(activities.find(theirs.id(), activity.id())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletingAnActivityHonoursTheExpectedVersion() {
|
||||||
|
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Delete", "test"));
|
||||||
|
ProjectActivityView activity = addActivity(project.id(), "지울 활동", "PUBLIC");
|
||||||
|
|
||||||
|
assertThat(activities.delete(project.id(), activity.id(), activity.version() + 9)).isZero();
|
||||||
|
assertThat(activities.delete(project.id(), activity.id(), activity.version())).isEqualTo(1);
|
||||||
|
assertThat(activities.listByProject(project.id())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 프로젝트 주제
|
||||||
|
|
||||||
|
private static UUID insertTopic(String name, String slug) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by)"
|
||||||
|
+ " VALUES (:id, :name, :normalized, :slug, 'test', 'test')")
|
||||||
|
.param("id", id)
|
||||||
|
.param("name", name)
|
||||||
|
.param("normalized", name.toLowerCase(java.util.Locale.ROOT))
|
||||||
|
.param("slug", slug)
|
||||||
|
.update();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UpdateProjectCommand updateWithTopics(
|
||||||
|
ProjectEditView project, java.util.List<UUID> topicIds) {
|
||||||
|
return new UpdateProjectCommand(
|
||||||
|
project.id(),
|
||||||
|
project.version(),
|
||||||
|
project.name(),
|
||||||
|
project.slug() == null ? "project-" + project.id() : project.slug(),
|
||||||
|
"목적 한 줄",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
project.phase(),
|
||||||
|
"지금 목표",
|
||||||
|
"다음 작업",
|
||||||
|
java.util.List.of("Keycloak"),
|
||||||
|
topicIds,
|
||||||
|
"PRIVATE",
|
||||||
|
null,
|
||||||
|
"test");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 화면의 "주요 주제" 는 이 링크 테이블을 읽는다. 화면은 처음부터 주제를 읽고 있었지만 저장할 경로가 없어 늘 비어 있었다 — 여기서 확인하는 것은 고른 순서가
|
||||||
|
* 그대로 남는가와, 통째로 교체되는가다.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void projectTopicsRoundTripInTheChosenOrder() {
|
||||||
|
ProjectEditView project = projects.create(new CreateProjectCommand("Topic Host", "test"));
|
||||||
|
UUID first = insertTopic("주제 하나", "topic-one");
|
||||||
|
UUID second = insertTopic("주제 둘", "topic-two");
|
||||||
|
|
||||||
|
ProjectEditView saved =
|
||||||
|
projects.update(updateWithTopics(project, java.util.List.of(second, first))).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(saved.topicIds()).containsExactly(second, first);
|
||||||
|
assertThat(saved.currentObjective()).isEqualTo("지금 목표");
|
||||||
|
assertThat(saved.nextStep()).isEqualTo("다음 작업");
|
||||||
|
|
||||||
|
// 공개 조회가 쓰는 조건 그대로 — ACTIVE 인 주제만, display_order 순서로.
|
||||||
|
assertThat(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT t.name FROM project_topic pt"
|
||||||
|
+ " JOIN topic t ON t.id = pt.topic_id"
|
||||||
|
+ " WHERE pt.project_id = :id AND t.status = 'ACTIVE'"
|
||||||
|
+ " ORDER BY pt.display_order, t.name")
|
||||||
|
.param("id", project.id())
|
||||||
|
.query(String.class)
|
||||||
|
.list())
|
||||||
|
.containsExactly("주제 둘", "주제 하나");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 부분 수정이 아니라 전체 교체다 — 빈 목록은 "주제를 전부 뗀다" 는 뜻이어야 한다. */
|
||||||
|
@Test
|
||||||
|
void savingAnEmptyTopicListDetachesEveryTopic() {
|
||||||
|
ProjectEditView project = projects.create(new CreateProjectCommand("Detach Host", "test"));
|
||||||
|
UUID topic = insertTopic("뗄 주제", "topic-detach");
|
||||||
|
ProjectEditView withTopic =
|
||||||
|
projects.update(updateWithTopics(project, java.util.List.of(topic))).orElseThrow();
|
||||||
|
assertThat(withTopic.topicIds()).containsExactly(topic);
|
||||||
|
|
||||||
|
ProjectEditView cleared =
|
||||||
|
projects.update(updateWithTopics(withTopic, java.util.List.of())).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(cleared.topicIds()).isEmpty();
|
||||||
|
assertThat(
|
||||||
|
jdbcClient
|
||||||
|
.sql("SELECT count(*) FROM project_topic WHERE project_id = :id")
|
||||||
|
.param("id", project.id())
|
||||||
|
.query(Integer.class)
|
||||||
|
.single())
|
||||||
|
.isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 편집기가 Decision 을 미리 그리려면 이 목록이 프로젝트의 공개 경로를
|
||||||
|
* 알고 있어야 한다 — 한동안 늘 null 이었고, 그동안 Decision 은 어떤 문서에서도 즉시 미리보기가 열리지 않았다.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void projectCatalogCarriesThePublicPathOfPublishedProjectsOnly() {
|
||||||
|
ProjectEditView open = projects.create(new CreateProjectCommand("Catalog Published", "test"));
|
||||||
|
jdbcClient
|
||||||
|
.sql("UPDATE project SET slug = 'catalog-published' WHERE id = :id")
|
||||||
|
.param("id", open.id())
|
||||||
|
.update();
|
||||||
|
ProjectEditView loaded = projects.find(open.id()).orElseThrow();
|
||||||
|
assertThat(projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test")).isPresent();
|
||||||
|
|
||||||
|
ProjectEditView hidden = projects.create(new CreateProjectCommand("Catalog Hidden", "test"));
|
||||||
|
|
||||||
|
var entries = catalog.search(CatalogEntryType.PROJECT, "catalog", null, 50).items();
|
||||||
|
|
||||||
|
assertThat(entries)
|
||||||
|
.filteredOn(entry -> entry.id().equals(open.id()))
|
||||||
|
.singleElement()
|
||||||
|
.satisfies(
|
||||||
|
entry -> assertThat(entry.publicPath()).isEqualTo("/projects/catalog-published"));
|
||||||
|
// 게시되지 않은 프로젝트는 공개 주소가 실제로 없다. 지어내면 미리보기가 보여 준 링크가 게시 뒤에 달라진다.
|
||||||
|
assertThat(entries)
|
||||||
|
.filteredOn(entry -> entry.id().equals(hidden.id()))
|
||||||
|
.singleElement()
|
||||||
|
.satisfies(entry -> assertThat(entry.publicPath()).isNull());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-5
@@ -206,12 +206,21 @@ class PublicSitePersistenceIntegrationTest {
|
|||||||
.noneMatch(entry -> entry.title().contains("숨김"));
|
.noneMatch(entry -> entry.title().contains("숨김"));
|
||||||
assertThat(view.latestEntries())
|
assertThat(view.latestEntries())
|
||||||
.as(
|
.as(
|
||||||
"계약 LatestEntry.entryType 은 네 값만 허용한다 — projection 의 QUESTION/PROJECT 등이 섞이면"
|
"계약 LatestEntry.entryType 이 허용하는 값만 나와야 한다 — projection 의 PROJECT/PROJECT_DECISION"
|
||||||
+ " 응답 매퍼가 계약 밖 값을 만나 500 이 된다")
|
+ " 등이 섞이면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다")
|
||||||
.extracting("entryType")
|
.extracting("entryType")
|
||||||
.containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY")
|
.containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY")
|
||||||
.allSatisfy(
|
.allSatisfy(
|
||||||
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
|
type ->
|
||||||
|
assertThat(type)
|
||||||
|
.isIn("CASE", "REFERENCE", "QUESTION", "PROJECT_ACTIVITY", "RELEASE"));
|
||||||
|
assertThat(view.latestEntries())
|
||||||
|
.as("게시한 Open Question 도 최근 기록에 나와야 한다 — 목록에서 빠지면 게시한 사실이 어디에도 보이지 않는다")
|
||||||
|
.anySatisfy(
|
||||||
|
entry -> {
|
||||||
|
assertThat(entry.entryType()).isEqualTo("QUESTION");
|
||||||
|
assertThat(entry.path()).isEqualTo("/questions/reprocessing-latency");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -397,10 +406,16 @@ class PublicSitePersistenceIntegrationTest {
|
|||||||
assertThat(view.relatedProjects()).extracting("title").contains("Tech Log");
|
assertThat(view.relatedProjects()).extracting("title").contains("Tech Log");
|
||||||
assertThat(view.latestRecords()).isNotEmpty();
|
assertThat(view.latestRecords()).isNotEmpty();
|
||||||
assertThat(view.latestRecords())
|
assertThat(view.latestRecords())
|
||||||
.as("주제 상세의 최신 기록도 계약의 entryType 네 값을 벗어나면 안 된다")
|
.as("주제 상세의 최신 기록도 계약의 entryType 을 벗어나면 안 된다")
|
||||||
.extracting("entryType")
|
.extracting("entryType")
|
||||||
.allSatisfy(
|
.allSatisfy(
|
||||||
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
|
type ->
|
||||||
|
assertThat(type)
|
||||||
|
.isIn("CASE", "REFERENCE", "QUESTION", "PROJECT_ACTIVITY", "RELEASE"));
|
||||||
|
assertThat(view.latestRecords())
|
||||||
|
.as("주제와 홈은 같은 목록 의미를 쓴다 — 홈에 나오는 Open Question 이 여기서 빠지면 두 화면이 어긋난다")
|
||||||
|
.extracting("entryType")
|
||||||
|
.contains("QUESTION");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+57
@@ -1,25 +1,35 @@
|
|||||||
package dev.caskeleton.bootstrap.techlog;
|
package dev.caskeleton.bootstrap.techlog;
|
||||||
|
|
||||||
import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort;
|
import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
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.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.ArchiveReleaseUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.CreateProjectActivityUseCase;
|
||||||
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.CreateReleaseUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase;
|
import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.DeleteProjectActivityUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.DeleteProjectDecisionUseCase;
|
import dev.caskeleton.application.techlog.management.service.DeleteProjectDecisionUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
|
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase;
|
import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase;
|
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.GetHomeFocusUseCase;
|
||||||
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.GetReleaseForEditUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioProjectActivitiesUseCase;
|
||||||
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.ListStudioReleasesUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
|
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase;
|
||||||
import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase;
|
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.UnpublishProjectUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UpdateProjectActivityUseCase;
|
||||||
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.techlog.management.service.UpdateReleaseUseCase;
|
||||||
import dev.caskeleton.application.transaction.TransactionPort;
|
import dev.caskeleton.application.transaction.TransactionPort;
|
||||||
@@ -128,4 +138,51 @@ public class TechLogManagementConfig {
|
|||||||
DocumentDeletionPort documents, TransactionPort tx) {
|
DocumentDeletionPort documents, TransactionPort tx) {
|
||||||
return new DeleteProjectDecisionUseCase(documents, tx);
|
return new DeleteProjectDecisionUseCase(documents, tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
PublishProjectUseCase publishProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
|
||||||
|
return new PublishProjectUseCase(projects, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
UnpublishProjectUseCase unpublishProjectUseCase(
|
||||||
|
ProjectRepositoryPort projects, TransactionPort tx) {
|
||||||
|
return new UnpublishProjectUseCase(projects, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
GetHomeFocusUseCase getHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
|
||||||
|
return new GetHomeFocusUseCase(config, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
UpdateHomeFocusUseCase updateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
|
||||||
|
return new UpdateHomeFocusUseCase(config, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
ListStudioProjectActivitiesUseCase listStudioProjectActivitiesUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities, TransactionPort tx) {
|
||||||
|
return new ListStudioProjectActivitiesUseCase(activities, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
CreateProjectActivityUseCase createProjectActivityUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities,
|
||||||
|
ProjectRepositoryPort projects,
|
||||||
|
TransactionPort tx) {
|
||||||
|
return new CreateProjectActivityUseCase(activities, projects, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
UpdateProjectActivityUseCase updateProjectActivityUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities, TransactionPort tx) {
|
||||||
|
return new UpdateProjectActivityUseCase(activities, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
DeleteProjectActivityUseCase deleteProjectActivityUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities, TransactionPort tx) {
|
||||||
|
return new DeleteProjectActivityUseCase(activities, tx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,14 @@ spring:
|
|||||||
# true | false (Java 21 virtual threads for Tomcat request handlers)
|
# true | false (Java 21 virtual threads for Tomcat request handlers)
|
||||||
enabled: ${SPRING_THREADS_VIRTUAL_ENABLED}
|
enabled: ${SPRING_THREADS_VIRTUAL_ENABLED}
|
||||||
servlet:
|
servlet:
|
||||||
|
session:
|
||||||
|
# Studio 작성자는 한 기록을 여러 번 저장하며 오래 머문다. Spring 기본 30분 유휴 만료는 그
|
||||||
|
# 리듬보다 짧아 작성 도중 로그인 화면으로 돌아가는 일이 잦았다 — 저장하지 않은 편집이
|
||||||
|
# 있으면 그 시점에 잃는다.
|
||||||
|
#
|
||||||
|
# 값은 배포가 정한다. 늘릴수록 훔친 session cookie가 유효한 창도 같이 늘어나므로,
|
||||||
|
# Keycloak realm 의 SSO idle 과 따로 놀지 않게 함께 맞춘다.
|
||||||
|
timeout: ${APP_SESSION_TIMEOUT:8h}
|
||||||
multipart:
|
multipart:
|
||||||
# feature-api-contract-baseline D8: bound request body size so an oversized
|
# feature-api-contract-baseline D8: bound request body size so an oversized
|
||||||
# upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via
|
# upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via
|
||||||
|
|||||||
+63
@@ -2158,4 +2158,67 @@ class CleanArchitectureTest {
|
|||||||
.filter(value -> value instanceof Number number && number.intValue() == SqlTypes.UUID)
|
.filter(value -> value instanceof Number number && number.intValue() == SqlTypes.UUID)
|
||||||
.isPresent();
|
.isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring 이 생성자를 고를 수 있어야 한다.
|
||||||
|
*
|
||||||
|
* <p>이 규칙은 사고 하나에서 나왔다. 한 어댑터가 생성자를 둘 갖고 있었고 — 하나는 운영용, 하나는 테스트가 id 생성기를 넣기 위한 것 — 둘 중 어느 것에도
|
||||||
|
* {@code @Autowired} 가 없었다. 컴포넌트 스캔은 고르지 못하고 기본 생성자를 찾다가 실패한다. 컴파일도, 단위 테스트도, 실제 PostgreSQL 위에서
|
||||||
|
* 도는 통합 테스트도 전부 통과했다. 그 어느 것도 컨텍스트를 띄우지 않기 때문이다. 운영에서 기동만 못 했다.
|
||||||
|
*
|
||||||
|
* <p>그래서 여기서 막는다: 스캔되는 스프링 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 {@code @Autowired} 가 붙어 있어야 한다.
|
||||||
|
*/
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule SPRING_COMPONENTS_HAVE_AN_UNAMBIGUOUS_CONSTRUCTOR =
|
||||||
|
classes()
|
||||||
|
.that()
|
||||||
|
.areAnnotatedWith("org.springframework.stereotype.Repository")
|
||||||
|
.or()
|
||||||
|
.areAnnotatedWith("org.springframework.stereotype.Service")
|
||||||
|
.or()
|
||||||
|
.areAnnotatedWith("org.springframework.stereotype.Component")
|
||||||
|
.or()
|
||||||
|
.areAnnotatedWith("org.springframework.web.bind.annotation.RestController")
|
||||||
|
.should(haveAConstructorSpringCanChoose())
|
||||||
|
.as(
|
||||||
|
"D20: a scanned Spring component must have exactly one constructor, or mark one"
|
||||||
|
+ " @Autowired — otherwise component scan falls back to a no-arg constructor that"
|
||||||
|
+ " does not exist and the application fails to start");
|
||||||
|
|
||||||
|
private static ArchCondition<JavaClass> haveAConstructorSpringCanChoose() {
|
||||||
|
return new ArchCondition<>("have a constructor Spring can choose") {
|
||||||
|
@Override
|
||||||
|
public void check(JavaClass item, ConditionEvents events) {
|
||||||
|
var constructors =
|
||||||
|
item.getConstructors().stream()
|
||||||
|
.filter(constructor -> !constructor.getModifiers().contains(JavaModifier.SYNTHETIC))
|
||||||
|
.toList();
|
||||||
|
if (constructors.size() <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean autowired =
|
||||||
|
constructors.stream()
|
||||||
|
.anyMatch(
|
||||||
|
constructor ->
|
||||||
|
constructor.getAnnotations().stream()
|
||||||
|
.anyMatch(
|
||||||
|
annotation ->
|
||||||
|
annotation
|
||||||
|
.getRawType()
|
||||||
|
.getName()
|
||||||
|
.equals(
|
||||||
|
"org.springframework.beans.factory.annotation.Autowired")));
|
||||||
|
if (!autowired) {
|
||||||
|
events.add(
|
||||||
|
SimpleConditionEvent.violated(
|
||||||
|
item,
|
||||||
|
item.getName()
|
||||||
|
+ " declares "
|
||||||
|
+ constructors.size()
|
||||||
|
+ " constructors and marks none @Autowired;"
|
||||||
|
+ " component scan cannot choose one"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -11,6 +11,8 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||||
import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration;
|
import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration;
|
||||||
|
import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
|
||||||
|
import org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration;
|
||||||
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
|
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
|
||||||
import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration;
|
import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration;
|
||||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||||
@@ -117,13 +119,21 @@ class ActuatorSecurityHttpTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Redis 도 함께 제외한다. 빼지 않으면 reactive Redis health indicator 가 이 슬라이스에 그대로
|
||||||
|
* 올라와 localhost:6379 로 붙으려 하고, 실패하면 /actuator/health 가 503 이 된다. 그러면 이
|
||||||
|
* 테스트는 "probe 가 permitAll 인가" 가 아니라 "이 기계에 Redis 가 떠 있는가" 를 재게 된다 —
|
||||||
|
* 보안 태세와 아무 상관 없는 이유로 빨개진다.
|
||||||
|
*/
|
||||||
@EnableAutoConfiguration(
|
@EnableAutoConfiguration(
|
||||||
exclude = {
|
exclude = {
|
||||||
DataSourceAutoConfiguration.class,
|
DataSourceAutoConfiguration.class,
|
||||||
DataSourceTransactionManagerAutoConfiguration.class,
|
DataSourceTransactionManagerAutoConfiguration.class,
|
||||||
HibernateJpaAutoConfiguration.class,
|
HibernateJpaAutoConfiguration.class,
|
||||||
DataJpaRepositoriesAutoConfiguration.class,
|
DataJpaRepositoriesAutoConfiguration.class,
|
||||||
FlywayAutoConfiguration.class
|
FlywayAutoConfiguration.class,
|
||||||
|
DataRedisAutoConfiguration.class,
|
||||||
|
DataRedisReactiveAutoConfiguration.class
|
||||||
})
|
})
|
||||||
@Import(ManagementSecurityConfig.class)
|
@Import(ManagementSecurityConfig.class)
|
||||||
static class MinimalActuatorApp {}
|
static class MinimalActuatorApp {}
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.command;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code createProjectActivity}. {@code origin} 은 명령이 정하지 않는다 — 이 경로로 들어온 것은 언제나 {@code MANUAL} 이다.
|
||||||
|
*/
|
||||||
|
public record CreateProjectActivityCommand(
|
||||||
|
UUID projectId,
|
||||||
|
long expectedProjectVersion,
|
||||||
|
String activityType,
|
||||||
|
String title,
|
||||||
|
String summary,
|
||||||
|
String visibility,
|
||||||
|
String relatedResourceType,
|
||||||
|
UUID relatedResourceId,
|
||||||
|
Instant occurredAt,
|
||||||
|
String actor) {}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.command;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code updateHomeFocus}. 세 슬롯은 전부 nullable 이고, null 은 "이 슬롯을 비운다" 는 뜻이다 — 부분 수정이 아니라 전체 교체다.
|
||||||
|
*
|
||||||
|
* <p>부분 수정으로 두면 "비우기" 를 표현할 방법이 없어진다. 화면이 세 칸을 한 번에 보여 주고 한 번에 저장하므로 전체 교체가 화면과도 맞다.
|
||||||
|
*/
|
||||||
|
public record UpdateHomeFocusCommand(
|
||||||
|
long expectedVersion,
|
||||||
|
String defaultType,
|
||||||
|
UUID currentProjectId,
|
||||||
|
UUID openQuestionId,
|
||||||
|
UUID recentDecisionId,
|
||||||
|
String actor) {}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.command;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code updateProjectActivity}.
|
||||||
|
*
|
||||||
|
* <p>{@code activityType} 과 관련 자원은 고칠 수 없다 — 그것을 바꾸면 같은 줄이 다른 사건을 가리키게 된다. 바꿀 수 있는 것은 사람이 읽는
|
||||||
|
* 부분(제목·요약)과 노출 여부, 그리고 언제 일어난 일인지다.
|
||||||
|
*/
|
||||||
|
public record UpdateProjectActivityCommand(
|
||||||
|
UUID projectId,
|
||||||
|
UUID activityId,
|
||||||
|
long expectedVersion,
|
||||||
|
String title,
|
||||||
|
String summary,
|
||||||
|
String visibility,
|
||||||
|
Instant occurredAt,
|
||||||
|
String actor) {}
|
||||||
+2
@@ -17,11 +17,13 @@ public record UpdateProjectCommand(
|
|||||||
String currentObjective,
|
String currentObjective,
|
||||||
String nextStep,
|
String nextStep,
|
||||||
List<String> technologyLabels,
|
List<String> technologyLabels,
|
||||||
|
List<UUID> topicIds,
|
||||||
String targetVisibility,
|
String targetVisibility,
|
||||||
Integer featuredOrder,
|
Integer featuredOrder,
|
||||||
String actor) {
|
String actor) {
|
||||||
|
|
||||||
public UpdateProjectCommand {
|
public UpdateProjectCommand {
|
||||||
technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels);
|
technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels);
|
||||||
|
topicIds = topicIds == null ? List.of() : List.copyOf(topicIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.model;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈 화면이 무엇을 앞에 둘지 정하는 단일 행 설정. 계약 {@code HomeFocusResponse} 의 application 표현.
|
||||||
|
*
|
||||||
|
* <p>세 슬롯은 모두 nullable 이다. 비어 있다는 것은 "고르지 않았다" 는 뜻이고, 공개 화면은 그때 focus 영역을 아예 그리지 않는다.
|
||||||
|
*/
|
||||||
|
public record HomeFocusConfigView(
|
||||||
|
UUID id,
|
||||||
|
long version,
|
||||||
|
String defaultType,
|
||||||
|
UUID currentProjectId,
|
||||||
|
UUID openQuestionId,
|
||||||
|
UUID recentDecisionId) {}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약 {@code ProjectActivityResponse}.
|
||||||
|
*
|
||||||
|
* <p>{@code origin} 은 이 줄이 어디서 왔는지다 — 게시 같은 실제 사건이 남긴 {@code AUTO} 인지, 작성자가 손으로 적은 {@code MANUAL}
|
||||||
|
* 인지. 지우기가 그 둘을 다르게 다루므로 뷰에도 실어 보낸다.
|
||||||
|
*/
|
||||||
|
public record ProjectActivityView(
|
||||||
|
UUID id,
|
||||||
|
UUID projectId,
|
||||||
|
String activityType,
|
||||||
|
String title,
|
||||||
|
String summary,
|
||||||
|
String visibility,
|
||||||
|
String origin,
|
||||||
|
String relatedResourceType,
|
||||||
|
UUID relatedResourceId,
|
||||||
|
Instant occurredAt,
|
||||||
|
long version) {}
|
||||||
+6
-2
@@ -7,8 +7,10 @@ import java.util.UUID;
|
|||||||
/**
|
/**
|
||||||
* 계약 {@code ProjectEditResponse}.
|
* 계약 {@code ProjectEditResponse}.
|
||||||
*
|
*
|
||||||
* <p>{@code topicIds}/{@code documentLinks}/{@code questionLinks} 는 링크 테이블이 소유한다. (가) 범위에서는 그 편집
|
* <p>{@code topicIds} 는 {@code project_topic} 이 소유한다. 프로젝트 편집 화면이 이 목록을 고르고, 공개 프로젝트 화면의 "주요 주제" 가
|
||||||
* 화면이 없으므로 항상 비어 있고, 링크를 다루는 화면이 생길 때 같은 뷰에 채운다.
|
* 그 결과를 그린다 — 그 화면은 처음부터 주제를 읽고 있었지만 저장할 경로가 없어 늘 비어 있었다.
|
||||||
|
*
|
||||||
|
* <p>{@code documentLinks}/{@code questionLinks} 는 아직 편집 화면이 없어 항상 비어 있다.
|
||||||
*/
|
*/
|
||||||
public record ProjectEditView(
|
public record ProjectEditView(
|
||||||
UUID id,
|
UUID id,
|
||||||
@@ -23,6 +25,7 @@ public record ProjectEditView(
|
|||||||
String currentObjective,
|
String currentObjective,
|
||||||
String nextStep,
|
String nextStep,
|
||||||
List<String> technologyLabels,
|
List<String> technologyLabels,
|
||||||
|
List<UUID> topicIds,
|
||||||
String workflowStatus,
|
String workflowStatus,
|
||||||
String targetVisibility,
|
String targetVisibility,
|
||||||
Integer featuredOrder,
|
Integer featuredOrder,
|
||||||
@@ -32,5 +35,6 @@ public record ProjectEditView(
|
|||||||
|
|
||||||
public ProjectEditView {
|
public ProjectEditView {
|
||||||
technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels);
|
technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels);
|
||||||
|
topicIds = topicIds == null ? List.of() : List.copyOf(topicIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.port.out;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** 홈 focus 설정의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */
|
||||||
|
public interface HomeFocusConfigPort {
|
||||||
|
|
||||||
|
/** 단일 행이므로 조회는 실패하지 않는다. 마이그레이션이 빈 행을 이미 넣어 두었다. */
|
||||||
|
HomeFocusConfigView load();
|
||||||
|
|
||||||
|
/** 낙관적 잠금. 버전이 어긋나면 {@code Optional.empty()}. */
|
||||||
|
Optional<HomeFocusConfigView> update(UpdateHomeFocusCommand command);
|
||||||
|
|
||||||
|
boolean projectExists(UUID id);
|
||||||
|
|
||||||
|
boolean questionExists(UUID id);
|
||||||
|
|
||||||
|
boolean decisionExists(UUID id);
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.port.out;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** 프로젝트 활동의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */
|
||||||
|
public interface ProjectActivityRepositoryPort {
|
||||||
|
|
||||||
|
/** 최근 것이 먼저다 — 공개 타임라인과 같은 정렬이라 편집기와 화면이 같은 순서를 본다. */
|
||||||
|
List<ProjectActivityView> listByProject(UUID projectId);
|
||||||
|
|
||||||
|
Optional<ProjectActivityView> find(UUID projectId, UUID activityId);
|
||||||
|
|
||||||
|
ProjectActivityView create(CreateProjectActivityCommand command);
|
||||||
|
|
||||||
|
/** 낙관적 잠금. 버전이 어긋나면 {@code Optional.empty()}. */
|
||||||
|
Optional<ProjectActivityView> update(UpdateProjectActivityCommand command);
|
||||||
|
|
||||||
|
/** 삭제된 행 수. 0 이면 없거나 버전이 어긋난 것이다. */
|
||||||
|
int delete(UUID projectId, UUID activityId, long expectedVersion);
|
||||||
|
}
|
||||||
+11
@@ -25,5 +25,16 @@ public interface ProjectRepositoryPort {
|
|||||||
|
|
||||||
boolean isReferenced(UUID id);
|
boolean isReferenced(UUID id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 게시. 프로젝트는 Studio 문서가 아니라 {@code RecordKind} 에 없고, 따라서 문서 게시 파이프라인을 타지 않는다. 공개 화면들은 모두 {@code
|
||||||
|
* public_resource_projection} 의 {@code PROJECT} 행을 가시성 관문으로 쓰므로, 그 행을 세우는 것이 곧 게시다.
|
||||||
|
*
|
||||||
|
* <p>버전이 어긋나면 {@code Optional.empty()}.
|
||||||
|
*/
|
||||||
|
Optional<ProjectEditView> publish(UUID id, long expectedVersion, String visibility, String actor);
|
||||||
|
|
||||||
|
/** 게시 취소. 투영 행은 지우지 않고 {@code WITHDRAWN} 으로 내린다 — 지우면 다시 게시할 때 이력이 끊긴다. */
|
||||||
|
Optional<ProjectEditView> unpublish(UUID id, long expectedVersion, String actor);
|
||||||
|
|
||||||
boolean slugTaken(String slug, UUID exceptId);
|
boolean slugTaken(String slug, UUID exceptId);
|
||||||
}
|
}
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
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.CreateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionMode;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPort;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code createProjectActivity}.
|
||||||
|
*
|
||||||
|
* <p>{@code expectedProjectVersion} 은 활동이 아니라 <em>프로젝트</em>의 버전이다. 활동은 아직 없으므로 자기 버전을 가질 수 없고, 두
|
||||||
|
* 사람이 같은 프로젝트의 타임라인을 동시에 고치는 것을 막으려면 붙잡을 것이 프로젝트뿐이다.
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.WRITE,
|
||||||
|
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||||
|
public class CreateProjectActivityUseCase {
|
||||||
|
|
||||||
|
private final ProjectActivityRepositoryPort activities;
|
||||||
|
private final ProjectRepositoryPort projects;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public CreateProjectActivityUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities,
|
||||||
|
ProjectRepositoryPort projects,
|
||||||
|
TransactionPort transactions) {
|
||||||
|
this.activities = Objects.requireNonNull(activities, "activities");
|
||||||
|
this.projects = Objects.requireNonNull(projects, "projects");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProjectActivityView handle(CreateProjectActivityCommand command) {
|
||||||
|
Objects.requireNonNull(command, "command");
|
||||||
|
ProjectActivityUseCases.requireType(command.activityType());
|
||||||
|
ProjectActivityUseCases.requireVisibility(command.visibility());
|
||||||
|
ProjectActivityUseCases.requireTitle(command.title());
|
||||||
|
if (command.occurredAt() == null) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "occurredAt must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions.inWrite(
|
||||||
|
() -> {
|
||||||
|
ProjectEditView project =
|
||||||
|
projects
|
||||||
|
.find(command.projectId())
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.of(
|
||||||
|
ManagementError.PROJECT_NOT_FOUND, "no such project"));
|
||||||
|
if (project.version() != command.expectedProjectVersion()) {
|
||||||
|
throw ManagementException.withDetails(
|
||||||
|
ManagementError.VERSION_CONFLICT,
|
||||||
|
"the project changed since it was loaded",
|
||||||
|
new SaveTopicUseCase.VersionConflict(project.version()));
|
||||||
|
}
|
||||||
|
return activities.create(command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
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.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code deleteProjectActivity}.
|
||||||
|
*
|
||||||
|
* <p>{@code AUTO} 로 기록된 줄은 지우지 않는다. 그것은 게시 같은 실제 사건이 남긴 흔적이고, 지우면 무슨 일이 있었는지가 사라진다. 손으로 적은 {@code
|
||||||
|
* MANUAL} 줄만 지울 수 있다 — 잘못 적은 것을 되돌리는 것과 일어난 일을 없애는 것은 다르다.
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.WRITE,
|
||||||
|
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||||
|
public class DeleteProjectActivityUseCase {
|
||||||
|
|
||||||
|
private final ProjectActivityRepositoryPort activities;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public DeleteProjectActivityUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities, TransactionPort transactions) {
|
||||||
|
this.activities = Objects.requireNonNull(activities, "activities");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void handle(UUID projectId, UUID activityId, long expectedVersion, String actor) {
|
||||||
|
Objects.requireNonNull(projectId, "projectId");
|
||||||
|
Objects.requireNonNull(activityId, "activityId");
|
||||||
|
Objects.requireNonNull(actor, "actor");
|
||||||
|
|
||||||
|
transactions.inWrite(
|
||||||
|
() -> {
|
||||||
|
ProjectActivityView current =
|
||||||
|
activities
|
||||||
|
.find(projectId, activityId)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.of(
|
||||||
|
ManagementError.PROJECT_NOT_FOUND,
|
||||||
|
"no such activity on this project"));
|
||||||
|
if (!"MANUAL".equals(current.origin())) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.PROJECT_IN_USE,
|
||||||
|
"an automatically recorded activity cannot be deleted");
|
||||||
|
}
|
||||||
|
if (activities.delete(projectId, activityId, expectedVersion) == 0) {
|
||||||
|
throw ManagementException.withDetails(
|
||||||
|
ManagementError.VERSION_CONFLICT,
|
||||||
|
"the activity changed since it was loaded",
|
||||||
|
new SaveTopicUseCase.VersionConflict(current.version()));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
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.HomeFocusConfigView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionMode;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPort;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* final 이 아닌 이유는 다른 use case 들과 같다 — @RequiresPermission 이 CGLIB 프록시로 강제된다.
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||||
|
public class GetHomeFocusUseCase {
|
||||||
|
|
||||||
|
private final HomeFocusConfigPort config;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public GetHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort transactions) {
|
||||||
|
this.config = Objects.requireNonNull(config, "config");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HomeFocusConfigView handle() {
|
||||||
|
return transactions.inRead(config::load);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
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.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
|
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;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||||
|
public class ListStudioProjectActivitiesUseCase {
|
||||||
|
|
||||||
|
private final ProjectActivityRepositoryPort activities;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public ListStudioProjectActivitiesUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities, TransactionPort transactions) {
|
||||||
|
this.activities = Objects.requireNonNull(activities, "activities");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ProjectActivityView> handle(UUID projectId) {
|
||||||
|
Objects.requireNonNull(projectId, "projectId");
|
||||||
|
return transactions.inRead(() -> activities.listByProject(projectId));
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** 활동 use case 넷이 공유하는 규칙. 값의 허용 집합은 DB CHECK 제약과 같은 목록이다. */
|
||||||
|
final class ProjectActivityUseCases {
|
||||||
|
|
||||||
|
/** {@code project_activity_activity_type_check} 와 같은 목록이다. */
|
||||||
|
static final Set<String> TYPES =
|
||||||
|
Set.of(
|
||||||
|
"PHASE_CHANGED",
|
||||||
|
"QUESTION_OPENED",
|
||||||
|
"QUESTION_RESOLVED",
|
||||||
|
"DECISION_ACCEPTED",
|
||||||
|
"CASE_PUBLISHED",
|
||||||
|
"REFERENCE_PUBLISHED",
|
||||||
|
"MILESTONE_REACHED",
|
||||||
|
"PROJECT_PAUSED",
|
||||||
|
"PROJECT_RESUMED");
|
||||||
|
|
||||||
|
static final Set<String> VISIBILITIES = Set.of("PRIVATE", "PUBLIC");
|
||||||
|
|
||||||
|
private ProjectActivityUseCases() {}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 제약 위반을 그대로 터뜨리지 않고 여기서 먼저 거절한다. 드라이버가 던지는 것은 벤더 메시지 문자열이고,
|
||||||
|
* 그것을 화면에 그대로 내보내면 작성자는 무엇을 고쳐야 하는지 알 수 없다.
|
||||||
|
*/
|
||||||
|
static void requireType(String value) {
|
||||||
|
if (value == null || !TYPES.contains(value)) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "activityType is not a known activity type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void requireVisibility(String value) {
|
||||||
|
if (value == null || !VISIBILITIES.contains(value)) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "visibility must be PRIVATE or PUBLIC");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void requireTitle(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "title must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.capability.Idempotency;
|
||||||
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
|
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||||
|
import dev.caskeleton.application.security.RequiresPermission;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionMode;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPort;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code publishProject}.
|
||||||
|
*
|
||||||
|
* <p>프로젝트는 Studio 문서가 아니다 — {@code RecordKind} 에 없고 본문도 검증 대상도 없다. 그래서 문서 게시 파이프라인 대신 여기서 직접 공개 상태를
|
||||||
|
* 세운다. 릴리스가 자체 경로를 갖는 것과 같은 이유다.
|
||||||
|
*
|
||||||
|
* <p>slug 가 없으면 거절한다. 공개 주소가 {@code /projects/<slug>} 이므로 slug 없이 게시하면 아무도 닿을 수 없는 페이지가 생긴다 — 저장은
|
||||||
|
* 성공했는데 링크는 없는, 이유를 알 수 없는 상태다.
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.WRITE,
|
||||||
|
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||||
|
public class PublishProjectUseCase {
|
||||||
|
|
||||||
|
private static final Set<String> VISIBILITIES = Set.of("PUBLIC", "UNLISTED");
|
||||||
|
|
||||||
|
private final ProjectRepositoryPort projects;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public PublishProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
|
||||||
|
this.projects = Objects.requireNonNull(projects, "projects");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProjectEditView handle(UUID id, long expectedVersion, String visibility, String actor) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
String requested = visibility == null ? "PUBLIC" : visibility;
|
||||||
|
if (!VISIBILITIES.contains(requested)) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "visibility must be PUBLIC or UNLISTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions.inWrite(
|
||||||
|
() -> {
|
||||||
|
ProjectEditView current =
|
||||||
|
projects
|
||||||
|
.find(id)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.of(
|
||||||
|
ManagementError.PROJECT_NOT_FOUND, "no such project"));
|
||||||
|
if (current.slug() == null || current.slug().isBlank()) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED,
|
||||||
|
"a slug is required before a project can be published");
|
||||||
|
}
|
||||||
|
return projects
|
||||||
|
.publish(id, expectedVersion, requested, actor)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.withDetails(
|
||||||
|
ManagementError.VERSION_CONFLICT,
|
||||||
|
"the project changed since it was loaded",
|
||||||
|
new SaveTopicUseCase.VersionConflict(current.version())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.capability.Idempotency;
|
||||||
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
|
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||||
|
import dev.caskeleton.application.security.RequiresPermission;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionMode;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPort;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** {@code unpublishProject}. 투영 행은 지우지 않고 {@code WITHDRAWN} 으로 내린다. */
|
||||||
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.WRITE,
|
||||||
|
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||||
|
public class UnpublishProjectUseCase {
|
||||||
|
|
||||||
|
private final ProjectRepositoryPort projects;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public UnpublishProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
|
||||||
|
this.projects = Objects.requireNonNull(projects, "projects");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProjectEditView handle(UUID id, long expectedVersion, String actor) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
return transactions.inWrite(
|
||||||
|
() -> {
|
||||||
|
ProjectEditView current =
|
||||||
|
projects
|
||||||
|
.find(id)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.of(
|
||||||
|
ManagementError.PROJECT_NOT_FOUND, "no such project"));
|
||||||
|
return projects
|
||||||
|
.unpublish(id, expectedVersion, actor)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.withDetails(
|
||||||
|
ManagementError.VERSION_CONFLICT,
|
||||||
|
"the project changed since it was loaded",
|
||||||
|
new SaveTopicUseCase.VersionConflict(current.version())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
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.UpdateHomeFocusCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
|
||||||
|
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;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code updateHomeFocus}.
|
||||||
|
*
|
||||||
|
* <p>지목한 대상이 실제로 있는지 여기서 먼저 확인한다. 테이블에는 FK 가 없어서 (설정이 대상보다 오래 살아남는 것을 허용하는 설계다) 없는 id 를 그대로 저장할 수
|
||||||
|
* 있고, 그러면 공개 화면은 조용히 빈 focus 를 그린다 — 저장은 성공했는데 화면에는 아무것도 안 나오는, 이유를 알 수 없는 실패가 된다.
|
||||||
|
*
|
||||||
|
* <p>대상이 <em>공개</em>인지는 확인하지 않는다. 아직 게시하지 않은 프로젝트를 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 정상적인 순서다. 공개 여부는
|
||||||
|
* 공개 조회 쪽이 매번 다시 판단한다.
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.WRITE,
|
||||||
|
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||||
|
public class UpdateHomeFocusUseCase {
|
||||||
|
|
||||||
|
private static final Set<String> DEFAULT_TYPES =
|
||||||
|
Set.of("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION");
|
||||||
|
|
||||||
|
private final HomeFocusConfigPort config;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public UpdateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort transactions) {
|
||||||
|
this.config = Objects.requireNonNull(config, "config");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HomeFocusConfigView handle(UpdateHomeFocusCommand command) {
|
||||||
|
Objects.requireNonNull(command, "command");
|
||||||
|
if (command.defaultType() != null && !DEFAULT_TYPES.contains(command.defaultType())) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "defaultType is not a known focus type");
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions.inWrite(
|
||||||
|
() -> {
|
||||||
|
requireExists(
|
||||||
|
command.currentProjectId(),
|
||||||
|
config::projectExists,
|
||||||
|
ManagementError.PROJECT_NOT_FOUND,
|
||||||
|
"no such project");
|
||||||
|
requireExists(
|
||||||
|
command.openQuestionId(),
|
||||||
|
config::questionExists,
|
||||||
|
ManagementError.QUESTION_NOT_FOUND,
|
||||||
|
"no such question");
|
||||||
|
requireExists(
|
||||||
|
command.recentDecisionId(),
|
||||||
|
config::decisionExists,
|
||||||
|
ManagementError.DECISION_NOT_FOUND,
|
||||||
|
"no such decision");
|
||||||
|
|
||||||
|
HomeFocusConfigView current = config.load();
|
||||||
|
return config
|
||||||
|
.update(command)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.withDetails(
|
||||||
|
ManagementError.VERSION_CONFLICT,
|
||||||
|
"the home focus changed since it was loaded",
|
||||||
|
new SaveTopicUseCase.VersionConflict(current.version())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireExists(
|
||||||
|
UUID id, java.util.function.Predicate<UUID> exists, ManagementError error, String message) {
|
||||||
|
if (id != null && !exists.test(id)) {
|
||||||
|
throw ManagementException.of(error, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.capability.Idempotency;
|
||||||
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
|
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||||
|
import dev.caskeleton.application.security.RequiresPermission;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
|
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 updateProjectActivity}. */
|
||||||
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.WRITE,
|
||||||
|
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||||
|
public class UpdateProjectActivityUseCase {
|
||||||
|
|
||||||
|
private final ProjectActivityRepositoryPort activities;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public UpdateProjectActivityUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities, TransactionPort transactions) {
|
||||||
|
this.activities = Objects.requireNonNull(activities, "activities");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProjectActivityView handle(UpdateProjectActivityCommand command) {
|
||||||
|
Objects.requireNonNull(command, "command");
|
||||||
|
ProjectActivityUseCases.requireVisibility(command.visibility());
|
||||||
|
ProjectActivityUseCases.requireTitle(command.title());
|
||||||
|
if (command.occurredAt() == null) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "occurredAt must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions.inWrite(
|
||||||
|
() -> {
|
||||||
|
ProjectActivityView current =
|
||||||
|
activities
|
||||||
|
.find(command.projectId(), command.activityId())
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.of(
|
||||||
|
ManagementError.PROJECT_NOT_FOUND,
|
||||||
|
"no such activity on this project"));
|
||||||
|
return activities
|
||||||
|
.update(command)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.withDetails(
|
||||||
|
ManagementError.VERSION_CONFLICT,
|
||||||
|
"the activity changed since it was loaded",
|
||||||
|
new SaveTopicUseCase.VersionConflict(current.version())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -6,8 +6,8 @@ import java.util.UUID;
|
|||||||
* 계약 {@code BodyAsset}.
|
* 계약 {@code BodyAsset}.
|
||||||
*
|
*
|
||||||
* <p>본문이 {@code :::evidence key="..."} 로 가리키는 Asset 이다. 본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데
|
* <p>본문이 {@code :::evidence key="..."} 로 가리키는 Asset 이다. 본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데
|
||||||
* {@code /media/{assetId}} 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이 의도된 성질이다 — key 에서 주소를 만들 수 없다. 공개 화면이 key 를
|
* {@code /media/{assetId}} 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이 의도된 성질이다 — key 에서 주소를 만들 수 없다. 공개 화면이
|
||||||
* 해석할 수 있도록 게시된 기록이 실제로 참조하는 Asset 을 함께 준다.
|
* key 를 해석할 수 있도록 게시된 기록이 실제로 참조하는 Asset 을 함께 준다.
|
||||||
*
|
*
|
||||||
* @param url 검증된 전송 경로다. object storage URL 이 아니다(설계 05장 §3.1).
|
* @param url 검증된 전송 경로다. object storage URL 이 아니다(설계 05장 §3.1).
|
||||||
* @param decorative 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개 화면도 같은 값을 보고 {@code alt} 를 정해야
|
* @param decorative 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개 화면도 같은 값을 보고 {@code alt} 를 정해야
|
||||||
|
|||||||
+8
@@ -9,17 +9,23 @@ import java.util.List;
|
|||||||
* <p>계약의 {@code CaseDetailResponse.case} 와 {@code ReferenceDetailResponse.reference} 는 담는 필드가
|
* <p>계약의 {@code CaseDetailResponse.case} 와 {@code ReferenceDetailResponse.reference} 는 담는 필드가
|
||||||
* 다르지만(문제/결론 vs 범위/적용), 원천이 같은 {@code document} + 유형별 detail 이라 하나의 레코드로 읽고 웹 계층에서 유형별 모양으로 나눈다.
|
* 다르지만(문제/결론 vs 범위/적용), 원천이 같은 {@code document} + 유형별 detail 이라 하나의 레코드로 읽고 웹 계층에서 유형별 모양으로 나눈다.
|
||||||
*
|
*
|
||||||
|
* @param summary 문서가 스스로 밝히는 한 줄 요약. 제목 바로 아래에 온다 — 유형별 요약({@code primarySummary}: Case 는 문제,
|
||||||
|
* Reference 는 범위)과 다르다. 이 자리가 없던 동안 화면은 유형별 요약을 대신 썼고, 그러면 머리말이 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와
|
||||||
|
* 같은 글을 두 번 말했다.
|
||||||
* @param content Markdown 원문이다. studio 의 렌더 블록이 아니다 — 공개 계약은 {@code contentFormat} 과 함께 원문을 준다.
|
* @param content Markdown 원문이다. studio 의 렌더 블록이 아니다 — 공개 계약은 {@code contentFormat} 과 함께 원문을 준다.
|
||||||
*/
|
*/
|
||||||
public record PublishedDocumentView(
|
public record PublishedDocumentView(
|
||||||
String type,
|
String type,
|
||||||
String canonicalPath,
|
String canonicalPath,
|
||||||
String title,
|
String title,
|
||||||
|
String summary,
|
||||||
String primarySummary,
|
String primarySummary,
|
||||||
String secondarySummary,
|
String secondarySummary,
|
||||||
List<String> environmentSummary,
|
List<String> environmentSummary,
|
||||||
List<String> appliesTo,
|
List<String> appliesTo,
|
||||||
List<String> excludedScope,
|
List<String> excludedScope,
|
||||||
|
List<ReferenceRuleView> rules,
|
||||||
|
List<String> examples,
|
||||||
String freshnessStatus,
|
String freshnessStatus,
|
||||||
String content,
|
String content,
|
||||||
String contentFormat,
|
String contentFormat,
|
||||||
@@ -37,6 +43,8 @@ public record PublishedDocumentView(
|
|||||||
environmentSummary = environmentSummary == null ? List.of() : List.copyOf(environmentSummary);
|
environmentSummary = environmentSummary == null ? List.of() : List.copyOf(environmentSummary);
|
||||||
appliesTo = appliesTo == null ? List.of() : List.copyOf(appliesTo);
|
appliesTo = appliesTo == null ? List.of() : List.copyOf(appliesTo);
|
||||||
excludedScope = excludedScope == null ? List.of() : List.copyOf(excludedScope);
|
excludedScope = excludedScope == null ? List.of() : List.copyOf(excludedScope);
|
||||||
|
rules = rules == null ? List.of() : List.copyOf(rules);
|
||||||
|
examples = examples == null ? List.of() : List.copyOf(examples);
|
||||||
tags = tags == null ? List.of() : List.copyOf(tags);
|
tags = tags == null ? List.of() : List.copyOf(tags);
|
||||||
// Reference 에는 evidence 를 담는 본문이 없다. 빈 목록이 정상이며 없음과 구분하지 않는다.
|
// Reference 에는 evidence 를 담는 본문이 없다. 빈 목록이 정상이며 없음과 구분하지 않는다.
|
||||||
bodyAssets = bodyAssets == null ? List.of() : List.copyOf(bodyAssets);
|
bodyAssets = bodyAssets == null ? List.of() : List.copyOf(bodyAssets);
|
||||||
|
|||||||
+2
@@ -15,9 +15,11 @@ public record PublishedProjectView(
|
|||||||
String nextStep,
|
String nextStep,
|
||||||
String systemOverviewMarkdown,
|
String systemOverviewMarkdown,
|
||||||
List<String> technologies,
|
List<String> technologies,
|
||||||
|
List<TopicSummaryView> topics,
|
||||||
Instant updatedAt) {
|
Instant updatedAt) {
|
||||||
|
|
||||||
public PublishedProjectView {
|
public PublishedProjectView {
|
||||||
technologies = technologies == null ? List.of() : List.copyOf(technologies);
|
technologies = technologies == null ? List.of() : List.copyOf(technologies);
|
||||||
|
topics = topics == null ? List.of() : List.copyOf(topics);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.caskeleton.application.techlog.publicsite.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference 의 판단 기준 한 줄. 계약 {@code ReferenceDetailResponse.reference.rules[]}.
|
||||||
|
*
|
||||||
|
* <p>Reference 의 본문은 마크다운 한 덩어리가 아니라 제목이 붙은 규칙의 목록이다 — Studio 의 편집기가 그렇게 받고, 공개 화면도 그렇게 그린다.
|
||||||
|
*/
|
||||||
|
public record ReferenceRuleView(String title, String body) {}
|
||||||
@@ -455,6 +455,12 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
|||||||
useJUnitPlatform {
|
useJUnitPlatform {
|
||||||
excludeTags 'quarantine'
|
excludeTags 'quarantine'
|
||||||
}
|
}
|
||||||
|
// Gradle 기본값은 512m 이다. 이 스위트는 한 JVM 안에서 여러 Spring context 를 캐시하고
|
||||||
|
// (@SpringBootTest 슬라이스마다 하나) ArchUnit 이 전체 클래스 그래프를 들고 있으며
|
||||||
|
// Testcontainers 까지 함께 뜬다. 512m 에서는 그 조합이 OOM 으로 무너졌고, 증상은 테스트
|
||||||
|
// 실패가 아니라 "Gradle Test Executor 를 완료할 수 없음" — 어느 테스트 때문인지 알 수 없는
|
||||||
|
// 형태로 나타난다.
|
||||||
|
maxHeapSize = '2g'
|
||||||
}
|
}
|
||||||
|
|
||||||
// feature-ci-quality-gates-contract §4 (D7/D9) — flaky quarantine bucket. Runs ONLY
|
// feature-ci-quality-gates-contract §4 (D7/D9) — flaky quarantine bucket. Runs ONLY
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ b20d7a2 (feature/response-envelope-adr-006)
|
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ 0ffbc28 (master)
|
||||||
6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4 studio-v1.yaml
|
18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd studio-v1.yaml
|
||||||
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 55a9599 (feature/public-v1-response-envelope)
|
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ ef49d3a (master)
|
||||||
8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e public-v1.yaml
|
7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e public-v1.yaml
|
||||||
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 6ef5c1c (master)
|
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 0ffbc28 (master)
|
||||||
ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f studio-management-v1.yaml
|
72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55 studio-management-v1.yaml
|
||||||
|
|||||||
@@ -1022,6 +1022,7 @@ components:
|
|||||||
enum:
|
enum:
|
||||||
- CASE
|
- CASE
|
||||||
- REFERENCE
|
- REFERENCE
|
||||||
|
- QUESTION
|
||||||
- PROJECT_ACTIVITY
|
- PROJECT_ACTIVITY
|
||||||
- RELEASE
|
- RELEASE
|
||||||
title:
|
title:
|
||||||
@@ -1239,6 +1240,12 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
|
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
|
||||||
|
#
|
||||||
|
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
|
||||||
|
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
problemSummary:
|
problemSummary:
|
||||||
type: string
|
type: string
|
||||||
conclusionSummary:
|
conclusionSummary:
|
||||||
@@ -1325,6 +1332,12 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
|
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
|
||||||
|
#
|
||||||
|
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
|
||||||
|
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
scopeSummary:
|
scopeSummary:
|
||||||
type: string
|
type: string
|
||||||
appliesTo:
|
appliesTo:
|
||||||
@@ -1335,6 +1348,23 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
# Reference 의 본문은 `content` 마크다운이 아니라 이 두 칸에 있다. Studio 의 Reference
|
||||||
|
# 편집기는 규칙(제목+본문)과 예시를 따로 받고 body_markdown 은 비워 두므로, 이것을
|
||||||
|
# 내보내지 않으면 공개 화면에 판단 기준과 예시가 통째로 빠진다.
|
||||||
|
rules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [title, body]
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
examples:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
freshnessStatus:
|
freshnessStatus:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -1572,6 +1602,13 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
topics:
|
||||||
|
type: array
|
||||||
|
description: |-
|
||||||
|
이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||||
|
화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TopicSummary'
|
||||||
updatedAt: *id003
|
updatedAt: *id003
|
||||||
featuredDecision:
|
featuredDecision:
|
||||||
$ref: '#/components/schemas/RelatedEntry'
|
$ref: '#/components/schemas/RelatedEntry'
|
||||||
|
|||||||
@@ -3015,49 +3015,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:
|
||||||
@@ -3085,49 +3085,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProjectEditResponse'
|
$ref: '#/components/schemas/ProjectEditResponseEnvelope'
|
||||||
'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:
|
||||||
@@ -4973,7 +4973,7 @@ paths:
|
|||||||
$ref: '#/components/schemas/ExpectedVersionRequest'
|
$ref: '#/components/schemas/ExpectedVersionRequest'
|
||||||
security:
|
security:
|
||||||
- sessionCookie: []
|
- sessionCookie: []
|
||||||
/api/v1/studio/home-focus:
|
/api/v1/studio/home/focus:
|
||||||
get:
|
get:
|
||||||
operationId: getHomeFocus
|
operationId: getHomeFocus
|
||||||
tags:
|
tags:
|
||||||
@@ -4985,37 +4985,37 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HomeFocusResponse'
|
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
|
||||||
'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:
|
||||||
@@ -5030,49 +5030,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HomeFocusResponse'
|
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
|
||||||
'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:
|
||||||
@@ -5101,33 +5101,31 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: array
|
$ref: '#/components/schemas/ProjectActivityListEnvelope'
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
|
||||||
'401':
|
'401':
|
||||||
description: '401'
|
description: '401'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: '403'
|
description: '403'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: '404'
|
description: '404'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: '500'
|
description: '500'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
post:
|
post:
|
||||||
operationId: createProjectActivity
|
operationId: createProjectActivity
|
||||||
tags:
|
tags:
|
||||||
@@ -5154,49 +5152,49 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: '400'
|
description: '400'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: '401'
|
description: '401'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: '403'
|
description: '403'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: '404'
|
description: '404'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: '409'
|
description: '409'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: '422'
|
description: '422'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: '500'
|
description: '500'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
/api/v1/studio/projects/{id}/activities/{activityId}:
|
/api/v1/studio/projects/{id}/activities/{activityId}:
|
||||||
put:
|
put:
|
||||||
operationId: updateProjectActivity
|
operationId: updateProjectActivity
|
||||||
@@ -5230,49 +5228,126 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
|
||||||
'400':
|
'400':
|
||||||
description: '400'
|
description: '400'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'401':
|
'401':
|
||||||
description: '401'
|
description: '401'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'403':
|
'403':
|
||||||
description: '403'
|
description: '403'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'404':
|
'404':
|
||||||
description: '404'
|
description: '404'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'409':
|
'409':
|
||||||
description: '409'
|
description: '409'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'422':
|
'422':
|
||||||
description: '422'
|
description: '422'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
'500':
|
'500':
|
||||||
description: '500'
|
description: '500'
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ProblemDetails'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
delete:
|
||||||
|
operationId: deleteProjectActivity
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
description: |-
|
||||||
|
프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
|
||||||
|
적은 줄이 공개 타임라인에 영구히 남았다.
|
||||||
|
|
||||||
|
`AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
|
||||||
|
있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다.
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- name: activityId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- $ref: '#/components/parameters/CsrfToken'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ExpectedVersionRequest'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: No Content
|
||||||
|
'400':
|
||||||
|
description: Bad Request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'403':
|
||||||
|
description: Forbidden
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'404':
|
||||||
|
description: Not Found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'409':
|
||||||
|
description: Conflict
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'422':
|
||||||
|
description: Unprocessable Content
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
'500':
|
||||||
|
description: Internal Server Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- sessionCookie: []
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
sessionCookie:
|
sessionCookie:
|
||||||
@@ -5510,6 +5585,53 @@ components:
|
|||||||
$ref: '#/components/schemas/ReleaseEditResponse'
|
$ref: '#/components/schemas/ReleaseEditResponse'
|
||||||
meta:
|
meta:
|
||||||
$ref: '#/components/schemas/ResponseMeta'
|
$ref: '#/components/schemas/ResponseMeta'
|
||||||
|
HomeFocusResponseEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/HomeFocusResponse'
|
||||||
|
meta:
|
||||||
|
$ref: '#/components/schemas/ResponseMeta'
|
||||||
|
ProjectActivityResponseEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||||
|
meta:
|
||||||
|
$ref: '#/components/schemas/ResponseMeta'
|
||||||
|
ProjectActivityListEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||||
|
meta:
|
||||||
|
$ref: '#/components/schemas/ResponseMeta'
|
||||||
PublishResponseEnvelope:
|
PublishResponseEnvelope:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: Tech Log Studio API
|
title: Tech Log Studio API
|
||||||
version: 3.0.0
|
version: 3.1.0
|
||||||
description: |
|
description: |
|
||||||
Tech Log Studio orchestration 계약이다.
|
Tech Log Studio orchestration 계약이다.
|
||||||
|
|
||||||
@@ -1321,7 +1321,10 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
type: { type: string, enum: [HEADING] }
|
type: { type: string, enum: [HEADING] }
|
||||||
id: { type: string, minLength: 1, maxLength: 200 }
|
id: { type: string, minLength: 1, maxLength: 200 }
|
||||||
level: { type: integer, minimum: 2, maximum: 4 }
|
# 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
|
||||||
|
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
|
||||||
|
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
|
||||||
|
level: { type: integer, minimum: 1, maximum: 6 }
|
||||||
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
||||||
ParagraphBlock:
|
ParagraphBlock:
|
||||||
type: object
|
type: object
|
||||||
@@ -1452,6 +1455,29 @@ components:
|
|||||||
width: { type: [integer, "null"], minimum: 1 }
|
width: { type: [integer, "null"], minimum: 1 }
|
||||||
height: { type: [integer, "null"], minimum: 1 }
|
height: { type: [integer, "null"], minimum: 1 }
|
||||||
decorative: { type: boolean }
|
decorative: { type: boolean }
|
||||||
|
ThematicBreakBlock:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: |
|
||||||
|
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
|
||||||
|
required: [type]
|
||||||
|
properties:
|
||||||
|
type: { type: string, enum: [THEMATIC_BREAK] }
|
||||||
|
ImageBlock:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: |
|
||||||
|
`` 로 쓴 그림이다.
|
||||||
|
|
||||||
|
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
|
||||||
|
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
|
||||||
|
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
|
||||||
|
required: [type, src, alt, title]
|
||||||
|
properties:
|
||||||
|
type: { type: string, enum: [IMAGE] }
|
||||||
|
src: { type: string, minLength: 1, maxLength: 500 }
|
||||||
|
alt: { type: string, maxLength: 300 }
|
||||||
|
title: { type: [string, "null"], maxLength: 300 }
|
||||||
CaseRenderBlock:
|
CaseRenderBlock:
|
||||||
oneOf:
|
oneOf:
|
||||||
- { $ref: "#/components/schemas/HeadingBlock" }
|
- { $ref: "#/components/schemas/HeadingBlock" }
|
||||||
@@ -1463,6 +1489,8 @@ components:
|
|||||||
- { $ref: "#/components/schemas/DataTableBlock" }
|
- { $ref: "#/components/schemas/DataTableBlock" }
|
||||||
- { $ref: "#/components/schemas/CalloutBlock" }
|
- { $ref: "#/components/schemas/CalloutBlock" }
|
||||||
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
|
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
|
||||||
|
- { $ref: "#/components/schemas/ThematicBreakBlock" }
|
||||||
|
- { $ref: "#/components/schemas/ImageBlock" }
|
||||||
discriminator:
|
discriminator:
|
||||||
propertyName: type
|
propertyName: type
|
||||||
mapping:
|
mapping:
|
||||||
@@ -1475,6 +1503,8 @@ components:
|
|||||||
DATA_TABLE: "#/components/schemas/DataTableBlock"
|
DATA_TABLE: "#/components/schemas/DataTableBlock"
|
||||||
CALLOUT: "#/components/schemas/CalloutBlock"
|
CALLOUT: "#/components/schemas/CalloutBlock"
|
||||||
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
|
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
|
||||||
|
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
|
||||||
|
IMAGE: "#/components/schemas/ImageBlock"
|
||||||
CasePublicRenderModel:
|
CasePublicRenderModel:
|
||||||
unevaluatedProperties: false
|
unevaluatedProperties: false
|
||||||
allOf:
|
allOf:
|
||||||
@@ -1542,7 +1572,10 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
kind: { type: string, enum: [PROJECT_DECISION] }
|
kind: { type: string, enum: [PROJECT_DECISION] }
|
||||||
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
||||||
decidedOn: { type: string, format: date }
|
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
|
||||||
|
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
|
||||||
|
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
|
||||||
|
decidedOn: { type: [string, "null"], format: date }
|
||||||
statement: { type: string, maxLength: 100000 }
|
statement: { type: string, maxLength: 100000 }
|
||||||
rationale: { type: string, maxLength: 100000 }
|
rationale: { type: string, maxLength: 100000 }
|
||||||
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
|
|||||||
Reference in New Issue
Block a user