diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java index 82a6137..c473640 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java @@ -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)))); diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java index 28e704c..b8846b9 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java @@ -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; diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java index c5642ca..2a14511 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java @@ -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(); diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java index 681fb29..3c52a7c 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java @@ -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 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 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) { diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java index 385259f..3723e2c 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java @@ -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(); } + + /** + * 프로젝트가 다루는 주제. 화면의 "주요 주제" 가 이 목록을 그린다. + * + *

{@code ACTIVE} 인 주제만 내보낸다 — 보관된 주제를 링크로 내보내면 따라간 곳이 비어 있다. 순서는 편집기가 정한 {@code display_order} + * 를 그대로 따른다. + */ + private List 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(); + } } diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java index 2b96ba3..f987730 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java @@ -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 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(); + } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectCommand.java index c82413f..cc686f0 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectCommand.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateProjectCommand.java @@ -17,11 +17,13 @@ public record UpdateProjectCommand( String currentObjective, String nextStep, List technologyLabels, + List 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); } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectEditView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectEditView.java index ddd4bd7..7d71f66 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectEditView.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/ProjectEditView.java @@ -7,8 +7,10 @@ import java.util.UUID; /** * 계약 {@code ProjectEditResponse}. * - *

{@code topicIds}/{@code documentLinks}/{@code questionLinks} 는 링크 테이블이 소유한다. (가) 범위에서는 그 편집 - * 화면이 없으므로 항상 비어 있고, 링크를 다루는 화면이 생길 때 같은 뷰에 채운다. + *

{@code topicIds} 는 {@code project_topic} 이 소유한다. 프로젝트 편집 화면이 이 목록을 고르고, 공개 프로젝트 화면의 "주요 주제" 가 + * 그 결과를 그린다 — 그 화면은 처음부터 주제를 읽고 있었지만 저장할 경로가 없어 늘 비어 있었다. + * + *

{@code documentLinks}/{@code questionLinks} 는 아직 편집 화면이 없어 항상 비어 있다. */ public record ProjectEditView( UUID id, @@ -23,6 +25,7 @@ public record ProjectEditView( String currentObjective, String nextStep, List technologyLabels, + List 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); } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java index b297bff..6f4f315 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java @@ -15,9 +15,11 @@ public record PublishedProjectView( String nextStep, String systemOverviewMarkdown, List technologies, + List topics, Instant updatedAt) { public PublishedProjectView { technologies = technologies == null ? List.of() : List.copyOf(technologies); + topics = topics == null ? List.of() : List.copyOf(topics); } } diff --git a/src/config/openapi/MANIFEST.sha256 b/src/config/openapi/MANIFEST.sha256 index dc810ee..cf6a74d 100644 --- a/src/config/openapi/MANIFEST.sha256 +++ b/src/config/openapi/MANIFEST.sha256 @@ -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 diff --git a/src/config/openapi/public-v1.yaml b/src/config/openapi/public-v1.yaml index 1c7cf2e..f40b592 100644 --- a/src/config/openapi/public-v1.yaml +++ b/src/config/openapi/public-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'