From 4c14f1eb8ff770049bbfba14a25386b239ce2816 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 23 Aug 2026 19:33:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=ED=99=9C=EB=8F=99=EC=9D=84=20=EB=A7=8C=EB=93=A4=EA=B3=A0=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=98=EA=B3=A0=20=EC=A7=80=EC=9A=B8=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 프로젝트 화면의 "활동" 이 언제나 비어 있었다. 계약에는 목록·생성·수정이 선언돼 있었지만 구현이 없었고, `project_activity` 는 0행이었다. 이 테이블은 투영을 거치지 않는다 — 공개 조회가 `visibility = 'PUBLIC'` 조건으로 직접 읽는다. 그래서 여기서 만든 줄이 곧 그 화면이다. `origin` 은 어댑터가 정한다. 이 경로로 들어오는 것은 언제나 `MANUAL` 이고 `AUTO` 는 게시 파이프라인의 몫이다. 클라이언트가 값을 정하게 두면 손으로 적은 줄에 `AUTO` 를 붙여 삭제 금지를 우회할 수 있다. 생성이 붙잡는 버전은 활동이 아니라 프로젝트의 것이다. 활동은 아직 없으므로 자기 버전을 가질 수 없고, 두 사람이 같은 타임라인을 동시에 고치는 것을 막으려면 붙잡을 것이 프로젝트뿐이다. 모든 쓰기에 `project_id` 조건이 붙는다. 경로가 둘을 함께 요구하므로 활동 id 만으로 수정하면 남의 프로젝트 줄을 고칠 수 있다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu --- .../ManagementProjectActivityController.java | 114 +++++++++++ .../mapper/ManagementResponseMapper.java | 25 +++ .../JdbcProjectActivityRepositoryAdapter.java | 144 +++++++++++++ .../ManagementPersistenceIntegrationTest.java | 108 ++++++++++ .../techlog/TechLogManagementConfig.java | 31 +++ .../command/CreateProjectActivityCommand.java | 19 ++ .../command/UpdateProjectActivityCommand.java | 20 ++ .../management/model/ProjectActivityView.java | 23 +++ .../out/ProjectActivityRepositoryPort.java | 25 +++ .../service/CreateProjectActivityUseCase.java | 73 +++++++ .../service/DeleteProjectActivityUseCase.java | 68 +++++++ .../ListStudioProjectActivitiesUseCase.java | 36 ++++ .../service/ProjectActivityUseCases.java | 51 +++++ .../service/UpdateProjectActivityUseCase.java | 63 ++++++ src/config/openapi/MANIFEST.sha256 | 8 +- src/config/openapi/studio-management-v1.yaml | 189 ++++++++++++++---- 16 files changed, 952 insertions(+), 45 deletions(-) create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectActivityController.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectActivityRepositoryAdapter.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateProjectActivityCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectActivityCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectActivityView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectActivityRepositoryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateProjectActivityUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectActivityUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioProjectActivitiesUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ProjectActivityUseCases.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateProjectActivityUseCase.java diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectActivityController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectActivityController.java new file mode 100644 index 0000000..897acc3 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectActivityController.java @@ -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}. + * + *

