feat: 프로젝트 활동을 만들고 고치고 지울 수 있게 한다

공개 프로젝트 화면의 "활동" 이 언제나 비어 있었다. 계약에는 목록·생성·수정이
선언돼 있었지만 구현이 없었고, `project_activity` 는 0행이었다.

이 테이블은 투영을 거치지 않는다 — 공개 조회가 `visibility = 'PUBLIC'` 조건으로
직접 읽는다. 그래서 여기서 만든 줄이 곧 그 화면이다.

`origin` 은 어댑터가 정한다. 이 경로로 들어오는 것은 언제나 `MANUAL` 이고 `AUTO` 는
게시 파이프라인의 몫이다. 클라이언트가 값을 정하게 두면 손으로 적은 줄에 `AUTO` 를
붙여 삭제 금지를 우회할 수 있다.

생성이 붙잡는 버전은 활동이 아니라 프로젝트의 것이다. 활동은 아직 없으므로 자기
버전을 가질 수 없고, 두 사람이 같은 타임라인을 동시에 고치는 것을 막으려면 붙잡을
것이 프로젝트뿐이다.

모든 쓰기에 `project_id` 조건이 붙는다. 경로가 둘을 함께 요구하므로 활동 id 만으로
수정하면 남의 프로젝트 줄을 고칠 수 있다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
DongHyeonka
2026-08-23 19:33:12 +09:00
co-authored by Claude Opus 5
parent 561d02ae3a
commit 4c14f1eb8f
16 changed files with 952 additions and 45 deletions
@@ -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();
}
}
@@ -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.CreateDraftResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse; 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;
@@ -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.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.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;
@@ -213,6 +215,29 @@ public final class ManagementResponseMapper {
view.version()); 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;
} }
@@ -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;
/**
* 프로젝트 활동 편집 저장소.
*
* <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;
private final Supplier<UUID> idGenerator;
public JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient) {
this(jdbcClient, UUID::randomUUID);
}
JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient, Supplier<UUID> 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<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 = 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<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();
}
}
@@ -5,12 +5,16 @@ 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.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.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand; 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.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.studio.query.CatalogEntryType; import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView; 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;
@@ -43,6 +47,7 @@ class ManagementPersistenceIntegrationTest {
private static JdbcProjectRepositoryAdapter projects; private static JdbcProjectRepositoryAdapter projects;
private static JdbcHomeFocusConfigAdapter homeFocus; private static JdbcHomeFocusConfigAdapter homeFocus;
private static JdbcCatalogQueryAdapter catalog; private static JdbcCatalogQueryAdapter catalog;
private static JdbcProjectActivityRepositoryAdapter activities;
@BeforeAll @BeforeAll
static void migrate() { static void migrate() {
@@ -75,6 +80,7 @@ class ManagementPersistenceIntegrationTest {
projects = new JdbcProjectRepositoryAdapter(jdbcClient, new ObjectMapper()); projects = new JdbcProjectRepositoryAdapter(jdbcClient, new ObjectMapper());
homeFocus = new JdbcHomeFocusConfigAdapter(jdbcClient); homeFocus = new JdbcHomeFocusConfigAdapter(jdbcClient);
catalog = new JdbcCatalogQueryAdapter(jdbcClient); catalog = new JdbcCatalogQueryAdapter(jdbcClient);
activities = new JdbcProjectActivityRepositoryAdapter(jdbcClient);
} }
@AfterAll @AfterAll
@@ -586,4 +592,106 @@ class ManagementPersistenceIntegrationTest {
.extracting("id") .extracting("id")
.contains(caseId); .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();
}
} }
@@ -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.DocumentDeletionPort;
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort; 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;
@@ -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.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;
@@ -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.SaveTopicUseCase;
import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase; import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase; 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;
@@ -154,4 +159,30 @@ public class TechLogManagementConfig {
UpdateHomeFocusUseCase updateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) { UpdateHomeFocusUseCase updateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
return new UpdateHomeFocusUseCase(config, 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);
}
} }
@@ -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) {}
@@ -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) {}
@@ -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) {}
@@ -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);
}
@@ -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);
});
}
}
@@ -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;
});
}
}
@@ -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));
}
}
@@ -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");
}
}
}
@@ -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())));
});
}
}
+4 -4
View File
@@ -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 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 37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b public-v1.yaml
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ ed04872 (master) # source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 436937f (master)
c143ad3303eb05a02320940e2a0f5300a38b0c05f40b5bd5cf30960ef8004573 studio-management-v1.yaml 72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55 studio-management-v1.yaml
+148 -41
View File
@@ -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:
@@ -5525,6 +5600,38 @@ components:
$ref: '#/components/schemas/HomeFocusResponse' $ref: '#/components/schemas/HomeFocusResponse'
meta: meta:
$ref: '#/components/schemas/ResponseMeta' $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