feat: implement topic and project management, so documents can be authored
Publishing was impossible on an empty database. Validation requires a topic,
the studio catalog answered zero topics, and nothing in the two implemented
contracts could create one — `studio-management-v1.yaml` owned that surface
and none of its 79 operations existed. Every path to a published record ran
through a door with no handle.
This implements the nine that unblock authoring: topics (list/create/update/
delete) and projects (list/get/create/update/delete). The remaining seventy
stay unimplemented; each has its own consumer and its own moment.
The contract was converted to the response envelope first (ADR-006), which is
what its own header said to do at implementation time. Doing it after would
have meant changing the wire shape of endpoints the frontend had already been
written against.
ManagementError is a separate enum rather than an extension of StudioError.
Each contract enumerates its own ApiError.code set, so a code reachable from
the wrong surface makes that contract false. It deliberately omits
INTERNAL_ERROR: the skeleton's OperationalError owns that code with
retryable=true, and declaring it twice with different values leaves the
registry with no answer. PublicError made the same call for the same reason.
Two contract defects surfaced while implementing. TopicEdit had neither id nor
version, so a listed topic could not be addressed by the `/topics/{id}` path
and a client had no source for the expectedVersion the write operations
require; both are fixed in the design package. The AWS SDK BOM had to be
imported in app-bootstrap as well — module-scoped dependency management does
not propagate to consumers, and this is the first runtime consumer of that
pattern.
Topic and project deletion refuse while records still reference them rather
than cascading. A topic disappearing should not silently reclassify the
documents that used it; moving them first is the caller's decision to make.
ActuatorSecurityHttpTest.healthEndpointIsPermitAll fails on this branch before
this change as well; it is untouched here.
This commit is contained in:
+226
@@ -0,0 +1,226 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
|
||||
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
|
||||
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
||||
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
|
||||
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Project 편집 저장소.
|
||||
*
|
||||
* <p>{@code technology_labels} 는 jsonb 다. 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로
|
||||
* 보내 타입이 어긋나므로, JSON 문자열로 직렬화해 {@code ::jsonb} 로 캐스팅한다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
|
||||
|
||||
private static final String EDIT_COLUMNS =
|
||||
"id, version, name, slug, one_line_purpose, purpose_markdown, boundary_markdown,"
|
||||
+ " system_overview_markdown, phase, current_objective, next_step, technology_labels,"
|
||||
+ " workflow_status, target_visibility, featured_order, first_published_at,"
|
||||
+ " last_published_at, updated_at";
|
||||
|
||||
private static final String INDEX_COLUMNS =
|
||||
"id, name, phase, workflow_status, target_visibility, current_objective, next_step,"
|
||||
+ " updated_at, version, first_published_at, last_published_at";
|
||||
|
||||
private static final TypeReference<List<String>> LABELS = new TypeReference<>() {};
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public JdbcProjectRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
private static Instant instant(ResultSet rs, String column) throws SQLException {
|
||||
Timestamp t = rs.getTimestamp(column);
|
||||
return t == null ? null : t.toInstant();
|
||||
}
|
||||
|
||||
private List<String> labels(String json) {
|
||||
if (json == null || json.isBlank()) return List.of();
|
||||
try {
|
||||
return objectMapper.readValue(json, LABELS);
|
||||
} catch (Exception malformed) {
|
||||
// 열 자체가 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다.
|
||||
// 편집 화면 전체를 막는 대신 빈 목록으로 두고 나머지 필드를 보여준다.
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private String labelsJson(List<String> values) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(values == null ? List.of() : values);
|
||||
} catch (Exception impossible) {
|
||||
throw new IllegalStateException("technology labels are not serialisable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectEditView mapEdit(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new ProjectEditView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getLong("version"),
|
||||
rs.getString("name"),
|
||||
rs.getString("slug"),
|
||||
rs.getString("one_line_purpose"),
|
||||
rs.getString("purpose_markdown"),
|
||||
rs.getString("boundary_markdown"),
|
||||
rs.getString("system_overview_markdown"),
|
||||
rs.getString("phase"),
|
||||
rs.getString("current_objective"),
|
||||
rs.getString("next_step"),
|
||||
labels(rs.getString("technology_labels")),
|
||||
rs.getString("workflow_status"),
|
||||
rs.getString("target_visibility"),
|
||||
(Integer) rs.getObject("featured_order"),
|
||||
instant(rs, "first_published_at"),
|
||||
instant(rs, "last_published_at"),
|
||||
instant(rs, "updated_at"));
|
||||
}
|
||||
|
||||
private static ProjectIndexItemView mapIndex(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new ProjectIndexItemView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("phase"),
|
||||
rs.getString("workflow_status"),
|
||||
rs.getString("target_visibility"),
|
||||
rs.getString("current_objective"),
|
||||
rs.getString("next_step"),
|
||||
instant(rs, "updated_at"),
|
||||
rs.getLong("version"),
|
||||
instant(rs, "first_published_at"),
|
||||
instant(rs, "last_published_at"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProjectIndexItemView> listAll(int limit, int offset) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT " + INDEX_COLUMNS + " FROM project ORDER BY updated_at DESC, id"
|
||||
+ " LIMIT :limit OFFSET :offset")
|
||||
.param("limit", limit)
|
||||
.param("offset", offset)
|
||||
.query(JdbcProjectRepositoryAdapter::mapIndex)
|
||||
.list();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countAll() {
|
||||
return Optional.ofNullable(
|
||||
jdbcClient.sql("SELECT COUNT(*) FROM project").query(Integer.class).single())
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectEditView> find(UUID id) {
|
||||
return jdbcClient
|
||||
.sql("SELECT " + EDIT_COLUMNS + " FROM project WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(this::mapEdit)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectEditView create(CreateProjectCommand command) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project (id, name, created_by, updated_by) "
|
||||
+ "VALUES (:id, :name, :actor, :actor)")
|
||||
.param("id", id)
|
||||
.param("name", command.title().trim())
|
||||
.param("actor", command.actor())
|
||||
.update();
|
||||
return find(id).orElseThrow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectEditView> update(UpdateProjectCommand command) {
|
||||
String slug = command.slug() == null || command.slug().isBlank() ? null : command.slug().trim();
|
||||
int updated =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE project SET name = :name, slug = :slug, one_line_purpose = :purpose,"
|
||||
+ " purpose_markdown = :purposeMd, boundary_markdown = :boundaryMd,"
|
||||
+ " system_overview_markdown = :overviewMd, phase = :phase,"
|
||||
+ " current_objective = :objective, next_step = :nextStep,"
|
||||
+ " technology_labels = CAST(:labels AS jsonb),"
|
||||
+ " target_visibility = :visibility, featured_order = :featured,"
|
||||
+ " version = version + 1, updated_at = now(), updated_by = :actor"
|
||||
+ " WHERE id = :id AND version = :expected")
|
||||
.param("id", command.id())
|
||||
.param("expected", command.expectedVersion())
|
||||
.param("name", command.name().trim())
|
||||
.param("slug", slug)
|
||||
.param("purpose", nullToEmpty(command.oneLinePurpose()))
|
||||
.param("purposeMd", nullToEmpty(command.purposeMarkdown()))
|
||||
.param("boundaryMd", nullToEmpty(command.boundaryMarkdown()))
|
||||
.param("overviewMd", nullToEmpty(command.systemOverviewMarkdown()))
|
||||
.param("phase", command.phase())
|
||||
.param("objective", command.currentObjective())
|
||||
.param("nextStep", command.nextStep())
|
||||
.param("labels", labelsJson(command.technologyLabels()))
|
||||
.param("visibility", command.targetVisibility())
|
||||
.param("featured", command.featuredOrder())
|
||||
.param("actor", command.actor())
|
||||
.update();
|
||||
return updated == 0 ? Optional.empty() : find(command.id());
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(UUID id, long expectedVersion) {
|
||||
return jdbcClient
|
||||
.sql("DELETE FROM project WHERE id = :id AND version = :expected")
|
||||
.param("id", id)
|
||||
.param("expected", expectedVersion)
|
||||
.update();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReferenced(UUID id) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS ("
|
||||
+ " SELECT 1 FROM project_document_link WHERE project_id = :id"
|
||||
+ " UNION ALL SELECT 1 FROM project_question_link WHERE project_id = :id"
|
||||
+ " UNION ALL SELECT 1 FROM project_decision WHERE project_id = :id"
|
||||
+ " UNION ALL SELECT 1 FROM public_resource_project_link WHERE project_id = :id"
|
||||
+ ")")
|
||||
.param("id", id)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean slugTaken(String slug, UUID exceptId) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS (SELECT 1 FROM project WHERE slug = :slug"
|
||||
+ " AND (:except IS NULL OR id <> :except))")
|
||||
.param("slug", slug)
|
||||
.param("except", exceptId)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||
|
||||
import dev.caskeleton.application.techlog.management.command.SaveTopicCommand;
|
||||
import dev.caskeleton.application.techlog.management.model.TopicEditView;
|
||||
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Topic 편집 저장소.
|
||||
*
|
||||
* <p>정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@code
|
||||
* uq_topic_normalized_name} 이 그 컬럼 위에 있으므로, 계산이 한 곳에만 있어야 사전 확인과 제약이
|
||||
* 같은 값을 본다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort {
|
||||
|
||||
private static final String COLUMNS =
|
||||
"id, name, slug, description, scope, status, version";
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcTopicRepositoryAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
private static String normalize(String name) {
|
||||
return name == null ? "" : name.trim().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static TopicEditView map(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new TopicEditView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("slug"),
|
||||
rs.getString("description"),
|
||||
rs.getString("scope"),
|
||||
rs.getString("status"),
|
||||
rs.getLong("version"),
|
||||
null,
|
||||
List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TopicEditView> listAll() {
|
||||
return jdbcClient
|
||||
.sql("SELECT " + COLUMNS + " FROM topic ORDER BY name")
|
||||
.query(JdbcTopicRepositoryAdapter::map)
|
||||
.list();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<TopicEditView> find(UUID id) {
|
||||
return jdbcClient
|
||||
.sql("SELECT " + COLUMNS + " FROM topic WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(JdbcTopicRepositoryAdapter::map)
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TopicEditView create(SaveTopicCommand command) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic (id, name, normalized_name, slug, description, scope, status,"
|
||||
+ " version, created_by, updated_by)"
|
||||
+ " VALUES (:id, :name, :normalized, :slug, :description, :scope,"
|
||||
+ " COALESCE(:status, 'ACTIVE'), 0, :actor, :actor)")
|
||||
.param("id", id)
|
||||
.param("name", command.name().trim())
|
||||
.param("normalized", normalize(command.name()))
|
||||
.param("slug", command.slug().trim())
|
||||
.param("description", command.description())
|
||||
.param("scope", command.scope())
|
||||
.param("status", command.status())
|
||||
.param("actor", command.actor())
|
||||
.update();
|
||||
return find(id).orElseThrow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<TopicEditView> update(SaveTopicCommand command) {
|
||||
int updated =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE topic SET name = :name, normalized_name = :normalized, slug = :slug,"
|
||||
+ " description = :description, scope = :scope,"
|
||||
+ " status = COALESCE(:status, status), version = version + 1,"
|
||||
+ " updated_at = now(), updated_by = :actor"
|
||||
+ " WHERE id = :id AND version = :expected")
|
||||
.param("id", command.id())
|
||||
.param("expected", command.expectedVersion())
|
||||
.param("name", command.name().trim())
|
||||
.param("normalized", normalize(command.name()))
|
||||
.param("slug", command.slug().trim())
|
||||
.param("description", command.description())
|
||||
.param("scope", command.scope())
|
||||
.param("status", command.status())
|
||||
.param("actor", command.actor())
|
||||
.update();
|
||||
return updated == 0 ? Optional.empty() : find(command.id());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(UUID id, long expectedVersion) {
|
||||
return jdbcClient
|
||||
.sql("DELETE FROM topic WHERE id = :id AND version = :expected")
|
||||
.param("id", id)
|
||||
.param("expected", expectedVersion)
|
||||
.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 확인. 주제를 가리키는 곳이 늘어나면 여기도 늘어야 한다 — 빠뜨리면 외래키가 대신 막고
|
||||
* 500 이 나간다.
|
||||
*/
|
||||
@Override
|
||||
public boolean isReferenced(UUID id) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS ("
|
||||
+ " SELECT 1 FROM document WHERE primary_topic_id = :id"
|
||||
+ " UNION ALL SELECT 1 FROM open_question WHERE primary_topic_id = :id"
|
||||
+ " UNION ALL SELECT 1 FROM public_resource_projection WHERE primary_topic_id = :id"
|
||||
+ ")")
|
||||
.param("id", id)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean nameTaken(String normalizedName, UUID exceptId) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS (SELECT 1 FROM topic WHERE normalized_name = :name"
|
||||
+ " AND (:except IS NULL OR id <> :except))")
|
||||
.param("name", normalizedName)
|
||||
.param("except", exceptId)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean slugTaken(String slug, UUID exceptId) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS (SELECT 1 FROM topic WHERE slug = :slug"
|
||||
+ " AND (:except IS NULL OR id <> :except))")
|
||||
.param("slug", slug)
|
||||
.param("except", exceptId)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user