공개 프로젝트 화면의 "활동" 은 {@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 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(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java index 3fd8ca4..28e704c 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java @@ -3,6 +3,7 @@ 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.HomeFocusResponse; 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.ProjectIndexItem; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage; @@ -13,6 +14,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIn import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit; import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView; +import dev.caskeleton.application.techlog.management.model.ProjectActivityView; import dev.caskeleton.application.techlog.management.model.ProjectEditView; import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView; import dev.caskeleton.application.techlog.management.model.ReleaseEditView; @@ -213,6 +215,29 @@ public final class ManagementResponseMapper { 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 projectActivities( + java.util.List views) { + return views.stream().map(ManagementResponseMapper::projectActivity).toList(); + } + private static String nullToEmpty(String value) { return value == null ? "" : value; } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectActivityRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectActivityRepositoryAdapter.java new file mode 100644 index 0000000..7ff1005 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectActivityRepositoryAdapter.java @@ -0,0 +1,144 @@ +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 java.util.function.Supplier; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * 프로젝트 활동 편집 저장소. + * + *

{@code origin} 은 이 경로에서 언제나 {@code MANUAL} 이다. 이 어댑터로 들어오는 것은 작성자가 손으로 적은 줄뿐이고, {@code AUTO} 는 + * 게시 파이프라인이 남기는 몫이다. 클라이언트가 값을 정하게 두면 손으로 적은 줄에 {@code AUTO} 를 붙여 삭제 금지를 우회할 수 있다. + * + *

모든 쓰기에 {@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; + private final Supplier idGenerator; + + public JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient) { + this(jdbcClient, UUID::randomUUID); + } + + JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient, Supplier idGenerator) { + this.jdbcClient = jdbcClient; + this.idGenerator = idGenerator; + } + + 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 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 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 = idGenerator.get(); + 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 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(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java index c36a197..2b96ba3 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java @@ -5,12 +5,16 @@ import static org.assertj.core.api.Assertions.assertThat; import com.zaxxer.hikari.HikariConfig; 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.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 org.flywaydb.core.Flyway; import org.junit.jupiter.api.AfterAll; @@ -43,6 +47,7 @@ class ManagementPersistenceIntegrationTest { private static JdbcProjectRepositoryAdapter projects; private static JdbcHomeFocusConfigAdapter homeFocus; private static JdbcCatalogQueryAdapter catalog; + private static JdbcProjectActivityRepositoryAdapter activities; @BeforeAll static void migrate() { @@ -75,6 +80,7 @@ class ManagementPersistenceIntegrationTest { projects = new JdbcProjectRepositoryAdapter(jdbcClient, new ObjectMapper()); homeFocus = new JdbcHomeFocusConfigAdapter(jdbcClient); catalog = new JdbcCatalogQueryAdapter(jdbcClient); + activities = new JdbcProjectActivityRepositoryAdapter(jdbcClient); } @AfterAll @@ -586,4 +592,106 @@ class ManagementPersistenceIntegrationTest { .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(); + } } diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java index d8efe0a..f687cd3 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java @@ -2,13 +2,16 @@ package dev.caskeleton.bootstrap.techlog; 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.ReleaseRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort; import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase; +import dev.caskeleton.application.techlog.management.service.CreateProjectActivityUseCase; import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase; import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase; 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.DeleteProjectUseCase; import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase; @@ -17,6 +20,7 @@ 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.GetReleaseForEditUseCase; +import dev.caskeleton.application.techlog.management.service.ListStudioProjectActivitiesUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase; @@ -25,6 +29,7 @@ import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCa 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.UpdateReleaseUseCase; import dev.caskeleton.application.transaction.TransactionPort; @@ -154,4 +159,30 @@ public class TechLogManagementConfig { 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); + } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateProjectActivityCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateProjectActivityCommand.java new file mode 100644 index 0000000..85f8956 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/CreateProjectActivityCommand.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectActivityCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectActivityCommand.java new file mode 100644 index 0000000..2620539 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectActivityCommand.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.techlog.management.command; + +import java.time.Instant; +import java.util.UUID; + +/** + * {@code updateProjectActivity}. + * + *

{@code activityType} 과 관련 자원은 고칠 수 없다 — 그것을 바꾸면 같은 줄이 다른 사건을 가리키게 된다. 바꿀 수 있는 것은 사람이 읽는 + * 부분(제목·요약)과 노출 여부, 그리고 언제 일어난 일인지다. + */ +public record UpdateProjectActivityCommand( + UUID projectId, + UUID activityId, + long expectedVersion, + String title, + String summary, + String visibility, + Instant occurredAt, + String actor) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectActivityView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectActivityView.java new file mode 100644 index 0000000..b757813 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectActivityView.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.techlog.management.model; + +import java.time.Instant; +import java.util.UUID; + +/** + * 계약 {@code ProjectActivityResponse}. + * + *

{@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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectActivityRepositoryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectActivityRepositoryPort.java new file mode 100644 index 0000000..db4c353 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectActivityRepositoryPort.java @@ -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 listByProject(UUID projectId); + + Optional find(UUID projectId, UUID activityId); + + ProjectActivityView create(CreateProjectActivityCommand command); + + /** 낙관적 잠금. 버전이 어긋나면 {@code Optional.empty()}. */ + Optional update(UpdateProjectActivityCommand command); + + /** 삭제된 행 수. 0 이면 없거나 버전이 어긋난 것이다. */ + int delete(UUID projectId, UUID activityId, long expectedVersion); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateProjectActivityUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateProjectActivityUseCase.java new file mode 100644 index 0000000..201a956 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/CreateProjectActivityUseCase.java @@ -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}. + * + *

{@code expectedProjectVersion} 은 활동이 아니라 프로젝트의 버전이다. 활동은 아직 없으므로 자기 버전을 가질 수 없고, 두 + * 사람이 같은 프로젝트의 타임라인을 동시에 고치는 것을 막으려면 붙잡을 것이 프로젝트뿐이다. + */ +@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); + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectActivityUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectActivityUseCase.java new file mode 100644 index 0000000..a6089fd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectActivityUseCase.java @@ -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}. + * + *

