feat: 프로젝트 주제를 저장하고 공개 응답에 싣는다

프로젝트 화면의 "주요 주제" 가 늘 비어 있었다. `project_topic` 은 테이블도 있고
공개 조회가 조인할 수도 있었지만, 응답에 실을 자리가 없었고 저장할 경로도 없었다 —
`ProjectUpdateRequest.topicIds` 는 계약에 있었지만 명령이 그 값을 들고 다니지 않았다.

주제는 통째로 교체한다. 부분 수정으로 두면 "주제를 전부 뗀다" 를 표현할 방법이 없고,
화면도 목록 하나를 한 번에 저장하므로 그쪽과도 맞는다. 고른 순서가 곧 화면 순서이므로
목록의 자리를 display_order 에 그대로 적는다.

공개 조회는 ACTIVE 인 주제만 내보낸다 — 보관된 주제를 링크로 내보내면 따라간 곳이
비어 있다.

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 21:33:41 +09:00
co-authored by Claude Opus 5
parent ca63d7d3ec
commit 6aa140077d
11 changed files with 176 additions and 8 deletions
@@ -109,6 +109,7 @@ public class ManagementProjectController {
body.getCurrentObjective(),
body.getNextStep(),
labels,
body.getTopicIds() == null ? List.of() : List.copyOf(body.getTopicIds()),
body.getTargetVisibility().getValue(),
body.getFeaturedOrder(),
ManagementPrincipals.require(principal))));
@@ -89,7 +89,7 @@ public final class ManagementResponseMapper {
model.setNextStep(view.nextStep());
model.setTechnologyLabels(List.copyOf(view.technologyLabels()));
model.setFeaturedOrder(view.featuredOrder());
model.setTopicIds(List.of());
model.setTopicIds(List.copyOf(view.topicIds()));
model.setDocumentLinks(List.of());
model.setQuestionLinks(List.of());
return model;
@@ -9,6 +9,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDet
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
@@ -56,6 +57,8 @@ public final class ProjectResponseMapper {
body.setNextStep(p.nextStep());
body.setSystemOverviewMarkdown(p.systemOverviewMarkdown());
body.setTechnologies(p.technologies());
body.setTopics(
p.topics().stream().map(topic -> new TopicSummary(topic.name(), topic.slug())).toList());
body.setUpdatedAt(PublicResponseMapper.at(p.updatedAt()));
ProjectDetailResponse dto = new ProjectDetailResponse();
@@ -93,6 +93,7 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
rs.getString("current_objective"),
rs.getString("next_step"),
labels(rs.getString("technology_labels")),
topicIdsOf(rs.getObject("id", UUID.class)),
rs.getString("workflow_status"),
rs.getString("target_visibility"),
(Integer) rs.getObject("featured_order"),
@@ -190,7 +191,44 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
.param("featured", command.featuredOrder())
.param("actor", command.actor())
.update();
return updated == 0 ? Optional.empty() : find(command.id());
if (updated == 0) {
return Optional.empty();
}
replaceTopics(command.id(), command.topicIds());
return find(command.id());
}
/** 편집기가 고른 주제. 순서가 곧 화면 순서이므로 목록의 자리를 {@code display_order} 에 그대로 적는다. */
private List<UUID> topicIdsOf(UUID projectId) {
return jdbcClient
.sql(
"SELECT topic_id FROM project_topic WHERE project_id = :projectId"
+ " ORDER BY display_order")
.param("projectId", projectId)
.query(UUID.class)
.list();
}
/*
* 통째로 교체한다. 부분 수정으로 두면 "주제를 전부 뗀다" 를 표현할 방법이 없고, 화면도 목록 하나를
* 한 번에 저장하므로 그쪽과도 맞는다.
*/
private void replaceTopics(UUID projectId, List<UUID> topicIds) {
jdbcClient
.sql("DELETE FROM project_topic WHERE project_id = :projectId")
.param("projectId", projectId)
.update();
int order = 0;
for (UUID topicId : topicIds) {
jdbcClient
.sql(
"INSERT INTO project_topic (project_id, topic_id, display_order)"
+ " VALUES (:projectId, :topicId, :order)")
.param("projectId", projectId)
.param("topicId", topicId)
.param("order", order++)
.update();
}
}
private static String nullToEmpty(String value) {
@@ -10,6 +10,7 @@ import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
@@ -88,6 +89,7 @@ public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
rs.getString("next_step"),
rs.getString("system_overview_markdown"),
json.strings(rs.getString("technology_labels")),
topicsOf(projectId),
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at"));
return new ProjectDetailView(
rs.getString("navigation_path"),
@@ -327,4 +329,22 @@ public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
.query(UUID.class)
.optional();
}
/**
* 프로젝트가 다루는 주제. 화면의 "주요 주제" 가 이 목록을 그린다.
*
* <p>{@code ACTIVE} 인 주제만 내보낸다 — 보관된 주제를 링크로 내보내면 따라간 곳이 비어 있다. 순서는 편집기가 정한 {@code display_order}
* 를 그대로 따른다.
*/
private List<TopicSummaryView> topicsOf(UUID projectId) {
return jdbcClient
.sql(
"SELECT t.name, t.slug FROM project_topic pt"
+ " JOIN topic t ON t.id = pt.topic_id"
+ " WHERE pt.project_id = :projectId AND t.status = 'ACTIVE'"
+ " ORDER BY pt.display_order, t.name")
.param("projectId", projectId)
.query((rs, rowNum) -> new TopicSummaryView(rs.getString("name"), rs.getString("slug")))
.list();
}
}
@@ -9,6 +9,7 @@ import dev.caskeleton.application.techlog.management.command.CreateProjectActivi
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
@@ -694,4 +695,94 @@ class ManagementPersistenceIntegrationTest {
assertThat(activities.delete(project.id(), activity.id(), activity.version())).isEqualTo(1);
assertThat(activities.listByProject(project.id())).isEmpty();
}
// ---------------------------------------------------------------- 프로젝트 주제
private static UUID insertTopic(String name, String slug) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by)"
+ " VALUES (:id, :name, :normalized, :slug, 'test', 'test')")
.param("id", id)
.param("name", name)
.param("normalized", name.toLowerCase(java.util.Locale.ROOT))
.param("slug", slug)
.update();
return id;
}
private static UpdateProjectCommand updateWithTopics(
ProjectEditView project, java.util.List<UUID> topicIds) {
return new UpdateProjectCommand(
project.id(),
project.version(),
project.name(),
project.slug() == null ? "project-" + project.id() : project.slug(),
"목적 한 줄",
"",
"",
"",
project.phase(),
"지금 목표",
"다음 작업",
java.util.List.of("Keycloak"),
topicIds,
"PRIVATE",
null,
"test");
}
/**
* 프로젝트 화면의 "주요 주제" 는 이 링크 테이블을 읽는다. 화면은 처음부터 주제를 읽고 있었지만 저장할 경로가 없어 늘 비어 있었다 — 여기서 확인하는 것은 고른 순서가
* 그대로 남는가와, 통째로 교체되는가다.
*/
@Test
void projectTopicsRoundTripInTheChosenOrder() {
ProjectEditView project = projects.create(new CreateProjectCommand("Topic Host", "test"));
UUID first = insertTopic("주제 하나", "topic-one");
UUID second = insertTopic("주제 둘", "topic-two");
ProjectEditView saved =
projects.update(updateWithTopics(project, java.util.List.of(second, first))).orElseThrow();
assertThat(saved.topicIds()).containsExactly(second, first);
assertThat(saved.currentObjective()).isEqualTo("지금 목표");
assertThat(saved.nextStep()).isEqualTo("다음 작업");
// 공개 조회가 쓰는 조건 그대로 — ACTIVE 인 주제만, display_order 순서로.
assertThat(
jdbcClient
.sql(
"SELECT t.name FROM project_topic pt"
+ " JOIN topic t ON t.id = pt.topic_id"
+ " WHERE pt.project_id = :id AND t.status = 'ACTIVE'"
+ " ORDER BY pt.display_order, t.name")
.param("id", project.id())
.query(String.class)
.list())
.containsExactly("주제 둘", "주제 하나");
}
/** 부분 수정이 아니라 전체 교체다 — 빈 목록은 "주제를 전부 뗀다" 는 뜻이어야 한다. */
@Test
void savingAnEmptyTopicListDetachesEveryTopic() {
ProjectEditView project = projects.create(new CreateProjectCommand("Detach Host", "test"));
UUID topic = insertTopic("뗄 주제", "topic-detach");
ProjectEditView withTopic =
projects.update(updateWithTopics(project, java.util.List.of(topic))).orElseThrow();
assertThat(withTopic.topicIds()).containsExactly(topic);
ProjectEditView cleared =
projects.update(updateWithTopics(withTopic, java.util.List.of())).orElseThrow();
assertThat(cleared.topicIds()).isEmpty();
assertThat(
jdbcClient
.sql("SELECT count(*) FROM project_topic WHERE project_id = :id")
.param("id", project.id())
.query(Integer.class)
.single())
.isZero();
}
}
@@ -17,11 +17,13 @@ public record UpdateProjectCommand(
String currentObjective,
String nextStep,
List<String> technologyLabels,
List<UUID> topicIds,
String targetVisibility,
Integer featuredOrder,
String actor) {
public UpdateProjectCommand {
technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels);
topicIds = topicIds == null ? List.of() : List.copyOf(topicIds);
}
}
@@ -7,8 +7,10 @@ import java.util.UUID;
/**
* 계약 {@code ProjectEditResponse}.
*
* <p>{@code topicIds}/{@code documentLinks}/{@code questionLinks} 는 링크 테이블이 소유한다. (가) 범위에서는 그 편집
* 화면이 없으므로 항상 비어 있고, 링크를 다루는 화면이 생길 때 같은 뷰에 채운다.
* <p>{@code topicIds}{@code project_topic} 이 소유한다. 프로젝트 편집 화면이 이 목록을 고르고, 공개 프로젝트 화면의 "주요 주제" 가
* 그 결과를 그린다 — 그 화면은 처음부터 주제를 읽고 있었지만 저장할 경로가 없어 늘 비어 있었다.
*
* <p>{@code documentLinks}/{@code questionLinks} 는 아직 편집 화면이 없어 항상 비어 있다.
*/
public record ProjectEditView(
UUID id,
@@ -23,6 +25,7 @@ public record ProjectEditView(
String currentObjective,
String nextStep,
List<String> technologyLabels,
List<UUID> topicIds,
String workflowStatus,
String targetVisibility,
Integer featuredOrder,
@@ -32,5 +35,6 @@ public record ProjectEditView(
public ProjectEditView {
technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels);
topicIds = topicIds == null ? List.of() : List.copyOf(topicIds);
}
}
@@ -15,9 +15,11 @@ public record PublishedProjectView(
String nextStep,
String systemOverviewMarkdown,
List<String> technologies,
List<TopicSummaryView> topics,
Instant updatedAt) {
public PublishedProjectView {
technologies = technologies == null ? List.of() : List.copyOf(technologies);
topics = topics == null ? List.of() : List.copyOf(topics);
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ 436937f (master)
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ 06ae075 (master)
6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f studio-v1.yaml
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 436937f (master)
37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b public-v1.yaml
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 436937f (master)
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 06ae075 (master)
702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c public-v1.yaml
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 06ae075 (master)
72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55 studio-management-v1.yaml
+7
View File
@@ -1572,6 +1572,13 @@ components:
type: array
items:
type: string
topics:
type: array
description: |-
이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다.
items:
$ref: '#/components/schemas/TopicSummary'
updatedAt: *id003
featuredDecision:
$ref: '#/components/schemas/RelatedEntry'