diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java index 38f1e31c..71c833e6 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java @@ -145,7 +145,10 @@ public class WikiTransformationExecutor { WikiTransformationRunEntity run = new WikiTransformationRunEntity(); run.setTransformationId(transformation.getId()); run.setKbId(page.getKbId()); - run.setWorkspaceId(transformation.getWorkspaceId()); + // Global templates have a null workspace_id; the run row requires one, so + // fall back to the default workspace for bookkeeping (run visibility is + // gated by the KB's workspace, not this field). + run.setWorkspaceId(transformation.getWorkspaceId() != null ? transformation.getWorkspaceId() : 1L); run.setInputKind("page"); run.setPageId(pageId); run.setStatus("running"); @@ -420,7 +423,10 @@ public class WikiTransformationExecutor { WikiTransformationRunEntity run = new WikiTransformationRunEntity(); run.setTransformationId(transformation.getId()); run.setKbId(raw.getKbId()); - run.setWorkspaceId(transformation.getWorkspaceId()); + // Global templates have a null workspace_id; the run row requires one, so + // fall back to the default workspace for bookkeeping (run visibility is + // gated by the KB's workspace, not this field). + run.setWorkspaceId(transformation.getWorkspaceId() != null ? transformation.getWorkspaceId() : 1L); run.setInputKind("raw"); run.setRawId(rawId); run.setStatus("running"); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java index 729d7e09..af71db3b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java @@ -29,7 +29,12 @@ public class WikiTransformationService { private final WikiTransformationMapper transformationMapper; private final WikiTransformationRunMapper runMapper; - /** Templates visible to a KB: pinned to this KB plus workspace-wide ones (kb_id NULL). */ + /** + * Templates visible to a KB: pinned to this KB, plus workspace-wide ones — + * either scoped to this workspace ({@code workspace_id = current}) or global + * ({@code workspace_id IS NULL}, e.g. the built-in starter pack, visible to + * every workspace). + */ public List listForKb(Long kbId, Long workspaceId) { if (kbId == null) { return List.of(); @@ -38,14 +43,17 @@ public class WikiTransformationService { new LambdaQueryWrapper() .and(w -> w.eq(WikiTransformationEntity::getKbId, kbId) .or(g -> g.isNull(WikiTransformationEntity::getKbId) - .eq(WikiTransformationEntity::getWorkspaceId, workspaceId))) + .and(ws -> ws.eq(WikiTransformationEntity::getWorkspaceId, workspaceId) + .or().isNull(WikiTransformationEntity::getWorkspaceId)))) .orderByDesc(WikiTransformationEntity::getUpdateTime)); } public List listByWorkspace(Long workspaceId) { return transformationMapper.selectList( new LambdaQueryWrapper() - .eq(WikiTransformationEntity::getWorkspaceId, workspaceId) + // This workspace's own templates plus global ones (workspace_id NULL). + .and(w -> w.eq(WikiTransformationEntity::getWorkspaceId, workspaceId) + .or().isNull(WikiTransformationEntity::getWorkspaceId)) .orderByDesc(WikiTransformationEntity::getUpdateTime)); } @@ -65,7 +73,9 @@ public class WikiTransformationService { WikiTransformationEntity global = transformationMapper.selectOne( new LambdaQueryWrapper() .isNull(WikiTransformationEntity::getKbId) - .eq(WikiTransformationEntity::getWorkspaceId, workspaceId) + // This workspace's own template, or a global one (workspace_id NULL). + .and(ws -> ws.eq(WikiTransformationEntity::getWorkspaceId, workspaceId) + .or().isNull(WikiTransformationEntity::getWorkspaceId)) .eq(WikiTransformationEntity::getName, name) .last("LIMIT 1")); return Optional.ofNullable(global); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V165__wiki_transformation_starter_pack_global.sql b/mateclaw-server/src/main/resources/db/migration/h2/V165__wiki_transformation_starter_pack_global.sql new file mode 100644 index 00000000..da6d4bd4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V165__wiki_transformation_starter_pack_global.sql @@ -0,0 +1,14 @@ +-- V165: make the built-in starter-pack transformation templates global. +-- V108 seeded the 7 templates with a hardcoded workspace_id = 1, so any +-- workspace other than 1 saw an empty Transformations list. The fix marks them +-- global by clearing workspace_id (NULL = global). The column was NOT NULL, so +-- relax it first; listForKb / listByWorkspace / findByName treat NULL as global, +-- and the access checks already allow templates whose workspace_id IS NULL. +-- Targeted by fixed seed ids so real user templates are untouched. + +ALTER TABLE mate_wiki_transformation ALTER COLUMN workspace_id SET NULL; + +UPDATE mate_wiki_transformation +SET workspace_id = NULL +WHERE id IN (1000004001, 1000004002, 1000004003, 1000004004, 1000004005, 1000004006, 1000004007) + AND workspace_id = 1; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V165__wiki_transformation_starter_pack_global.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V165__wiki_transformation_starter_pack_global.sql new file mode 100644 index 00000000..179ec6ad --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V165__wiki_transformation_starter_pack_global.sql @@ -0,0 +1,14 @@ +-- V165: make the built-in starter-pack transformation templates global. +-- V108 seeded the 7 templates with a hardcoded workspace_id = 1, so any +-- workspace other than 1 saw an empty Transformations list. The fix marks them +-- global by clearing workspace_id (NULL = global). The column was NOT NULL, so +-- relax it first; listForKb / listByWorkspace / findByName treat NULL as global, +-- and the access checks already allow templates whose workspace_id IS NULL. +-- Targeted by fixed seed ids so real user templates are untouched. + +ALTER TABLE mate_wiki_transformation ALTER COLUMN workspace_id DROP NOT NULL; + +UPDATE mate_wiki_transformation +SET workspace_id = NULL +WHERE id IN (1000004001, 1000004002, 1000004003, 1000004004, 1000004005, 1000004006, 1000004007) + AND workspace_id = 1; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V165__wiki_transformation_starter_pack_global.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V165__wiki_transformation_starter_pack_global.sql new file mode 100644 index 00000000..01090bb0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V165__wiki_transformation_starter_pack_global.sql @@ -0,0 +1,14 @@ +-- V165: make the built-in starter-pack transformation templates global. +-- V108 seeded the 7 templates with a hardcoded workspace_id = 1, so any +-- workspace other than 1 saw an empty Transformations list. The fix marks them +-- global by clearing workspace_id (NULL = global). The column was NOT NULL, so +-- relax it first; listForKb / listByWorkspace / findByName treat NULL as global, +-- and the access checks already allow templates whose workspace_id IS NULL. +-- Targeted by fixed seed ids so real user templates are untouched. + +ALTER TABLE mate_wiki_transformation MODIFY COLUMN workspace_id BIGINT NULL DEFAULT 1; + +UPDATE mate_wiki_transformation +SET workspace_id = NULL +WHERE id IN (1000004001, 1000004002, 1000004003, 1000004004, 1000004005, 1000004006, 1000004007) + AND workspace_id = 1; diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiTransformationStarterPackGlobalE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiTransformationStarterPackGlobalE2ETest.java new file mode 100644 index 00000000..8facec06 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiTransformationStarterPackGlobalE2ETest.java @@ -0,0 +1,131 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiTransformationEntity; +import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; +import vip.mate.wiki.repository.WikiTransformationMapper; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression for the starter-pack visibility bug: the 7 built-in transformation + * templates were seeded with a hardcoded {@code workspace_id = 1}, so any other + * workspace saw an empty Transformations list. V165 clears their workspace_id + * (NULL = global) and the queries treat NULL as visible everywhere — this test + * boots H2 with the real Flyway migrations (V108 seed + V165 fix) and asserts + * the templates show up regardless of workspace, while workspace-scoped + * templates stay isolated. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999" + } +) +class WikiTransformationStarterPackGlobalE2ETest { + + private static final Set STARTER_PACK = Set.of( + "contract-risk-extract", "meeting-action-items", "customer-profile", + "competitor-update", "resume-structured-extract", "incident-postmortem", "paper-imrad"); + + private static final AtomicLong SEQ = new AtomicLong(System.nanoTime()); + + @Autowired + private WikiTransformationService service; + @Autowired + private WikiKnowledgeBaseMapper kbMapper; + @Autowired + private WikiTransformationMapper transformationMapper; + + private long newKb(long workspaceId) { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + long id = SEQ.incrementAndGet(); + kb.setId(id); + kb.setName("kb-" + id); + kb.setStatus("active"); + kb.setWorkspaceId(workspaceId); + kb.setCreateTime(LocalDateTime.now()); + kb.setUpdateTime(LocalDateTime.now()); + kb.setDeleted(0); + kbMapper.insert(kb); + return id; + } + + private void insertWorkspaceTemplate(long workspaceId, String name) { + WikiTransformationEntity t = new WikiTransformationEntity(); + t.setId(SEQ.incrementAndGet()); + t.setKbId(null); // workspace-wide + t.setWorkspaceId(workspaceId); // but scoped to one workspace + t.setName(name); + t.setTitle(name); + t.setPromptTemplate("do something"); + t.setEnabled(true); + t.setCreateTime(LocalDateTime.now()); + t.setUpdateTime(LocalDateTime.now()); + t.setDeleted(0); + transformationMapper.insert(t); + } + + private Set visibleNames(long kbId, long workspaceId) { + return service.listForKb(kbId, workspaceId).stream() + .map(WikiTransformationEntity::getName) + .collect(Collectors.toSet()); + } + + @Test + @DisplayName("Starter pack is visible from a non-default workspace (the bug)") + void starterPackVisibleFromOtherWorkspace() { + long kb = newKb(999L); + Set names = visibleNames(kb, 999L); + for (String expected : STARTER_PACK) { + assertTrue(names.contains(expected), + "workspace 999 should see starter-pack template '" + expected + "', got: " + names); + } + } + + @Test + @DisplayName("Starter pack still visible from workspace 1 (no regression)") + void starterPackStillVisibleFromWorkspaceOne() { + long kb = newKb(1L); + assertTrue(visibleNames(kb, 1L).containsAll(STARTER_PACK)); + } + + @Test + @DisplayName("A workspace-scoped template stays isolated; globals are seen by both") + void workspaceScopedTemplateStaysIsolated() { + String unique = "ws-only-" + SEQ.incrementAndGet(); + insertWorkspaceTemplate(777L, unique); + + long kb777 = newKb(777L); + long kb888 = newKb(888L); + + assertTrue(visibleNames(kb777, 777L).contains(unique), "owner workspace should see its template"); + assertFalse(visibleNames(kb888, 888L).contains(unique), "other workspace must NOT see it"); + + // Global starter pack reaches both workspaces. + assertTrue(visibleNames(kb777, 777L).containsAll(STARTER_PACK)); + assertTrue(visibleNames(kb888, 888L).containsAll(STARTER_PACK)); + } + + @Test + @DisplayName("listByWorkspace surfaces globals alongside the workspace's own") + void listByWorkspaceIncludesGlobals() { + List rows = service.listByWorkspace(424242L); + Set names = rows.stream().map(WikiTransformationEntity::getName).collect(Collectors.toSet()); + assertTrue(names.containsAll(STARTER_PACK), + "listByWorkspace for an arbitrary workspace should still include the global starter pack"); + } +}