{@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; + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioProjectActivitiesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioProjectActivitiesUseCase.java new file mode 100644 index 0000000..f99a969 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ListStudioProjectActivitiesUseCase.java @@ -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 handle(UUID projectId) { + Objects.requireNonNull(projectId, "projectId"); + return transactions.inRead(() -> activities.listByProject(projectId)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ProjectActivityUseCases.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ProjectActivityUseCases.java new file mode 100644 index 0000000..bb0d053 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/ProjectActivityUseCases.java @@ -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 TYPES = + Set.of( + "PHASE_CHANGED", + "QUESTION_OPENED", + "QUESTION_RESOLVED", + "DECISION_ACCEPTED", + "CASE_PUBLISHED", + "REFERENCE_PUBLISHED", + "MILESTONE_REACHED", + "PROJECT_PAUSED", + "PROJECT_RESUMED"); + + static final Set 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"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateProjectActivityUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateProjectActivityUseCase.java new file mode 100644 index 0000000..ebbe65b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateProjectActivityUseCase.java @@ -0,0 +1,63 @@ +package dev.caskeleton.application.techlog.management.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.techlog.error.ManagementError; +import dev.caskeleton.application.techlog.error.ManagementException; +import dev.caskeleton.application.techlog.management.command.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()))); + }); + } +} diff --git a/src/config/openapi/MANIFEST.sha256 b/src/config/openapi/MANIFEST.sha256 index 616d203..dc810ee 100644 --- a/src/config/openapi/MANIFEST.sha256 +++ b/src/config/openapi/MANIFEST.sha256 @@ -1,6 +1,6 @@ -# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ ed04872 (master) +# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ 436937f (master) 6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f studio-v1.yaml -# source: tech-log-design-package contracts/openapi/public-v1.yaml @ ed04872 (master) +# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 436937f (master) 37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b public-v1.yaml -# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ ed04872 (master) -c143ad3303eb05a02320940e2a0f5300a38b0c05f40b5bd5cf30960ef8004573 studio-management-v1.yaml +# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 436937f (master) +72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55 studio-management-v1.yaml diff --git a/src/config/openapi/studio-management-v1.yaml b/src/config/openapi/studio-management-v1.yaml index a4d366e..ad744a9 100644 --- a/src/config/openapi/studio-management-v1.yaml +++ b/src/config/openapi/studio-management-v1.yaml @@ -5101,33 +5101,31 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/ProjectActivityResponse' + $ref: '#/components/schemas/ProjectActivityListEnvelope' '401': description: '401' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: '403' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: '404' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: '500' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' post: operationId: createProjectActivity tags: @@ -5154,49 +5152,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProjectActivityResponse' + $ref: '#/components/schemas/ProjectActivityResponseEnvelope' '400': description: '400' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: '401' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: '403' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: '404' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: '409' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: '422' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: '500' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' /api/v1/studio/projects/{id}/activities/{activityId}: put: operationId: updateProjectActivity @@ -5230,49 +5228,126 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProjectActivityResponse' + $ref: '#/components/schemas/ProjectActivityResponseEnvelope' '400': description: '400' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: '401' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: '403' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: '404' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: '409' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: '422' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: '500' content: - application/problem+json: + application/json: 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: securitySchemes: sessionCookie: @@ -5525,6 +5600,38 @@ components: $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: type: object additionalProperties: false