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:
co-authored by
Claude Opus 5
parent
ca63d7d3ec
commit
6aa140077d
+1
@@ -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))));
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+3
@@ -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();
|
||||
|
||||
+39
-1
@@ -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) {
|
||||
|
||||
+20
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user