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:
co-authored by
Claude Opus 5
parent
561d02ae3a
commit
4c14f1eb8f
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.UpdateProjectActivityRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.CreateProjectActivityUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.DeleteProjectActivityUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioProjectActivitiesUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UpdateProjectActivityUseCase;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 활동. 계약 {@code listStudioProjectActivities}/{@code createProjectActivity}/{@code
|
||||||
|
* updateProjectActivity}/{@code deleteProjectActivity}.
|
||||||
|
*
|
||||||
|
* <p>공개 프로젝트 화면의 "활동" 은 {@code project_activity} 를 직접 읽는다 — 투영을 거치지 않으므로 여기서 만든 줄이 곧 그 화면이다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ManagementProjectActivityController {
|
||||||
|
|
||||||
|
private final ListStudioProjectActivitiesUseCase listActivities;
|
||||||
|
private final CreateProjectActivityUseCase createActivity;
|
||||||
|
private final UpdateProjectActivityUseCase updateActivity;
|
||||||
|
private final DeleteProjectActivityUseCase deleteActivity;
|
||||||
|
|
||||||
|
public ManagementProjectActivityController(
|
||||||
|
ListStudioProjectActivitiesUseCase listActivities,
|
||||||
|
CreateProjectActivityUseCase createActivity,
|
||||||
|
UpdateProjectActivityUseCase updateActivity,
|
||||||
|
DeleteProjectActivityUseCase deleteActivity) {
|
||||||
|
this.listActivities = listActivities;
|
||||||
|
this.createActivity = createActivity;
|
||||||
|
this.updateActivity = updateActivity;
|
||||||
|
this.deleteActivity = deleteActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/projects/{id}/activities")
|
||||||
|
public List<ProjectActivityResponse> listStudioProjectActivities(@PathVariable("id") UUID id) {
|
||||||
|
return ManagementResponseMapper.projectActivities(listActivities.handle(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/projects/{id}/activities")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public ProjectActivityResponse createProjectActivity(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ProjectActivityRequest body) {
|
||||||
|
return ManagementResponseMapper.projectActivity(
|
||||||
|
createActivity.handle(
|
||||||
|
new CreateProjectActivityCommand(
|
||||||
|
id,
|
||||||
|
body.getExpectedProjectVersion(),
|
||||||
|
body.getActivityType(),
|
||||||
|
body.getTitle(),
|
||||||
|
body.getSummary(),
|
||||||
|
body.getVisibility() == null ? null : body.getVisibility().getValue(),
|
||||||
|
body.getRelatedResourceType(),
|
||||||
|
body.getRelatedResourceId(),
|
||||||
|
instant(body.getOccurredAt()),
|
||||||
|
ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/v1/studio/projects/{id}/activities/{activityId}")
|
||||||
|
public ProjectActivityResponse updateProjectActivity(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@PathVariable("activityId") UUID activityId,
|
||||||
|
@RequestBody UpdateProjectActivityRequest body) {
|
||||||
|
return ManagementResponseMapper.projectActivity(
|
||||||
|
updateActivity.handle(
|
||||||
|
new UpdateProjectActivityCommand(
|
||||||
|
id,
|
||||||
|
activityId,
|
||||||
|
body.getExpectedVersion(),
|
||||||
|
body.getTitle(),
|
||||||
|
body.getSummary(),
|
||||||
|
body.getVisibility() == null ? null : body.getVisibility().getValue(),
|
||||||
|
instant(body.getOccurredAt()),
|
||||||
|
ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/projects/{id}/activities/{activityId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteProjectActivity(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@PathVariable("activityId") UUID activityId,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
deleteActivity.handle(
|
||||||
|
id, activityId, body.getExpectedVersion(), ManagementPrincipals.require(principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.time.Instant instant(OffsetDateTime value) {
|
||||||
|
return value == null ? null : value.toInstant();
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+144
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.command;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code createProjectActivity}. {@code origin} 은 명령이 정하지 않는다 — 이 경로로 들어온 것은 언제나 {@code MANUAL} 이다.
|
||||||
|
*/
|
||||||
|
public record CreateProjectActivityCommand(
|
||||||
|
UUID projectId,
|
||||||
|
long expectedProjectVersion,
|
||||||
|
String activityType,
|
||||||
|
String title,
|
||||||
|
String summary,
|
||||||
|
String visibility,
|
||||||
|
String relatedResourceType,
|
||||||
|
UUID relatedResourceId,
|
||||||
|
Instant occurredAt,
|
||||||
|
String actor) {}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.command;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code updateProjectActivity}.
|
||||||
|
*
|
||||||
|
* <p>{@code activityType} 과 관련 자원은 고칠 수 없다 — 그것을 바꾸면 같은 줄이 다른 사건을 가리키게 된다. 바꿀 수 있는 것은 사람이 읽는
|
||||||
|
* 부분(제목·요약)과 노출 여부, 그리고 언제 일어난 일인지다.
|
||||||
|
*/
|
||||||
|
public record UpdateProjectActivityCommand(
|
||||||
|
UUID projectId,
|
||||||
|
UUID activityId,
|
||||||
|
long expectedVersion,
|
||||||
|
String title,
|
||||||
|
String summary,
|
||||||
|
String visibility,
|
||||||
|
Instant occurredAt,
|
||||||
|
String actor) {}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약 {@code ProjectActivityResponse}.
|
||||||
|
*
|
||||||
|
* <p>{@code origin} 은 이 줄이 어디서 왔는지다 — 게시 같은 실제 사건이 남긴 {@code AUTO} 인지, 작성자가 손으로 적은 {@code MANUAL}
|
||||||
|
* 인지. 지우기가 그 둘을 다르게 다루므로 뷰에도 실어 보낸다.
|
||||||
|
*/
|
||||||
|
public record ProjectActivityView(
|
||||||
|
UUID id,
|
||||||
|
UUID projectId,
|
||||||
|
String activityType,
|
||||||
|
String title,
|
||||||
|
String summary,
|
||||||
|
String visibility,
|
||||||
|
String origin,
|
||||||
|
String relatedResourceType,
|
||||||
|
UUID relatedResourceId,
|
||||||
|
Instant occurredAt,
|
||||||
|
long version) {}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.port.out;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** 프로젝트 활동의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */
|
||||||
|
public interface ProjectActivityRepositoryPort {
|
||||||
|
|
||||||
|
/** 최근 것이 먼저다 — 공개 타임라인과 같은 정렬이라 편집기와 화면이 같은 순서를 본다. */
|
||||||
|
List<ProjectActivityView> listByProject(UUID projectId);
|
||||||
|
|
||||||
|
Optional<ProjectActivityView> find(UUID projectId, UUID activityId);
|
||||||
|
|
||||||
|
ProjectActivityView create(CreateProjectActivityCommand command);
|
||||||
|
|
||||||
|
/** 낙관적 잠금. 버전이 어긋나면 {@code Optional.empty()}. */
|
||||||
|
Optional<ProjectActivityView> update(UpdateProjectActivityCommand command);
|
||||||
|
|
||||||
|
/** 삭제된 행 수. 0 이면 없거나 버전이 어긋난 것이다. */
|
||||||
|
int delete(UUID projectId, UUID activityId, long expectedVersion);
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.capability.Idempotency;
|
||||||
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
|
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||||
|
import dev.caskeleton.application.security.RequiresPermission;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionMode;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPort;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code createProjectActivity}.
|
||||||
|
*
|
||||||
|
* <p>{@code expectedProjectVersion} 은 활동이 아니라 <em>프로젝트</em>의 버전이다. 활동은 아직 없으므로 자기 버전을 가질 수 없고, 두
|
||||||
|
* 사람이 같은 프로젝트의 타임라인을 동시에 고치는 것을 막으려면 붙잡을 것이 프로젝트뿐이다.
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.WRITE,
|
||||||
|
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||||
|
public class CreateProjectActivityUseCase {
|
||||||
|
|
||||||
|
private final ProjectActivityRepositoryPort activities;
|
||||||
|
private final ProjectRepositoryPort projects;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public CreateProjectActivityUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities,
|
||||||
|
ProjectRepositoryPort projects,
|
||||||
|
TransactionPort transactions) {
|
||||||
|
this.activities = Objects.requireNonNull(activities, "activities");
|
||||||
|
this.projects = Objects.requireNonNull(projects, "projects");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProjectActivityView handle(CreateProjectActivityCommand command) {
|
||||||
|
Objects.requireNonNull(command, "command");
|
||||||
|
ProjectActivityUseCases.requireType(command.activityType());
|
||||||
|
ProjectActivityUseCases.requireVisibility(command.visibility());
|
||||||
|
ProjectActivityUseCases.requireTitle(command.title());
|
||||||
|
if (command.occurredAt() == null) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "occurredAt must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions.inWrite(
|
||||||
|
() -> {
|
||||||
|
ProjectEditView project =
|
||||||
|
projects
|
||||||
|
.find(command.projectId())
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.of(
|
||||||
|
ManagementError.PROJECT_NOT_FOUND, "no such project"));
|
||||||
|
if (project.version() != command.expectedProjectVersion()) {
|
||||||
|
throw ManagementException.withDetails(
|
||||||
|
ManagementError.VERSION_CONFLICT,
|
||||||
|
"the project changed since it was loaded",
|
||||||
|
new SaveTopicUseCase.VersionConflict(project.version()));
|
||||||
|
}
|
||||||
|
return activities.create(command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.capability.Idempotency;
|
||||||
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
|
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||||
|
import dev.caskeleton.application.security.RequiresPermission;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionMode;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPort;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code deleteProjectActivity}.
|
||||||
|
*
|
||||||
|
* <p>{@code AUTO} 로 기록된 줄은 지우지 않는다. 그것은 게시 같은 실제 사건이 남긴 흔적이고, 지우면 무슨 일이 있었는지가 사라진다. 손으로 적은 {@code
|
||||||
|
* MANUAL} 줄만 지울 수 있다 — 잘못 적은 것을 되돌리는 것과 일어난 일을 없애는 것은 다르다.
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.WRITE,
|
||||||
|
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||||
|
public class DeleteProjectActivityUseCase {
|
||||||
|
|
||||||
|
private final ProjectActivityRepositoryPort activities;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public DeleteProjectActivityUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities, TransactionPort transactions) {
|
||||||
|
this.activities = Objects.requireNonNull(activities, "activities");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void handle(UUID projectId, UUID activityId, long expectedVersion, String actor) {
|
||||||
|
Objects.requireNonNull(projectId, "projectId");
|
||||||
|
Objects.requireNonNull(activityId, "activityId");
|
||||||
|
Objects.requireNonNull(actor, "actor");
|
||||||
|
|
||||||
|
transactions.inWrite(
|
||||||
|
() -> {
|
||||||
|
ProjectActivityView current =
|
||||||
|
activities
|
||||||
|
.find(projectId, activityId)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.of(
|
||||||
|
ManagementError.PROJECT_NOT_FOUND,
|
||||||
|
"no such activity on this project"));
|
||||||
|
if (!"MANUAL".equals(current.origin())) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.PROJECT_IN_USE,
|
||||||
|
"an automatically recorded activity cannot be deleted");
|
||||||
|
}
|
||||||
|
if (activities.delete(projectId, activityId, expectedVersion) == 0) {
|
||||||
|
throw ManagementException.withDetails(
|
||||||
|
ManagementError.VERSION_CONFLICT,
|
||||||
|
"the activity changed since it was loaded",
|
||||||
|
new SaveTopicUseCase.VersionConflict(current.version()));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.capability.Idempotency;
|
||||||
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
|
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||||
|
import dev.caskeleton.application.security.RequiresPermission;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionMode;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPort;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||||
|
public class ListStudioProjectActivitiesUseCase {
|
||||||
|
|
||||||
|
private final ProjectActivityRepositoryPort activities;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public ListStudioProjectActivitiesUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities, TransactionPort transactions) {
|
||||||
|
this.activities = Objects.requireNonNull(activities, "activities");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ProjectActivityView> handle(UUID projectId) {
|
||||||
|
Objects.requireNonNull(projectId, "projectId");
|
||||||
|
return transactions.inRead(() -> activities.listByProject(projectId));
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** 활동 use case 넷이 공유하는 규칙. 값의 허용 집합은 DB CHECK 제약과 같은 목록이다. */
|
||||||
|
final class ProjectActivityUseCases {
|
||||||
|
|
||||||
|
/** {@code project_activity_activity_type_check} 와 같은 목록이다. */
|
||||||
|
static final Set<String> TYPES =
|
||||||
|
Set.of(
|
||||||
|
"PHASE_CHANGED",
|
||||||
|
"QUESTION_OPENED",
|
||||||
|
"QUESTION_RESOLVED",
|
||||||
|
"DECISION_ACCEPTED",
|
||||||
|
"CASE_PUBLISHED",
|
||||||
|
"REFERENCE_PUBLISHED",
|
||||||
|
"MILESTONE_REACHED",
|
||||||
|
"PROJECT_PAUSED",
|
||||||
|
"PROJECT_RESUMED");
|
||||||
|
|
||||||
|
static final Set<String> VISIBILITIES = Set.of("PRIVATE", "PUBLIC");
|
||||||
|
|
||||||
|
private ProjectActivityUseCases() {}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 제약 위반을 그대로 터뜨리지 않고 여기서 먼저 거절한다. 드라이버가 던지는 것은 벤더 메시지 문자열이고,
|
||||||
|
* 그것을 화면에 그대로 내보내면 작성자는 무엇을 고쳐야 하는지 알 수 없다.
|
||||||
|
*/
|
||||||
|
static void requireType(String value) {
|
||||||
|
if (value == null || !TYPES.contains(value)) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "activityType is not a known activity type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void requireVisibility(String value) {
|
||||||
|
if (value == null || !VISIBILITIES.contains(value)) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "visibility must be PRIVATE or PUBLIC");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void requireTitle(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "title must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package dev.caskeleton.application.techlog.management.service;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.capability.Idempotency;
|
||||||
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
|
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||||
|
import dev.caskeleton.application.security.RequiresPermission;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionMode;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPort;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** {@code updateProjectActivity}. */
|
||||||
|
@RequiresPermission(StudioPermissions.WRITE)
|
||||||
|
@UseCaseCapability(
|
||||||
|
transactionMode = TransactionMode.WRITE,
|
||||||
|
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||||
|
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||||
|
public class UpdateProjectActivityUseCase {
|
||||||
|
|
||||||
|
private final ProjectActivityRepositoryPort activities;
|
||||||
|
private final TransactionPort transactions;
|
||||||
|
|
||||||
|
public UpdateProjectActivityUseCase(
|
||||||
|
ProjectActivityRepositoryPort activities, TransactionPort transactions) {
|
||||||
|
this.activities = Objects.requireNonNull(activities, "activities");
|
||||||
|
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProjectActivityView handle(UpdateProjectActivityCommand command) {
|
||||||
|
Objects.requireNonNull(command, "command");
|
||||||
|
ProjectActivityUseCases.requireVisibility(command.visibility());
|
||||||
|
ProjectActivityUseCases.requireTitle(command.title());
|
||||||
|
if (command.occurredAt() == null) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED, "occurredAt must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions.inWrite(
|
||||||
|
() -> {
|
||||||
|
ProjectActivityView current =
|
||||||
|
activities
|
||||||
|
.find(command.projectId(), command.activityId())
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.of(
|
||||||
|
ManagementError.PROJECT_NOT_FOUND,
|
||||||
|
"no such activity on this project"));
|
||||||
|
return activities
|
||||||
|
.update(command)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
ManagementException.withDetails(
|
||||||
|
ManagementError.VERSION_CONFLICT,
|
||||||
|
"the activity changed since it was loaded",
|
||||||
|
new SaveTopicUseCase.VersionConflict(current.version())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user