Compare commits
3
Commits
6aa140077d
...
f1fd56fcb5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1fd56fcb5 | ||
|
|
bd66fb3610 | ||
|
|
a7e2b7d7fe |
+17
-3
@@ -68,11 +68,25 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
|||||||
.list();
|
.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트의 공개 경로를 함께 싣는다.
|
||||||
|
*
|
||||||
|
* <p>여기서 {@code publicPath} 를 늘 null 로 두었더니 Decision 의 즉시 미리보기가 어떤 문서에서도 열리지 않았다. Decision 의 공개
|
||||||
|
* 주소는 자기 slug 가 아니라 {@code <프로젝트 경로>/decisions#<slug>} 라, 렌더 모델이 프로젝트 경로를 요구한다 — 그것이 비어 있으면
|
||||||
|
* "PROJECT public path is required" 로 미리보기 전체가 멈춘다. 화면에는 무엇이 모자란지 나오지 않는다.
|
||||||
|
*
|
||||||
|
* <p>게시되지 않은 프로젝트는 여전히 null 이다. 그때는 공개 주소가 실제로 없고, 없는 주소를 지어내면 미리보기가 보여 주는 링크가 게시 뒤에 달라진다.
|
||||||
|
*/
|
||||||
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
|
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
|
||||||
return jdbcClient
|
return jdbcClient
|
||||||
.sql(
|
.sql(
|
||||||
"SELECT id, name, updated_at FROM project "
|
"SELECT pr.id, pr.name, pr.updated_at, p.navigation_path"
|
||||||
+ "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit")
|
+ " FROM project pr"
|
||||||
|
+ " LEFT JOIN public_resource_projection p"
|
||||||
|
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
|
||||||
|
+ " AND "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " WHERE lower(pr.name) LIKE :pattern ORDER BY pr.name LIMIT :limit")
|
||||||
.param("pattern", pattern)
|
.param("pattern", pattern)
|
||||||
.param("limit", limit)
|
.param("limit", limit)
|
||||||
.query(
|
.query(
|
||||||
@@ -82,7 +96,7 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
|||||||
CatalogEntryType.PROJECT,
|
CatalogEntryType.PROJECT,
|
||||||
rs.getString("name"),
|
rs.getString("name"),
|
||||||
"PROJECT",
|
"PROJECT",
|
||||||
null,
|
rs.getString("navigation_path"),
|
||||||
"project:" + rs.getTimestamp("updated_at").toInstant()))
|
"project:" + rs.getTimestamp("updated_at").toInstant()))
|
||||||
.list();
|
.list();
|
||||||
}
|
}
|
||||||
|
|||||||
+47
@@ -164,9 +164,56 @@ public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
|
|||||||
// 18. Document publish metadata
|
// 18. Document publish metadata
|
||||||
markSourcePublished(request);
|
markSourcePublished(request);
|
||||||
|
|
||||||
|
// 19. 프로젝트 활동 로그
|
||||||
|
recordProjectActivity(request);
|
||||||
|
|
||||||
return result(publicationId, eventId);
|
return result(publicationId, eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 활동은 손으로 적는 것이 아니라 게시가 남기는 로그다.
|
||||||
|
*
|
||||||
|
* <p>한동안 이 줄을 Studio 에서 직접 써야 했다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다.
|
||||||
|
* 게시가 곧 사건이므로 게시가 기록한다.
|
||||||
|
*
|
||||||
|
* <p>{@code operation_key} 로 문서마다 한 줄만 남긴다. 재게시는 새로 올린 것이 아니라 같은 글을 고친 것이므로 타임라인에 다시 나타나지 않아야 한다
|
||||||
|
* — {@code uq_project_activity_operation_key} 가 그것을 보장하고, 여기서는 충돌을 무시한다.
|
||||||
|
*
|
||||||
|
* <p>프로젝트에 매달리지 않은 기록은 남길 자리가 없다. 그때는 아무것도 하지 않는다.
|
||||||
|
*/
|
||||||
|
private void recordProjectActivity(PublishRequest request) {
|
||||||
|
if (request.projectId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
|
||||||
|
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
|
||||||
|
+ " operation_key, created_by, updated_by)"
|
||||||
|
+ " VALUES (:id, :projectId, :type, :title, '', 'PUBLIC', 'AUTO',"
|
||||||
|
+ " :resourceType, :resourceId, now(), :operationKey, :actor, :actor)"
|
||||||
|
+ " ON CONFLICT (project_id, operation_key) DO NOTHING")
|
||||||
|
.param("id", idGenerator.get())
|
||||||
|
.param("projectId", request.projectId())
|
||||||
|
.param("type", activityTypeOf(request.kind()))
|
||||||
|
.param("title", request.title())
|
||||||
|
.param("resourceType", request.kind().name())
|
||||||
|
.param("resourceId", request.documentId())
|
||||||
|
.param("operationKey", "publication:" + request.documentId())
|
||||||
|
.param("actor", request.principal())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code project_activity_activity_type_check} 가 허용하는 값으로 옮긴다. */
|
||||||
|
private static String activityTypeOf(RecordKind kind) {
|
||||||
|
return switch (kind) {
|
||||||
|
case CASE -> "CASE_PUBLISHED";
|
||||||
|
case REFERENCE -> "REFERENCE_PUBLISHED";
|
||||||
|
case QUESTION -> "QUESTION_OPENED";
|
||||||
|
case PROJECT_DECISION -> "DECISION_ACCEPTED";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PublishResultView unpublish(UnpublishRequest request) {
|
public PublishResultView unpublish(UnpublishRequest request) {
|
||||||
requireTransaction("unpublish");
|
requireTransaction("unpublish");
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
-- 프로젝트 활동을 게시 로그로 되돌린다.
|
||||||
|
--
|
||||||
|
-- 이 표는 원래 손으로 적는 자리였다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로
|
||||||
|
-- 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 이제 게시가 이 줄을 남긴다
|
||||||
|
-- (JdbcPublicationWriterAdapter 19단계).
|
||||||
|
--
|
||||||
|
-- 이 마이그레이션은 그 규칙을 이미 게시된 것들에 소급 적용한다. 게시는 있었는데 로그가 없는
|
||||||
|
-- 상태를 남겨 두면, 이 변경 이전에 올린 글은 타임라인에서 영영 빠진다.
|
||||||
|
--
|
||||||
|
-- occurred_at 은 최초 PUBLISHED 사건의 시각이다 -- now() 를 쓰면 옛 게시가 전부 오늘 올린 것처럼
|
||||||
|
-- 보인다. operation_key 는 애플리케이션이 쓰는 것과 같은 규칙이라, 나중에 같은 문서를 재게시해도
|
||||||
|
-- 줄이 늘지 않는다.
|
||||||
|
INSERT INTO project_activity (
|
||||||
|
id, project_id, activity_type, title, summary, visibility, origin,
|
||||||
|
related_resource_type, related_resource_id, occurred_at,
|
||||||
|
operation_key, created_by, updated_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
link.project_id,
|
||||||
|
CASE publication.source_kind
|
||||||
|
WHEN 'CASE' THEN 'CASE_PUBLISHED'
|
||||||
|
WHEN 'REFERENCE' THEN 'REFERENCE_PUBLISHED'
|
||||||
|
WHEN 'QUESTION' THEN 'QUESTION_OPENED'
|
||||||
|
ELSE 'DECISION_ACCEPTED'
|
||||||
|
END,
|
||||||
|
projection.title,
|
||||||
|
'',
|
||||||
|
'PUBLIC',
|
||||||
|
'AUTO',
|
||||||
|
publication.source_kind,
|
||||||
|
publication.source_id,
|
||||||
|
first_published.occurred_at,
|
||||||
|
'publication:' || publication.source_id,
|
||||||
|
'system:migration',
|
||||||
|
'system:migration'
|
||||||
|
FROM publication
|
||||||
|
JOIN public_resource_project_link link
|
||||||
|
ON link.resource_type = publication.source_kind
|
||||||
|
AND link.resource_id = publication.source_id
|
||||||
|
AND link.relation_type = 'PRIMARY'
|
||||||
|
JOIN public_resource_projection projection
|
||||||
|
ON projection.resource_type = publication.source_kind
|
||||||
|
AND projection.resource_id = publication.source_id
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT min(event.occurred_at) AS occurred_at
|
||||||
|
FROM publication_event event
|
||||||
|
WHERE event.publication_id = publication.publication_id
|
||||||
|
AND event.event_type = 'PUBLISHED'
|
||||||
|
) first_published ON true
|
||||||
|
WHERE publication.status = 'PUBLISHED'
|
||||||
|
AND first_published.occurred_at IS NOT NULL
|
||||||
|
ON CONFLICT (project_id, operation_key) DO NOTHING;
|
||||||
+9
-1
@@ -43,8 +43,16 @@ class PostgreSqlMigrationIntegrationTest {
|
|||||||
.load()
|
.load()
|
||||||
.migrate();
|
.migrate();
|
||||||
|
|
||||||
|
/*
|
||||||
|
TechLog 가 들어오면서 8·9·10 이 붙었는데 이 목록은 7 에서 멈춰 있었다. 마이그레이션을 더한
|
||||||
|
사람이 여기를 같이 고치지 않으면 이 테스트만 빨개지고, 그 빨간색은 "스키마가 잘못됐다" 가
|
||||||
|
아니라 "목록을 안 고쳤다" 를 뜻한다 — 정확히 그 상태로 두 번 지나갔다.
|
||||||
|
|
||||||
|
목록을 고정해 두는 이유는 남아 있다: 마이그레이션이 순서대로, 빠짐없이 적용되는지 확인한다.
|
||||||
|
그래서 개수를 세는 것이 아니라 버전을 그대로 적는다.
|
||||||
|
*/
|
||||||
assertThat(appliedVersions(postgres, "flyway_schema_history"))
|
assertThat(appliedVersions(postgres, "flyway_schema_history"))
|
||||||
.containsExactly("1", "3", "4", "5", "6", "7");
|
.containsExactly("1", "3", "4", "5", "6", "7", "8", "9", "10");
|
||||||
|
|
||||||
Flyway coreStream =
|
Flyway coreStream =
|
||||||
Flyway.configure()
|
Flyway.configure()
|
||||||
|
|||||||
+30
@@ -785,4 +785,34 @@ class ManagementPersistenceIntegrationTest {
|
|||||||
.single())
|
.single())
|
||||||
.isZero();
|
.isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 편집기가 Decision 을 미리 그리려면 이 목록이 프로젝트의 공개 경로를
|
||||||
|
* 알고 있어야 한다 — 한동안 늘 null 이었고, 그동안 Decision 은 어떤 문서에서도 즉시 미리보기가 열리지 않았다.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void projectCatalogCarriesThePublicPathOfPublishedProjectsOnly() {
|
||||||
|
ProjectEditView open = projects.create(new CreateProjectCommand("Catalog Published", "test"));
|
||||||
|
jdbcClient
|
||||||
|
.sql("UPDATE project SET slug = 'catalog-published' WHERE id = :id")
|
||||||
|
.param("id", open.id())
|
||||||
|
.update();
|
||||||
|
ProjectEditView loaded = projects.find(open.id()).orElseThrow();
|
||||||
|
assertThat(projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test")).isPresent();
|
||||||
|
|
||||||
|
ProjectEditView hidden = projects.create(new CreateProjectCommand("Catalog Hidden", "test"));
|
||||||
|
|
||||||
|
var entries = catalog.search(CatalogEntryType.PROJECT, "catalog", null, 50).items();
|
||||||
|
|
||||||
|
assertThat(entries)
|
||||||
|
.filteredOn(entry -> entry.id().equals(open.id()))
|
||||||
|
.singleElement()
|
||||||
|
.satisfies(
|
||||||
|
entry -> assertThat(entry.publicPath()).isEqualTo("/projects/catalog-published"));
|
||||||
|
// 게시되지 않은 프로젝트는 공개 주소가 실제로 없다. 지어내면 미리보기가 보여 준 링크가 게시 뒤에 달라진다.
|
||||||
|
assertThat(entries)
|
||||||
|
.filteredOn(entry -> entry.id().equals(hidden.id()))
|
||||||
|
.singleElement()
|
||||||
|
.satisfies(entry -> assertThat(entry.publicPath()).isNull());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ 06ae075 (master)
|
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ 83148b2 (master)
|
||||||
6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f studio-v1.yaml
|
18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd studio-v1.yaml
|
||||||
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 06ae075 (master)
|
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 83148b2 (master)
|
||||||
702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c public-v1.yaml
|
702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c public-v1.yaml
|
||||||
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 06ae075 (master)
|
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 83148b2 (master)
|
||||||
72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55 studio-management-v1.yaml
|
72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55 studio-management-v1.yaml
|
||||||
|
|||||||
@@ -1572,7 +1572,10 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
kind: { type: string, enum: [PROJECT_DECISION] }
|
kind: { type: string, enum: [PROJECT_DECISION] }
|
||||||
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
||||||
decidedOn: { type: string, format: date }
|
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
|
||||||
|
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
|
||||||
|
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
|
||||||
|
decidedOn: { type: [string, "null"], format: date }
|
||||||
statement: { type: string, maxLength: 100000 }
|
statement: { type: string, maxLength: 100000 }
|
||||||
rationale: { type: string, maxLength: 100000 }
|
rationale: { type: string, maxLength: 100000 }
|
||||||
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||||
|
|||||||
Reference in New Issue
Block a user