diff --git a/.gitignore b/.gitignore index 212a48dce..e9315ea18 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ datasets/ tools/seeder/datasets/* !tools/seeder/datasets/e2e/ !tools/seeder/datasets/e2e/** +!tools/seeder/datasets/minecraft/ +!tools/seeder/datasets/minecraft/** !tools/seeder/datasets/markdown-context-test/ !tools/seeder/datasets/markdown-context-test/** diff --git a/apps/app-api/src/__tests__/recall-routes.spec.ts b/apps/app-api/src/__tests__/recall-routes.spec.ts index 1c5c0cad5..2f4a7cbc3 100644 --- a/apps/app-api/src/__tests__/recall-routes.spec.ts +++ b/apps/app-api/src/__tests__/recall-routes.spec.ts @@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Context } from "@/utils/context"; const opMocks = vi.hoisted(() => ({ - collectMemoryRecallOp: vi.fn(), + collectEffectiveMemoryRecallOp: vi.fn(), recallContextRerankOp: vi.fn(), rerankTermRecallOp: vi.fn(), termRecallOp: vi.fn(), @@ -36,7 +36,7 @@ vi.mock("@cat/operations", async () => { return { ...actual, - collectMemoryRecallOp: opMocks.collectMemoryRecallOp, + collectEffectiveMemoryRecallOp: opMocks.collectEffectiveMemoryRecallOp, recallContextRerankOp: opMocks.recallContextRerankOp, rerankTermRecallOp: opMocks.rerankTermRecallOp, termRecallOp: opMocks.termRecallOp, @@ -59,8 +59,8 @@ vi.mock("@cat/permissions", async () => { import { getElementWithChunkIds, + listEffectiveMemoryIdsByProject, listAllLanguages, - listMemoryIdsByProject, listProjectGlossaryIds, } from "@cat/domain"; @@ -193,8 +193,12 @@ describe("recall routes", () => { domainMocks.getElementWithChunkIds.mockResolvedValue(element); vi.mocked(executeQuery).mockImplementation(async (_ctx, query) => { if (query === getElementWithChunkIds) return element; - if (query === listMemoryIdsByProject) - return ["22222222-2222-4222-8222-222222222222"]; + if (query === listEffectiveMemoryIdsByProject) + return { + projectMemoryIds: ["22222222-2222-4222-8222-222222222222"], + personalMemoryIds: [], + allMemoryIds: ["22222222-2222-4222-8222-222222222222"], + }; return []; }); @@ -225,7 +229,7 @@ describe("recall routes", () => { }, ]; - opMocks.collectMemoryRecallOp.mockResolvedValue(memories); + opMocks.collectEffectiveMemoryRecallOp.mockResolvedValue(memories); opMocks.recallContextRerankOp.mockResolvedValue(memories); const stream = await call( @@ -260,8 +264,12 @@ describe("recall routes", () => { domainMocks.getElementWithChunkIds.mockResolvedValue(element); vi.mocked(executeQuery).mockImplementation(async (_ctx, query) => { if (query === getElementWithChunkIds) return element; - if (query === listMemoryIdsByProject) - return ["22222222-2222-4222-8222-222222222222"]; + if (query === listEffectiveMemoryIdsByProject) + return { + projectMemoryIds: ["22222222-2222-4222-8222-222222222222"], + personalMemoryIds: [], + allMemoryIds: ["22222222-2222-4222-8222-222222222222"], + }; return []; }); @@ -279,7 +287,7 @@ describe("recall routes", () => { evidences: [], }; - opMocks.collectMemoryRecallOp.mockResolvedValue([exactMemory]); + opMocks.collectEffectiveMemoryRecallOp.mockResolvedValue([exactMemory]); opMocks.recallContextRerankOp.mockResolvedValue([exactMemory]); const stream = await call( diff --git a/apps/app-api/src/__tests__/suggestion-route.spec.ts b/apps/app-api/src/__tests__/suggestion-route.spec.ts index 66c9f3317..5afd6196d 100644 --- a/apps/app-api/src/__tests__/suggestion-route.spec.ts +++ b/apps/app-api/src/__tests__/suggestion-route.spec.ts @@ -8,7 +8,7 @@ import type { Context } from "@/utils/context"; // ─── Mocks ────────────────────────────────────────────────────────────────── const opMocks = vi.hoisted(() => ({ - collectMemoryRecallOp: vi.fn(), + collectEffectiveMemoryRecallOp: vi.fn(), termRecallOp: vi.fn(), llmTranslateOp: vi.fn(), })); @@ -40,7 +40,7 @@ vi.mock("@cat/operations", async () => { await vi.importActual("@cat/operations"); return { ...actual, - collectMemoryRecallOp: opMocks.collectMemoryRecallOp, + collectEffectiveMemoryRecallOp: opMocks.collectEffectiveMemoryRecallOp, termRecallOp: opMocks.termRecallOp, llmTranslateOp: opMocks.llmTranslateOp, }; @@ -75,7 +75,7 @@ import { executeQuery } from "@cat/domain"; import { findOpenAutoTranslatePR, getElementWithChunkIds, - listMemoryIdsByProject, + listEffectiveMemoryIdsByProject, listProjectGlossaryIds, } from "@cat/domain"; @@ -161,11 +161,16 @@ describe("suggestion.onNew", () => { vi.mocked(executeQuery).mockImplementation(async (_ctx, query) => { if (query === getElementWithChunkIds) return MOCK_ELEMENT; if (query === listProjectGlossaryIds) return []; - if (query === listMemoryIdsByProject) return []; + if (query === listEffectiveMemoryIdsByProject) + return { + projectMemoryIds: [], + personalMemoryIds: [], + allMemoryIds: [], + }; return []; // listNeighborElements fallback }); - opMocks.collectMemoryRecallOp.mockResolvedValue([]); + opMocks.collectEffectiveMemoryRecallOp.mockResolvedValue([]); opMocks.termRecallOp.mockResolvedValue({ terms: [] }); opMocks.llmTranslateOp.mockResolvedValue({ suggestion: null }); }); @@ -250,12 +255,16 @@ describe("suggestion.onNew", () => { vi.mocked(executeQuery).mockImplementation(async (_ctx, query) => { if (query === getElementWithChunkIds) return MOCK_ELEMENT; if (query === listProjectGlossaryIds) return []; - if (query === listMemoryIdsByProject) - return ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"]; + if (query === listEffectiveMemoryIdsByProject) + return { + projectMemoryIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], + personalMemoryIds: [], + allMemoryIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], + }; return []; }); - opMocks.collectMemoryRecallOp.mockResolvedValue([memory]); + opMocks.collectEffectiveMemoryRecallOp.mockResolvedValue([memory]); opMocks.llmTranslateOp.mockResolvedValue({ suggestion: null }); const stream = await call( diff --git a/apps/app-api/src/__tests__/translation-memory-governance.spec.ts b/apps/app-api/src/__tests__/translation-memory-governance.spec.ts new file mode 100644 index 000000000..7719e846e --- /dev/null +++ b/apps/app-api/src/__tests__/translation-memory-governance.spec.ts @@ -0,0 +1,294 @@ +import { createAuthedTestContext } from "@cat/test-utils"; +import { call, ORPCError } from "@orpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Context } from "@/utils/context"; + +const mocks = vi.hoisted(() => ({ + executeQuery: vi.fn(), + executeCommand: vi.fn(), + permissionCheck: vi.fn(), + interceptWrite: vi.fn(), + determineWriteMode: vi.fn().mockResolvedValue("direct"), +})); + +vi.mock("@cat/domain", async () => { + const actual = + await vi.importActual("@cat/domain"); + + return { + ...actual, + executeQuery: mocks.executeQuery, + executeCommand: mocks.executeCommand, + }; +}); + +vi.mock("@cat/permissions", async () => { + const actual = + await vi.importActual( + "@cat/permissions", + ); + + return { + ...actual, + getPermissionEngine: () => ({ + check: mocks.permissionCheck, + }), + determineWriteMode: mocks.determineWriteMode, + }; +}); + +vi.mock("@/utils/vcs-route-helper", async () => { + const actual = await vi.importActual< + typeof import("@/utils/vcs-route-helper") + >("@/utils/vcs-route-helper"); + + return { + ...actual, + createVCSRouteHelper: () => ({ + middleware: { + interceptWrite: mocks.interceptWrite, + }, + }), + }; +}); + +import { deleteMemoryItem, getMemoryAccessContext } from "@cat/domain"; + +import { deleteItem } from "@/orpc/routers/memory"; + +const projectId = "33333333-3333-4333-8333-333333333333"; +const memoryId = "44444444-4444-4444-8444-444444444444"; + +const createContext = (): Context => { + const base = createAuthedTestContext(); + const userId = base.user?.id; + + if (!userId) { + throw new Error("Expected authed test user"); + } + + const context = { + ...base, + auth: { + subjectType: "user", + subjectId: userId, + systemRoles: ["admin"], + scopes: [], + }, + drizzleDB: { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + client: {} as unknown as Context["drizzleDB"]["client"], + }, + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + redis: {}, + isSSR: true, + isWebSocket: false, + }; + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return context as unknown as Context; +}; + +describe("translation memory governance routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.determineWriteMode.mockResolvedValue("direct"); + + mocks.interceptWrite.mockImplementation( + async ( + _vcsCtx: unknown, + _entityType: unknown, + _entityId: unknown, + _action: unknown, + _before: unknown, + _after: unknown, + writeFn: () => Promise<{ deleted: boolean }>, + ) => await writeFn(), + ); + }); + + it("allows personal memory owner to delete items directly", async () => { + const context = createContext(); + const userId = context.user?.id; + if (!userId) throw new Error("Expected authed user"); + + mocks.executeQuery.mockImplementation(async (_ctx, query) => { + if (query === getMemoryAccessContext) { + return { + memoryId, + scope: "PERSONAL", + projectIds: [], + personalOwnerId: userId, + personalProjectId: projectId, + }; + } + return null; + }); + + mocks.executeCommand.mockResolvedValueOnce({ deleted: true }); + + const result = await call( + deleteItem, + { + memoryId, + memoryItemId: 101, + }, + { context }, + ); + + expect(result).toEqual({ deleted: true }); + expect(mocks.interceptWrite).not.toHaveBeenCalled(); + expect(mocks.executeCommand).toHaveBeenCalledWith( + { db: context.drizzleDB.client }, + deleteMemoryItem, + expect.objectContaining({ + memoryItemId: 101, + scope: "PERSONAL", + }), + ); + }); + + it("routes project memory deletion through VCS direct interceptWrite", async () => { + const context = createContext(); + const userId = context.user?.id; + if (!userId) throw new Error("Expected authed user"); + + mocks.permissionCheck.mockResolvedValue(true); + mocks.executeQuery.mockImplementation(async (_ctx, query) => { + if (query === getMemoryAccessContext) { + return { + memoryId, + scope: "PROJECT", + projectIds: [projectId], + personalOwnerId: null, + personalProjectId: null, + }; + } + return null; + }); + + mocks.executeCommand.mockResolvedValueOnce({ deleted: true }); + + const result = await call( + deleteItem, + { + memoryId, + memoryItemId: 102, + }, + { context }, + ); + + expect(result).toEqual({ deleted: true }); + expect(mocks.interceptWrite).toHaveBeenCalledTimes(1); + expect(mocks.interceptWrite).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "direct", + projectId, + createdBy: userId, + }), + "memory_item", + "102", + "DELETE", + expect.objectContaining({ + memoryId, + memoryItemId: 102, + scope: "PROJECT", + projectId, + }), + { deleted: true }, + expect.any(Function), + ); + }); + + it("routes project memory deletion through VCS isolation when branch context exists", async () => { + const base = createContext(); + const context = { + ...base, + branchId: 77, + branchChangesetId: 88, + branchProjectId: projectId, + } as Context; + + mocks.permissionCheck.mockResolvedValue(true); + mocks.executeQuery.mockImplementation(async (_ctx, query) => { + if (query === getMemoryAccessContext) { + return { + memoryId, + scope: "PROJECT", + projectIds: [projectId], + personalOwnerId: null, + personalProjectId: null, + }; + } + return null; + }); + + const result = await call( + deleteItem, + { + memoryId, + memoryItemId: 104, + }, + { context }, + ); + + expect(result).toEqual({ deleted: true }); + expect(mocks.executeCommand).not.toHaveBeenCalled(); + expect(mocks.interceptWrite).toHaveBeenCalledTimes(1); + expect(mocks.interceptWrite).toHaveBeenCalledWith( + { + mode: "isolation", + projectId, + branchId: 77, + branchChangesetId: 88, + }, + "memory_item", + "104", + "DELETE", + expect.objectContaining({ + memoryId, + memoryItemId: 104, + scope: "PROJECT", + projectId, + }), + null, + expect.any(Function), + ); + expect(mocks.interceptWrite.mock.calls[0]?.[4]).not.toHaveProperty( + "reason", + ); + }); + + it("rejects project delete when user has no editor permission", async () => { + const context = createContext(); + + mocks.permissionCheck.mockResolvedValue(false); + mocks.executeQuery.mockImplementation(async (_ctx, query) => { + if (query === getMemoryAccessContext) { + return { + memoryId, + scope: "PROJECT", + projectIds: [projectId], + personalOwnerId: null, + personalProjectId: null, + }; + } + return null; + }); + + await expect( + call( + deleteItem, + { + memoryId, + memoryItemId: 103, + }, + { context }, + ), + ).rejects.toBeInstanceOf(ORPCError); + + expect(mocks.interceptWrite).not.toHaveBeenCalled(); + expect(mocks.executeCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app-api/src/__tests__/vcs-branch-isolation.test.ts b/apps/app-api/src/__tests__/vcs-branch-isolation.test.ts index 810bbe23c..e75937221 100644 --- a/apps/app-api/src/__tests__/vcs-branch-isolation.test.ts +++ b/apps/app-api/src/__tests__/vcs-branch-isolation.test.ts @@ -141,7 +141,6 @@ function makeTestAuthContext(userId: string, db: typeof testDb.client) { } // ─── Tests ──────────────────────────────────────────────────────────────────── - describe("VCS branch isolation — integration", () => { test("interceptWrite in isolation mode records to branch changeset without calling writeFn", async () => { const { projectId, userId } = await seedProject(); @@ -332,6 +331,112 @@ describe("VCS branch isolation — integration", () => { } } }); + + test("withBranchContext: branch from another project → BAD_REQUEST", async () => { + const { projectId, userId } = await seedProject(); + const other = await seedProject(); + + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId, + title: "Cross Project Branch Context Test PR", + body: "", + reviewers: [], + authorId: userId, + }); + + const ctx = makeTestAuthContext(userId, testDb.client); + const next = vi.fn().mockResolvedValue({ output: undefined, context: {} }); + + try { + await invokeWithBranchContext( + { context: ctx, next, errors: {}, path: [], signal: undefined }, + { projectId: other.projectId, branchId: pr.branchId }, + vi.fn(), + ); + expect.fail("Expected ORPCError BAD_REQUEST"); + } catch (err) { + expect(err).toBeInstanceOf(ORPCError); + if (err instanceof ORPCError) { + expect(err.code).toBe("BAD_REQUEST"); + } + } + }); + + test("withBranchContext: header branch without project header → BAD_REQUEST", async () => { + const { projectId, userId } = await seedProject(); + + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId, + title: "Header Branch Context Test PR", + body: "", + reviewers: [], + authorId: userId, + }); + + const ctx = { + ...makeTestAuthContext(userId, testDb.client), + helpers: { + getReqHeader: (name: string) => { + if (name === "x-branch-id") return String(pr.branchId); + return undefined; + }, + }, + }; + const next = vi.fn().mockResolvedValue({ output: undefined, context: {} }); + + try { + await invokeWithBranchContext( + { context: ctx, next, errors: {}, path: [], signal: undefined }, + {}, + vi.fn(), + ); + expect.fail("Expected ORPCError BAD_REQUEST"); + } catch (err) { + expect(err).toBeInstanceOf(ORPCError); + if (err instanceof ORPCError) { + expect(err.code).toBe("BAD_REQUEST"); + } + } + }); + + test("withBranchContext: header project mismatch → BAD_REQUEST", async () => { + const { projectId, userId } = await seedProject(); + const other = await seedProject(); + + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId, + title: "Header Branch Project Mismatch Test PR", + body: "", + reviewers: [], + authorId: userId, + }); + + const ctx = { + ...makeTestAuthContext(userId, testDb.client), + helpers: { + getReqHeader: (name: string) => { + if (name === "x-branch-id") return String(pr.branchId); + if (name === "x-branch-project-id") return other.projectId; + return undefined; + }, + }, + }; + const next = vi.fn().mockResolvedValue({ output: undefined, context: {} }); + + try { + await invokeWithBranchContext( + { context: ctx, next, errors: {}, path: [], signal: undefined }, + { projectId }, + vi.fn(), + ); + expect.fail("Expected ORPCError BAD_REQUEST"); + } catch (err) { + expect(err).toBeInstanceOf(ORPCError); + if (err instanceof ORPCError) { + expect(err.code).toBe("BAD_REQUEST"); + } + } + }); }); // ─── Direct mode — route integration ───────────────────────────────────────── diff --git a/apps/app-api/src/orpc/middleware/with-branch-context.ts b/apps/app-api/src/orpc/middleware/with-branch-context.ts index 7e4c4f31a..cab465840 100644 --- a/apps/app-api/src/orpc/middleware/with-branch-context.ts +++ b/apps/app-api/src/orpc/middleware/with-branch-context.ts @@ -17,11 +17,14 @@ type BranchAwareContext = { * @zh 提取并验证 branchId,将分支上下文注入到 context 中。 * branchId 存在时验证 branch 状态和权限,并注入 branchId/branchChangesetId/branchProjectId。 * branchId 不存在时,若 projectId 有 isolation_forced 则返回 403。 - * 当 input 中未提供 branchId 时,会尝试从 x-branch-id 请求头读取。 + * 当 input 中未提供 branchId 时,会尝试从请求头读取成对的 + * x-branch-id / x-branch-project-id,并校验分支与项目归属一致。 * @en Extracts and validates branchId, injecting branch context into the handler context. * When branchId is present, validates branch status and permissions. * When absent, returns 403 if the project has isolation_forced. - * Falls back to reading x-branch-id request header when branchId is not in input. + * When branchId is not in input, it can fall back to paired + * x-branch-id / x-branch-project-id request headers and validates that the + * branch belongs to the resolved project. */ export const withBranchContext = os .$context() @@ -30,16 +33,43 @@ export const withBranchContext = os { context, next }, input: { branchId?: number; projectId?: string }, ) => { - // Resolve branchId: prefer input, fall back to x-branch-id header + // Resolve branchId: prefer input, fall back to scoped branch headers const headerBranchId = context.helpers.getReqHeader("x-branch-id"); + const headerBranchProjectId = context.helpers.getReqHeader( + "x-branch-project-id", + ); const parsedHeader = headerBranchId !== undefined ? Number(headerBranchId) : undefined; - const branchId = - input.branchId ?? - (parsedHeader !== undefined && Number.isFinite(parsedHeader) + const parsedHeaderBranchId = + parsedHeader !== undefined && Number.isFinite(parsedHeader) ? parsedHeader - : undefined); - const { projectId } = input; + : undefined; + const branchIdSource: "input" | "header" | "none" = + input.branchId !== undefined + ? "input" + : parsedHeaderBranchId !== undefined + ? "header" + : "none"; + const branchId = + branchIdSource === "input" ? input.branchId : parsedHeaderBranchId; + + if (branchIdSource === "header" && headerBranchProjectId === undefined) { + throw new ORPCError("BAD_REQUEST", { + message: "x-branch-id requires x-branch-project-id", + }); + } + + if ( + branchIdSource === "header" && + input.projectId !== undefined && + headerBranchProjectId !== input.projectId + ) { + throw new ORPCError("BAD_REQUEST", { + message: "x-branch-project-id does not match request projectId", + }); + } + + const projectId = input.projectId ?? headerBranchProjectId; const { drizzleDB: { client: db }, auth, @@ -61,6 +91,12 @@ export const withBranchContext = os }); } + if (projectId !== undefined && branch.projectId !== projectId) { + throw new ORPCError("BAD_REQUEST", { + message: `Branch ${branchId} does not belong to project ${projectId}`, + }); + } + // Check editor permission on the branch's project const engine = getPermissionEngine(); const allowed = await engine.check( diff --git a/apps/app-api/src/orpc/routers/comment.ts b/apps/app-api/src/orpc/routers/comment.ts index dfb034c1e..f62bc4263 100644 --- a/apps/app-api/src/orpc/routers/comment.ts +++ b/apps/app-api/src/orpc/routers/comment.ts @@ -17,11 +17,15 @@ import { CommentTargetTypeSchema, } from "@cat/shared"; import { listWithOverlay } from "@cat/vcs"; +import { ORPCError } from "@orpc/server"; import * as z from "zod"; import { withBranchContext } from "@/orpc/middleware/with-branch-context"; import { authed } from "@/orpc/server"; -import { createVCSRouteHelper } from "@/utils/vcs-route-helper"; +import { + createVCSRouteHelper, + ensureBranchWriteContext, +} from "@/utils/vcs-route-helper"; export const comment = authed .input( @@ -36,7 +40,10 @@ export const comment = authed projectId: z.uuid().optional(), }), ) - .use(withBranchContext, (i) => ({ branchId: i.branchId })) + .use(withBranchContext, (i) => ({ + branchId: i.branchId, + projectId: i.projectId, + })) .output(CommentSchema) .handler(async ({ context, input }) => { const { @@ -44,15 +51,26 @@ export const comment = authed user, } = context; - if ( - context.branchId !== undefined && - context.branchChangesetId !== undefined - ) { - if (context.branchProjectId === undefined) { - throw new Error( - "branchProjectId missing when branch context is active", - ); + if (context.branchId !== undefined && input.projectId === undefined) { + throw new ORPCError("BAD_REQUEST", { + message: "projectId is required when branchId is provided", + }); + } + + if (context.branchId !== undefined) { + const branchWriteContext = await ensureBranchWriteContext({ + drizzle, + branchId: context.branchId, + branchChangesetId: context.branchChangesetId, + branchProjectId: context.branchProjectId, + }); + + if (!branchWriteContext) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid branch context for comment creation", + }); } + const { middleware } = createVCSRouteHelper(drizzle); const entityId = crypto.randomUUID(); const commentData = { @@ -63,12 +81,7 @@ export const comment = authed userId: user.id, }; return await middleware.interceptWrite( - { - mode: "isolation", - projectId: context.branchProjectId, - branchId: context.branchId, - branchChangesetId: context.branchChangesetId, - }, + branchWriteContext, "comment", entityId, "CREATE", @@ -128,15 +141,25 @@ export const getRootComments = authed pageIndex: z.int().nonnegative(), pageSize: z.int().positive(), branchId: z.int().optional(), + projectId: z.uuidv4().optional(), }), ) - .use(withBranchContext, (i) => ({ branchId: i.branchId })) + .use(withBranchContext, (i) => ({ + branchId: i.branchId, + projectId: i.projectId, + })) .output(z.array(CommentSchema)) .handler(async ({ context, input }) => { const { drizzleDB: { client: drizzle }, } = context; + if (context.branchId !== undefined && input.projectId === undefined) { + throw new ORPCError("BAD_REQUEST", { + message: "projectId is required when branchId is provided", + }); + } + const mainItems = await executeQuery( { db: drizzle }, listRootComments, diff --git a/apps/app-api/src/orpc/routers/content-node.ts b/apps/app-api/src/orpc/routers/content-node.ts index a9b78fba3..9d8898881 100644 --- a/apps/app-api/src/orpc/routers/content-node.ts +++ b/apps/app-api/src/orpc/routers/content-node.ts @@ -9,11 +9,15 @@ import { } from "@cat/domain"; import { ContentNodeSchema } from "@cat/shared"; import { readWithOverlay } from "@cat/vcs"; +import { ORPCError } from "@orpc/server"; import * as z from "zod"; import { withBranchContext } from "@/orpc/middleware/with-branch-context"; import { authed, checkContentNodePermission } from "@/orpc/server"; -import { createVCSRouteHelper } from "@/utils/vcs-route-helper"; +import { + createVCSRouteHelper, + ensureBranchWriteContext, +} from "@/utils/vcs-route-helper"; export const get = authed .input(z.object({ contentNodeId: z.uuidv4(), branchId: z.int().optional() })) @@ -31,13 +35,33 @@ export const get = authed >(drizzle, context.branchId, "content_node", input.contentNodeId); if (overlayEntry !== null) { if (overlayEntry.action === "DELETE") return null; + if ( + context.branchProjectId !== undefined && + overlayEntry.data.projectId !== context.branchProjectId + ) { + throw new ORPCError("BAD_REQUEST", { + message: `Branch ${context.branchId} does not belong to content node project ${overlayEntry.data.projectId}`, + }); + } return overlayEntry.data; } } - return executeQuery({ db: drizzle }, getContentNode, { + const currentNode = await executeQuery({ db: drizzle }, getContentNode, { id: input.contentNodeId, }); + + if ( + currentNode && + context.branchProjectId !== undefined && + currentNode.projectId !== context.branchProjectId + ) { + throw new ORPCError("BAD_REQUEST", { + message: `Branch ${context.branchId} does not belong to content node project ${currentNode.projectId}`, + }); + } + + return currentNode; }); export const del = authed @@ -50,28 +74,36 @@ export const del = authed drizzleDB: { client: drizzle }, } = context; - if ( - context.branchId !== undefined && - context.branchChangesetId !== undefined - ) { - if (context.branchProjectId === undefined) { - throw new Error( - "branchProjectId missing when branch context is active", - ); - } - - const { middleware } = createVCSRouteHelper(drizzle); + if (context.branchId !== undefined) { const currentNode = await executeQuery({ db: drizzle }, getContentNode, { id: input.contentNodeId, }); + if ( + currentNode && + context.branchProjectId !== undefined && + currentNode.projectId !== context.branchProjectId + ) { + throw new ORPCError("BAD_REQUEST", { + message: `Branch ${context.branchId} does not belong to content node project ${currentNode.projectId}`, + }); + } + + const branchWriteContext = await ensureBranchWriteContext({ + drizzle, + branchId: context.branchId, + branchChangesetId: context.branchChangesetId, + branchProjectId: context.branchProjectId, + }); + + if (!branchWriteContext) { + throw new Error("branch write context missing for branch delete"); + } + + const { middleware } = createVCSRouteHelper(drizzle); + await middleware.interceptWrite( - { - mode: "isolation", - projectId: context.branchProjectId, - branchId: context.branchId, - branchChangesetId: context.branchChangesetId, - } satisfies VCSContext, + branchWriteContext satisfies VCSContext, "content_node", input.contentNodeId, "DELETE", diff --git a/apps/app-api/src/orpc/routers/element.ts b/apps/app-api/src/orpc/routers/element.ts index 491c97c79..8b24ff862 100644 --- a/apps/app-api/src/orpc/routers/element.ts +++ b/apps/app-api/src/orpc/routers/element.ts @@ -97,6 +97,7 @@ export const getTranslationStatus = authed z.object({ elementId: z.int(), languageId: z.string(), + branchId: z.int().optional(), }), ) .use(checkElementPermission("viewer"), (i) => i.elementId) diff --git a/apps/app-api/src/orpc/routers/file.ts b/apps/app-api/src/orpc/routers/file.ts index dbae0a60a..c9ddd150f 100644 --- a/apps/app-api/src/orpc/routers/file.ts +++ b/apps/app-api/src/orpc/routers/file.ts @@ -39,7 +39,10 @@ import { checkContentNodePermission, checkPermission, } from "@/orpc/server"; -import { createVCSRouteHelper } from "@/utils/vcs-route-helper"; +import { + createVCSRouteHelper, + ensureBranchWriteContext, +} from "@/utils/vcs-route-helper"; const toJSONType = (value: unknown): JSONType => // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- VCS payloads must cross a JSON serialization boundary before being stored in changesets @@ -190,16 +193,7 @@ export const finishCreateFromFile = authed } // Isolation write: record file content-node creation in branch changeset - if ( - context.branchId !== undefined && - context.branchChangesetId !== undefined - ) { - if (context.branchProjectId === undefined) { - throw new Error( - "branchProjectId missing when branch context is active", - ); - } - + if (context.branchId !== undefined) { const rootNode = await executeQuery( { db: drizzle }, getProjectRootContentNode, @@ -225,18 +219,24 @@ export const finishCreateFromFile = authed }); } + const branchWriteContext = await ensureBranchWriteContext({ + drizzle, + branchId: context.branchId, + branchChangesetId: context.branchChangesetId, + branchProjectId: context.branchProjectId, + }); + + if (!branchWriteContext) { + throw new Error("branch write context missing for file import"); + } + const { middleware } = createVCSRouteHelper(drizzle); const timestamp = new Date().toISOString(); const entityId = randomUUID(); const relationId = randomUUID(); await middleware.interceptWrite( - { - mode: "isolation", - projectId: context.branchProjectId, - branchId: context.branchId, - branchChangesetId: context.branchChangesetId, - }, + branchWriteContext, "content_node", entityId, "CREATE", @@ -270,12 +270,7 @@ export const finishCreateFromFile = authed ); await middleware.interceptWrite( - { - mode: "isolation", - projectId: context.branchProjectId, - branchId: context.branchId, - branchChangesetId: context.branchChangesetId, - }, + branchWriteContext, "content_relation", relationId, "CREATE", diff --git a/apps/app-api/src/orpc/routers/glossary.ts b/apps/app-api/src/orpc/routers/glossary.ts index 5c45a081f..318ad0a74 100644 --- a/apps/app-api/src/orpc/routers/glossary.ts +++ b/apps/app-api/src/orpc/routers/glossary.ts @@ -42,7 +42,10 @@ import * as z from "zod"; import { withBranchContext } from "@/orpc/middleware/with-branch-context"; import { authed, checkPermission } from "@/orpc/server"; import { getGraphRuntime } from "@/utils/graph-runtime"; -import { createVCSRouteHelper } from "@/utils/vcs-route-helper"; +import { + createVCSRouteHelper, + ensureBranchWriteContext, +} from "@/utils/vcs-route-helper"; export const deleteTerm = authed .input( @@ -56,30 +59,39 @@ export const deleteTerm = authed .use(checkPermission("glossary", "editor"), (input) => input.termId.toString(), ) - .use(withBranchContext, (i) => ({ branchId: i.branchId })) + .use(withBranchContext, (i) => ({ + branchId: i.branchId, + projectId: i.projectId, + })) .output(z.void()) .handler(async ({ context, input }) => { const { drizzleDB: { client: drizzle }, } = context; - if ( - context.branchId !== undefined && - context.branchChangesetId !== undefined - ) { - if (context.branchProjectId === undefined) { - throw new Error( - "branchProjectId missing when branch context is active", - ); + if (context.branchId !== undefined && input.projectId === undefined) { + throw new ORPCError("BAD_REQUEST", { + message: "projectId is required when branchId is provided", + }); + } + + if (context.branchId !== undefined) { + const branchWriteContext = await ensureBranchWriteContext({ + drizzle, + branchId: context.branchId, + branchChangesetId: context.branchChangesetId, + branchProjectId: context.branchProjectId, + }); + + if (!branchWriteContext) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid branch context for glossary term deletion", + }); } + const { middleware } = createVCSRouteHelper(drizzle); await middleware.interceptWrite( - { - mode: "isolation", - projectId: context.branchProjectId, - branchId: context.branchId, - branchChangesetId: context.branchChangesetId, - }, + branchWriteContext, "term", String(input.termId), "DELETE", @@ -232,33 +244,42 @@ export const insertTerm = authed projectId: z.uuidv4().optional(), }), ) - .use(withBranchContext, (i) => ({ branchId: i.branchId })) + .use(withBranchContext, (i) => ({ + branchId: i.branchId, + projectId: i.projectId, + })) .output(z.void()) .handler(async ({ context, input }) => { const { pluginManager, user } = context; const { termsData, glossaryId } = input; - if ( - context.branchId !== undefined && - context.branchChangesetId !== undefined - ) { - if (context.branchProjectId === undefined) { - throw new Error( - "branchProjectId missing when branch context is active", - ); - } + if (context.branchId !== undefined && input.projectId === undefined) { + throw new ORPCError("BAD_REQUEST", { + message: "projectId is required when branchId is provided", + }); + } + + if (context.branchId !== undefined) { const { drizzleDB: { client: drizzle }, } = context; + const branchWriteContext = await ensureBranchWriteContext({ + drizzle, + branchId: context.branchId, + branchChangesetId: context.branchChangesetId, + branchProjectId: context.branchProjectId, + }); + + if (!branchWriteContext) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid branch context for glossary term creation", + }); + } + const { middleware } = createVCSRouteHelper(drizzle); const entityId = crypto.randomUUID(); await middleware.interceptWrite( - { - mode: "isolation", - projectId: context.branchProjectId, - branchId: context.branchId, - branchChangesetId: context.branchChangesetId, - }, + branchWriteContext, "term", entityId, "CREATE", @@ -511,15 +532,25 @@ export const getConceptSubjects = authed z.object({ glossaryId: z.string(), branchId: z.int().optional(), + projectId: z.uuidv4().optional(), }), ) - .use(withBranchContext, (i) => ({ branchId: i.branchId })) + .use(withBranchContext, (i) => ({ + branchId: i.branchId, + projectId: i.projectId, + })) .output(z.array(z.object({ id: z.int(), subject: z.string() }))) .handler(async ({ context, input }) => { const { drizzleDB: { client: drizzle }, } = context; + if (context.branchId !== undefined && input.projectId === undefined) { + throw new ORPCError("BAD_REQUEST", { + message: "projectId is required when branchId is provided", + }); + } + const mainItems = await executeQuery( { db: drizzle }, listGlossaryConceptSubjects, diff --git a/apps/app-api/src/orpc/routers/memory.ts b/apps/app-api/src/orpc/routers/memory.ts index 5544f0205..5a890fffa 100644 --- a/apps/app-api/src/orpc/routers/memory.ts +++ b/apps/app-api/src/orpc/routers/memory.ts @@ -3,29 +3,36 @@ import type { VCSContext } from "@cat/vcs"; import { countMemoryItems, createMemory as createMemoryCommand, + deleteMemoryItem, executeCommand, executeQuery, getElementWithChunkIds, getMemory, + getMemoryAccessContext, listAllLanguages, - listMemoryIdsByProject, + listEffectiveMemoryIdsByProject, + listMemoryItems, listMemoryItemIdsByElement, listOwnedMemories, listProjectMemories, } from "@cat/domain"; import { buildMemoryRecallBm25Capabilities, - collectMemoryRecallOp, + collectEffectiveMemoryRecallOp, nlpSegmentOp, recallContextRerankOp, } from "@cat/operations"; +import { getPermissionEngine } from "@cat/permissions"; import { MemorySchema } from "@cat/shared"; import { MemoryRecallBm25CapabilityDirectorySchema, MemoryRecallBm25CapabilityQuerySchema, } from "@cat/shared"; +import { ORPCError } from "@orpc/server"; import * as z from "zod"; +import type { Context } from "@/utils/context"; + import { withBranchContext } from "@/orpc/middleware/with-branch-context"; import { authed, @@ -33,7 +40,108 @@ import { checkElementPermission, checkPermission, } from "@/orpc/server"; -import { createVCSRouteHelper } from "@/utils/vcs-route-helper"; +import { + createVCSRouteHelper, + ensureBranchWriteContext, +} from "@/utils/vcs-route-helper"; + +type MemoryAccessContext = Awaited>; +type AuthedPrincipal = { + auth: NonNullable; + user: NonNullable; +}; + +type EffectiveMemoryIds = { + projectMemoryIds: string[]; + personalMemoryIds: string[]; + allMemoryIds: string[]; +}; + +const normalizeEffectiveMemoryIds = ( + input: EffectiveMemoryIds | string[], +): EffectiveMemoryIds => { + if (Array.isArray(input)) { + return { + projectMemoryIds: input, + personalMemoryIds: [], + allMemoryIds: input, + }; + } + + return input; +}; + +const canReadMemory = async ( + context: AuthedPrincipal, + accessContext: MemoryAccessContext, +) => { + if (!accessContext) return false; + + if (accessContext.scope === "PERSONAL") { + return accessContext.personalOwnerId === context.user.id; + } + + const engine = getPermissionEngine(); + + if ( + await engine.check( + context.auth, + { type: "memory", id: accessContext.memoryId }, + "viewer", + ) + ) { + return true; + } + + const projectChecks = await Promise.all( + accessContext.projectIds.map( + async (projectId) => + await engine.check( + context.auth, + { type: "project", id: projectId }, + "viewer", + ), + ), + ); + + return projectChecks.some(Boolean); +}; + +const canDeleteMemoryItem = async ( + context: AuthedPrincipal, + accessContext: MemoryAccessContext, +) => { + if (!accessContext) return false; + + if (accessContext.scope === "PERSONAL") { + return accessContext.personalOwnerId === context.user.id; + } + + const engine = getPermissionEngine(); + + if ( + await engine.check( + context.auth, + { type: "memory", id: accessContext.memoryId }, + "editor", + ) + ) { + return true; + } + + const projectChecks = await Promise.all( + accessContext.projectIds.map( + async (projectId) => + await engine.check( + context.auth, + { type: "project", id: projectId }, + "editor", + ), + ), + ); + + return projectChecks.some(Boolean); +}; export const create = authed .input( @@ -55,25 +163,34 @@ export const create = authed user, } = context; - if ( - context.branchId !== undefined && - context.branchChangesetId !== undefined - ) { - if (context.branchProjectId === undefined) { - throw new Error( - "branchProjectId missing when branch context is active", - ); + if (context.branchId !== undefined) { + if ( + input.projectIds === undefined || + input.projectIds.length !== 1 || + input.projectIds[0] !== context.branchProjectId + ) { + throw new ORPCError("BAD_REQUEST", { + message: + "Branch memory creation requires exactly one projectId matching the active branch project", + }); } + + const branchWriteContext = await ensureBranchWriteContext({ + drizzle, + branchId: context.branchId, + branchChangesetId: context.branchChangesetId, + branchProjectId: context.branchProjectId, + }); + + if (!branchWriteContext) { + throw new Error("branch write context missing for memory creation"); + } + const { middleware } = createVCSRouteHelper(drizzle); const entityId = crypto.randomUUID(); return await middleware.interceptWrite( - { - mode: "isolation", - projectId: context.branchProjectId, - branchId: context.branchId, - branchChangesetId: context.branchChangesetId, - }, + branchWriteContext, "memory_item", entityId, "CREATE", @@ -90,9 +207,9 @@ export const create = authed }, async () => ({ id: "00000000-0000-0000-0000-000000000000", - externalId: entityId, name: input.name, description: input.description ?? null, + scope: "PROJECT" as const, creatorId: user.id, createdAt: new Date(), updatedAt: new Date(), @@ -171,13 +288,27 @@ export const onNew = authed throw new Error(`Element ${elementId} not found`); } - const memoryIds = await executeQuery( + const effectiveMemoryIdsRaw = await executeQuery( { db: drizzle }, - listMemoryIdsByProject, - { projectId: element.projectId }, + listEffectiveMemoryIdsByProject, + { + projectId: element.projectId, + userId: context.user.id, + }, + ); + + const effectiveMemoryIds = normalizeEffectiveMemoryIds( + effectiveMemoryIdsRaw, ); - if (!element || memoryIds.length === 0) return; + const { projectMemoryIds, personalMemoryIds } = effectiveMemoryIds; + + if ( + !element || + (projectMemoryIds.length === 0 && personalMemoryIds.length === 0) + ) { + return; + } const [excludeMemoryItemIds, nlpResult] = await Promise.all([ executeQuery({ db: drizzle }, listMemoryItemIdsByElement, { @@ -194,12 +325,13 @@ export const onNew = authed { elementId, queryText: element.value, - memories: await collectMemoryRecallOp( + memories: await collectEffectiveMemoryRecallOp( { text: element.value, sourceLanguageId: element.languageId, translationLanguageId, - memoryIds, + projectMemoryIds, + personalMemoryIds, chunkIds: element.chunkIds, minSimilarity: minConfidence, maxAmount, @@ -246,16 +378,243 @@ export const get = authed memoryId: z.uuidv4(), }), ) - .use(checkPermission("memory", "viewer"), (i) => i.memoryId) .output(MemorySchema.nullable()) .handler(async ({ context, input }) => { const { drizzleDB: { client: drizzle }, + auth, + user, } = context; + const accessContext = await executeQuery( + { db: drizzle }, + getMemoryAccessContext, + { memoryId: input.memoryId }, + ); + + if (!accessContext) { + return null; + } + + if (!(await canReadMemory({ auth, user }, accessContext))) { + throw new ORPCError("FORBIDDEN"); + } + return await executeQuery({ db: drizzle }, getMemory, input); }); +export const listItems = authed + .input( + z.object({ + memoryId: z.uuidv4(), + pageIndex: z.int().min(1).default(1), + pageSize: z.int().min(1).max(100).default(20), + searchText: z.string().trim().min(1).optional(), + }), + ) + .output( + z.object({ + total: z.int().min(0), + items: z.array( + z.object({ + id: z.int(), + memoryId: z.uuidv4(), + source: z.string(), + translation: z.string(), + sourceLanguageId: z.string(), + translationLanguageId: z.string(), + sourceElementId: z.int().nullable(), + translationId: z.int().nullable(), + creatorId: z.uuidv4().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + sourceScope: z.enum(["PROJECT", "PERSONAL"]), + promotedTargetMemoryItemId: z.int().nullable(), + }), + ), + }), + ) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: drizzle }, + auth, + user, + } = context; + + const accessContext = await executeQuery( + { db: drizzle }, + getMemoryAccessContext, + { memoryId: input.memoryId }, + ); + + if (!accessContext) { + throw new ORPCError("NOT_FOUND"); + } + + if (!(await canReadMemory({ auth, user }, accessContext))) { + throw new ORPCError("FORBIDDEN"); + } + + return await executeQuery({ db: drizzle }, listMemoryItems, input); + }); + +export const deleteItem = authed + .input( + z.object({ + memoryItemId: z.int(), + memoryId: z.uuidv4(), + reason: z.string().trim().max(500).optional(), + branchId: z.int().optional(), + }), + ) + .use(withBranchContext, (input) => ({ branchId: input.branchId })) + .output(z.object({ deleted: z.boolean() })) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: drizzle }, + auth, + user, + } = context; + + const accessContext = await executeQuery( + { db: drizzle }, + getMemoryAccessContext, + { memoryId: input.memoryId }, + ); + + if (!accessContext) { + return { deleted: false }; + } + + if (!(await canDeleteMemoryItem({ auth, user }, accessContext))) { + throw new ORPCError("FORBIDDEN"); + } + + if (accessContext.scope === "PROJECT") { + const projectId = accessContext.projectIds[0]; + if (!projectId) { + throw new ORPCError("BAD_REQUEST", { + message: "Project-scoped memory is missing project binding", + }); + } + + const { middleware } = createVCSRouteHelper(drizzle); + const payload = { + memoryItemId: input.memoryItemId, + memoryId: input.memoryId, + deletedById: user.id, + scope: accessContext.scope, + projectId, + ...(input.reason !== undefined ? { reason: input.reason } : {}), + }; + + if (context.branchId !== undefined) { + if (context.branchProjectId === undefined) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid branch context for memory deletion", + }); + } + + if (!accessContext.projectIds.includes(context.branchProjectId)) { + throw new ORPCError("FORBIDDEN", { + message: "Branch does not belong to the memory project", + }); + } + + const branchWriteContext = await ensureBranchWriteContext({ + drizzle, + branchId: context.branchId, + branchChangesetId: context.branchChangesetId, + branchProjectId: context.branchProjectId, + }); + + if (!branchWriteContext) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid branch context for memory deletion", + }); + } + + await middleware.interceptWrite( + branchWriteContext, + "memory_item", + String(input.memoryItemId), + "DELETE", + payload, + null, + async () => undefined, + ); + + return { deleted: true }; + } + + const vcsCtx: VCSContext = { + mode: "direct", + projectId, + createdBy: user.id, + }; + + const result = await middleware.interceptWrite( + vcsCtx, + "memory_item", + String(input.memoryItemId), + "DELETE", + payload, + { deleted: true }, + async () => + await executeCommand({ db: drizzle }, deleteMemoryItem, { + memoryItemId: input.memoryItemId, + deletedById: user.id, + scope: accessContext.scope, + projectId, + reason: input.reason, + }), + ); + + return { deleted: result.deleted }; + } + + const result = await executeCommand({ db: drizzle }, deleteMemoryItem, { + memoryItemId: input.memoryItemId, + deletedById: user.id, + scope: accessContext.scope, + projectId: accessContext.projectIds[0] ?? accessContext.personalProjectId, + reason: input.reason, + }); + + return { deleted: result.deleted }; + }); + +export const getMyProjectMemory = authed + .input( + z.object({ + projectId: z.uuidv4(), + }), + ) + .use(checkPermission("project", "viewer"), (input) => input.projectId) + .output(MemorySchema.nullable()) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: drizzle }, + user, + } = context; + + const idsRaw = await executeQuery( + { db: drizzle }, + listEffectiveMemoryIdsByProject, + { + projectId: input.projectId, + userId: user.id, + }, + ); + + const ids = normalizeEffectiveMemoryIds(idsRaw); + + const memoryId = ids.personalMemoryIds[0]; + if (!memoryId) return null; + + return await executeQuery({ db: drizzle }, getMemory, { memoryId }); + }); + export const getProjectOwned = authed .input( z.object({ @@ -317,20 +676,32 @@ export const searchByText = authed maxAmount, } = input; - const memoryIds = await executeQuery( + const effectiveMemoryIdsRaw = await executeQuery( { db: drizzle }, - listMemoryIdsByProject, - { projectId }, + listEffectiveMemoryIdsByProject, + { + projectId, + userId: context.user.id, + }, ); - if (memoryIds.length === 0) return; + const effectiveMemoryIds = normalizeEffectiveMemoryIds( + effectiveMemoryIdsRaw, + ); + + const { projectMemoryIds, personalMemoryIds } = effectiveMemoryIds; + + if (projectMemoryIds.length === 0 && personalMemoryIds.length === 0) { + return; + } - const memories = await collectMemoryRecallOp( + const memories = await collectEffectiveMemoryRecallOp( { text, sourceLanguageId, translationLanguageId, - memoryIds, + projectMemoryIds, + personalMemoryIds, chunkIds: [], minSimilarity: minConfidence, maxAmount, diff --git a/apps/app-api/src/orpc/routers/permission.spec.ts b/apps/app-api/src/orpc/routers/permission.spec.ts new file mode 100644 index 000000000..6c6121863 --- /dev/null +++ b/apps/app-api/src/orpc/routers/permission.spec.ts @@ -0,0 +1,157 @@ +import { createAuthedTestContext } from "@cat/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Context } from "@/utils/context"; + +const mocks = vi.hoisted(() => ({ + determineWriteMode: vi.fn(), + getPermissionEngine: vi.fn(() => ({ name: "engine" })), +})); + +vi.mock("@cat/permissions", () => ({ + determineWriteMode: mocks.determineWriteMode, + getPermissionEngine: mocks.getPermissionEngine, +})); + +import { getProjectWriteMode } from "./permission"; + +type ProcedureInternal = { + handler: (options: { + context: Context; + input: unknown; + errors: Record; + path: string[]; + signal: AbortSignal | undefined; + }) => Promise; +}; + +const noop = (): undefined => undefined; + +const getProcedureInternal = (procedure: unknown): ProcedureInternal => { + if (typeof procedure !== "object" || procedure === null) { + throw new TypeError("Expected an oRPC procedure object"); + } + + const internal = Reflect.get(procedure, "~orpc"); + if (typeof internal !== "object" || internal === null) { + throw new TypeError("Expected oRPC internals on the procedure"); + } + + const handler = Reflect.get(internal, "handler"); + if (typeof handler !== "function") { + throw new TypeError("Expected oRPC handler function"); + } + + return { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- narrow test-only access to oRPC internals + handler: handler as ProcedureInternal["handler"], + }; +}; + +const invokeHandler = async ( + procedure: unknown, + context: Context, + input: unknown, +) => { + const internal = getProcedureInternal(procedure); + + return await internal.handler({ + context, + input, + errors: {}, + path: [], + signal: undefined, + }); +}; + +// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- bounded fake DB for unit test context +const fakeDb = null as unknown as Context["drizzleDB"]["client"]; + +// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- bounded fake drizzle DB for unit test context +const fakeDrizzleDb = { + client: fakeDb, + connect: async () => { + /* noop */ + }, + disconnect: async () => { + /* noop */ + }, + ping: async () => { + /* noop */ + }, + migrate: async () => undefined, +} as Context["drizzleDB"]; + +const createMockContext = (): Context => { + const base = createAuthedTestContext( + { + id: "11111111-1111-4111-8111-111111111111", + email: "permission-router@test.local", + name: "Permission Router Tester", + emailVerified: true, + avatarFileId: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + }, + { + drizzleDB: fakeDrizzleDb, + helpers: { + setCookie: noop, + delCookie: noop, + getCookie: (name) => (name === "csrfToken" ? "csrf-token" : null), + getQueryParam: () => undefined, + getReqHeader: (name) => + name === "x-csrf-token" ? "csrf-token" : undefined, + setResHeader: noop, + }, + }, + ); + + return { + ...base, + auth: { + subjectType: "user", + subjectId: "11111111-1111-4111-8111-111111111111", + systemRoles: [], + scopes: null, + traceId: undefined, + ip: undefined, + userAgent: undefined, + }, + csrfToken: "csrf-token", + isSSR: false, + isWebSocket: false, + requestSignal: new AbortController().signal, + }; +}; + +describe("permission.getProjectWriteMode", () => { + const projectId = "22222222-2222-4222-8222-222222222222"; + + beforeEach(() => { + mocks.determineWriteMode.mockReset(); + mocks.getPermissionEngine.mockClear(); + }); + + it.each(["direct", "isolation", "no_access"] as const)( + "returns %s from determineWriteMode", + async (mode) => { + mocks.determineWriteMode.mockResolvedValue(mode); + + const output = await invokeHandler( + getProjectWriteMode, + createMockContext(), + { projectId }, + ); + + expect(output).toBe(mode); + expect(mocks.determineWriteMode).toHaveBeenCalledWith( + { name: "engine" }, + expect.objectContaining({ + subjectId: "11111111-1111-4111-8111-111111111111", + }), + projectId, + ); + }, + ); +}); diff --git a/apps/app-api/src/orpc/routers/permission.ts b/apps/app-api/src/orpc/routers/permission.ts index fe58da757..12804d536 100644 --- a/apps/app-api/src/orpc/routers/permission.ts +++ b/apps/app-api/src/orpc/routers/permission.ts @@ -1,4 +1,4 @@ -import { getPermissionEngine } from "@cat/permissions"; +import { determineWriteMode, getPermissionEngine } from "@cat/permissions"; import { ObjectTypeSchema, PermissionCheckSchema, @@ -51,6 +51,18 @@ export const listMyPermissionsOn = authed .map((s) => s.relation); }); +/** + * @zh 查询当前用户对项目的写入模式。 + * @en Query the current user's write mode on a project. + */ +export const getProjectWriteMode = authed + .input(z.object({ projectId: z.uuidv4() })) + .output(z.enum(["direct", "isolation", "no_access"])) + .handler(async ({ context, input }) => { + const engine = getPermissionEngine(); + return await determineWriteMode(engine, context.auth, input.projectId); + }); + /** * 授予权限元组(仅 system#admin 可操作)。 */ diff --git a/apps/app-api/src/orpc/routers/qa-review.spec.ts b/apps/app-api/src/orpc/routers/qa-review.spec.ts new file mode 100644 index 000000000..aed1b889c --- /dev/null +++ b/apps/app-api/src/orpc/routers/qa-review.spec.ts @@ -0,0 +1,459 @@ +import { createAuthedTestContext } from "@cat/test-utils"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest"; + +import type { Context } from "@/utils/context"; + +const mocks = vi.hoisted(() => ({ + executeCommand: vi.fn(), + executeQuery: vi.fn(), + interceptWrite: vi.fn(), + promoteApprovedTranslationMemoryOp: vi.fn(), +})); + +vi.mock("@cat/operations", () => ({ + promoteApprovedTranslationMemoryOp: mocks.promoteApprovedTranslationMemoryOp, +})); + +vi.mock("@cat/domain", async () => { + const actual = + await vi.importActual("@cat/domain"); + + return { + ...actual, + executeCommand: mocks.executeCommand, + executeQuery: mocks.executeQuery, + }; +}); + +vi.mock("@cat/shared", async () => { + const actual = + await vi.importActual("@cat/shared"); + return { + ...actual, + QaReviewActionResultSchema: { parse: (input: unknown) => input }, + }; +}); + +vi.mock("@/utils/vcs-route-helper", async () => { + const actual = await vi.importActual< + typeof import("@/utils/vcs-route-helper") + >("@/utils/vcs-route-helper"); + + return { + ...actual, + createVCSRouteHelper: vi.fn(() => ({ + middleware: { + interceptWrite: mocks.interceptWrite, + }, + })), + }; +}); + +type ProcedureInternal = { + handler: (options: { + context: Context; + input: unknown; + errors: Record; + path: string[]; + signal: AbortSignal | undefined; + }) => Promise; +}; + +const getProcedureInternal = (procedure: unknown): ProcedureInternal => { + if (typeof procedure !== "object" || procedure === null) { + throw new TypeError("Expected an oRPC procedure object"); + } + + const internal = Reflect.get(procedure, "~orpc"); + if (typeof internal !== "object" || internal === null) { + throw new TypeError("Expected oRPC internals on the procedure"); + } + + const handler = Reflect.get(internal, "handler"); + if (typeof handler !== "function") { + throw new TypeError("Expected oRPC handler function"); + } + + return { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- narrow boundary for oRPC internals in unit tests + handler: handler as ProcedureInternal["handler"], + }; +}; + +const invokeHandler = async ( + procedure: unknown, + context: Context, + input: unknown, +): Promise => { + const internal = getProcedureInternal(procedure); + + return await internal.handler({ + context, + input, + errors: {}, + path: [], + signal: undefined, + }); +}; + +import { + createChangeset, + getFirstQaReviewableElement, + listTranslationsByIds, + submitQaReviewAction, +} from "@cat/domain"; + +import { submitAction } from "./qa-review"; + +const noop = (): undefined => undefined; + +const createMockContext = (input: { + projectId: string; + branchId?: number | null; + includeBranchContext?: boolean; + branchChangesetId?: number; +}): Context => { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- bounded test double for oRPC handler tests + const fakeDbClient = { + transaction: vi.fn( + async (fn: (tx: unknown) => Promise) => await fn({}), + ), + } as unknown as Context["drizzleDB"]["client"]; + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- bounded test double for oRPC handler tests + const fakeDrizzleDb = { + client: fakeDbClient, + connect: async () => { + /* noop */ + }, + disconnect: async () => { + /* noop */ + }, + ping: async () => { + /* noop */ + }, + migrate: async () => undefined, + } as Context["drizzleDB"]; + + const baseContext = createAuthedTestContext( + { + id: "11111111-1111-4111-8111-111111111111", + email: "qa-router@test.local", + name: "QA Router Tester", + emailVerified: true, + avatarFileId: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + }, + { + drizzleDB: fakeDrizzleDb, + helpers: { + setCookie: noop, + delCookie: noop, + getCookie: (name) => (name === "csrfToken" ? "csrf-token" : null), + getQueryParam: () => undefined, + getReqHeader: (name) => + name === "x-csrf-token" ? "csrf-token" : undefined, + setResHeader: noop, + }, + }, + ); + + const context: Context = { + ...baseContext, + auth: { + subjectType: "user", + subjectId: "11111111-1111-4111-8111-111111111111", + systemRoles: [], + scopes: null, + traceId: undefined, + ip: undefined, + userAgent: undefined, + }, + csrfToken: "csrf-token", + isSSR: false, + isWebSocket: false, + requestSignal: new AbortController().signal, + }; + + if (input.includeBranchContext) { + return { + ...context, + branchId: input.branchId ?? undefined, + branchProjectId: input.projectId, + branchChangesetId: input.branchChangesetId, + }; + } + + return context; +}; + +const baseInput = { + projectId: "22222222-2222-4222-8222-222222222222", + languageId: "zh-Hans", + branchId: null, + elementId: 1996, + translationId: 7001, + queueItemId: 9001, + action: "APPROVE" as const, + expectedVersion: 3, + noteBody: "Looks good", + overrideBlocking: false, + overrideReason: undefined, + navigation: { + afterElementId: 1996, + pageSize: 16, + }, +}; + +describe("qaReview.submitAction handler", () => { + beforeEach(() => { + mocks.executeCommand.mockReset(); + mocks.executeQuery.mockReset(); + mocks.interceptWrite.mockReset(); + mocks.promoteApprovedTranslationMemoryOp.mockReset(); + mocks.promoteApprovedTranslationMemoryOp.mockResolvedValue({ + projectMemoryIds: ["33333333-3333-4333-8333-333333333333"], + promotedMemoryItemIds: [42], + noProjectMemoryTarget: false, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("updates main approval without writing branch overlay entries", async () => { + mocks.executeCommand.mockResolvedValueOnce({ + decisionId: 101, + annotationId: 102, + queueItemId: 9001, + queueStatus: "RESOLVED", + approvedTranslationId: 7001, + affectedSiblingQueueItemIds: [9002], + branchApprovalOverlayMutations: [], + }); + + mocks.executeQuery.mockImplementationOnce( + async (_ctx: unknown, query: unknown) => { + if (query === getFirstQaReviewableElement) { + return { elementId: 2001 }; + } + return null; + }, + ); + + const output = await invokeHandler( + submitAction, + createMockContext({ projectId: baseInput.projectId }), + baseInput, + ); + + expect(mocks.executeCommand).toHaveBeenCalledWith( + { db: {} }, + submitQaReviewAction, + expect.objectContaining({ + reviewerId: "11111111-1111-4111-8111-111111111111", + branchId: null, + }), + ); + expect(mocks.interceptWrite).not.toHaveBeenCalled(); + expect(mocks.promoteApprovedTranslationMemoryOp).toHaveBeenCalledWith({ + translationId: 7001, + approvedById: "11111111-1111-4111-8111-111111111111", + }); + expect(output).toMatchObject({ + decisionId: 101, + queueItemId: 9001, + nextTarget: { kind: "element", elementId: 2001 }, + }); + }); + + it("writes branch overlay entries on branch approval and keeps main approval untouched", async () => { + mocks.executeCommand.mockResolvedValueOnce({ + decisionId: 201, + annotationId: null, + queueItemId: 9101, + queueStatus: "RESOLVED", + approvedTranslationId: 7101, + affectedSiblingQueueItemIds: [9102], + branchApprovalOverlayMutations: [ + { translationId: 7101, approved: true }, + { translationId: 7102, approved: false }, + ], + }); + + (mocks.executeQuery as Mock).mockImplementation( + async (_ctx: unknown, query: unknown) => { + if (query === listTranslationsByIds) { + return [ + { + translatableElementId: 1996, + text: "Candidate", + translatorId: "11111111-1111-4111-8111-111111111111", + createdAt: new Date("2024-02-02T00:00:00.000Z"), + }, + ]; + } + if (query === getFirstQaReviewableElement) { + return null; + } + return null; + }, + ); + + const output = await invokeHandler( + submitAction, + createMockContext({ + projectId: baseInput.projectId, + branchId: 77, + includeBranchContext: true, + branchChangesetId: 123, + }), + { + ...baseInput, + branchId: 77, + }, + ); + + expect(mocks.interceptWrite).toHaveBeenCalledTimes(2); + expect(mocks.promoteApprovedTranslationMemoryOp).not.toHaveBeenCalled(); + expect(mocks.interceptWrite).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + mode: "isolation", + branchId: 77, + branchChangesetId: 123, + }), + "translation", + "7101", + "UPDATE", + expect.objectContaining({ approved: false }), + expect.objectContaining({ approved: true }), + expect.any(Function), + ); + expect(output).toMatchObject({ + decisionId: 201, + nextTarget: { kind: "empty" }, + }); + }); + + it("creates a branch changeset lazily before writing QA approval overlays", async () => { + mocks.executeCommand.mockImplementation( + async (_ctx: unknown, command: unknown) => { + if (command === submitQaReviewAction) { + return { + decisionId: 401, + annotationId: null, + queueItemId: 9301, + queueStatus: "RESOLVED", + approvedTranslationId: 7301, + affectedSiblingQueueItemIds: [], + branchApprovalOverlayMutations: [ + { translationId: 7301, approved: true }, + ], + }; + } + + if (command === createChangeset) { + return { id: 456 }; + } + + return null; + }, + ); + + (mocks.executeQuery as Mock).mockImplementation( + async (_ctx: unknown, query: unknown) => { + if (query === listTranslationsByIds) { + return [ + { + translatableElementId: 1996, + text: "Candidate", + translatorId: "11111111-1111-4111-8111-111111111111", + createdAt: new Date("2024-02-02T00:00:00.000Z"), + }, + ]; + } + + if (query === getFirstQaReviewableElement) { + return null; + } + + return null; + }, + ); + + await invokeHandler( + submitAction, + createMockContext({ + projectId: baseInput.projectId, + branchId: 66, + includeBranchContext: true, + }), + { + ...baseInput, + branchId: 66, + }, + ); + + expect(mocks.executeCommand).toHaveBeenNthCalledWith( + 2, + { db: {} }, + createChangeset, + { + projectId: baseInput.projectId, + branchId: 66, + status: "PENDING", + }, + ); + expect(mocks.interceptWrite).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "isolation", + branchId: 66, + branchChangesetId: 456, + }), + "translation", + "7301", + "UPDATE", + expect.objectContaining({ approved: false }), + expect.objectContaining({ approved: true }), + expect.any(Function), + ); + }); + + it("throws BAD_REQUEST when branch mode lacks branch context", async () => { + mocks.executeCommand.mockResolvedValueOnce({ + decisionId: 301, + annotationId: null, + queueItemId: 9201, + queueStatus: "RESOLVED", + approvedTranslationId: 7201, + affectedSiblingQueueItemIds: [], + branchApprovalOverlayMutations: [{ translationId: 7201, approved: true }], + }); + + await expect( + invokeHandler( + submitAction, + createMockContext({ + projectId: baseInput.projectId, + branchId: 88, + includeBranchContext: false, + }), + { + ...baseInput, + branchId: 88, + }, + ), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); +}); diff --git a/apps/app-api/src/orpc/routers/qa-review.ts b/apps/app-api/src/orpc/routers/qa-review.ts index f7902070e..89f01b23b 100644 --- a/apps/app-api/src/orpc/routers/qa-review.ts +++ b/apps/app-api/src/orpc/routers/qa-review.ts @@ -1,16 +1,60 @@ import { + CountQaReviewableElementsQuerySchema, CountQaReviewQueueItemsQuerySchema, + GetFirstQaReviewableElementQuerySchema, + GetQaReviewableElementDetailQuerySchema, GetQaReviewQueueItemDetailQuerySchema, + ListQaReviewableElementsQuerySchema, ListQaReviewQueueItemsQuerySchema, + countQaReviewableElements, countQaReviewQueueItems, + executeCommand, executeQuery, + getFirstQaReviewableElement, + getQaReviewableElementDetail, getQaReviewQueueItemDetail, listQaReviewQueueItems, + listQaReviewableElements, + listTranslationsByIds, + submitQaReviewAction, } from "@cat/domain"; +import { promoteApprovedTranslationMemoryOp } from "@cat/operations"; +import { serverLogger as logger } from "@cat/server-shared"; +import { + QaReviewActionResultSchema, + SubmitQaReviewActionInputSchema, + assertSingleNonNullish, +} from "@cat/shared"; +import { EditorOverlayTranslationStateSchema } from "@cat/vcs"; import { ORPCError } from "@orpc/client"; import * as z from "zod"; +import { withBranchContext } from "@/orpc/middleware/with-branch-context"; import { authed, checkPermission } from "@/orpc/server"; +import { + createVCSRouteHelper, + ensureBranchWriteContext, +} from "@/utils/vcs-route-helper"; + +const loadTranslationOverlayPayload = async ( + db: Parameters[0]["db"], + translationId: number, + languageId: string, +) => { + const row = assertSingleNonNullish( + await executeQuery({ db }, listTranslationsByIds, { + translationIds: [translationId], + }), + ); + + return { + translatableElementId: row.translatableElementId, + languageId, + text: row.text, + translatorId: row.translatorId, + createdAt: row.createdAt.toISOString(), + }; +}; /** * @zh 按项目编辑器作用域列出 QA 审校队列项。 @@ -67,3 +111,201 @@ export const getQueueItem = authed return detail; }); + +/** + * @zh 按元素聚合列出 QA 可审校集合。 + * @en List QA reviewable items aggregated by element. + */ +export const listReviewableElements = authed + .input(ListQaReviewableElementsQuerySchema) + .use(checkPermission("project", "viewer"), (input) => input.projectId) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: db }, + } = context; + + return await executeQuery({ db }, listQaReviewableElements, input); + }); + +/** + * @zh 统计可审校元素数量。 + * @en Count reviewable elements. + */ +export const countReviewableElements = authed + .input(CountQaReviewableElementsQuerySchema) + .use(checkPermission("project", "viewer"), (input) => input.projectId) + .output(z.int().min(0)) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: db }, + } = context; + + return await executeQuery({ db }, countQaReviewableElements, input); + }); + +/** + * @zh 获取单个可审校元素详情。 + * @en Get a reviewable element detail. + */ +export const getReviewableElement = authed + .input(GetQaReviewableElementDetailQuerySchema) + .use(checkPermission("project", "viewer"), (input) => input.projectId) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: db }, + } = context; + + const detail = await executeQuery( + { db }, + getQaReviewableElementDetail, + input, + ); + if (!detail || detail.projectId !== input.projectId) { + throw new ORPCError("NOT_FOUND"); + } + + return detail; + }); + +/** + * @zh 获取首个(或下一个)可审校元素。 + * @en Get the first (or next) reviewable element. + */ +export const getFirstReviewableElement = authed + .input(GetFirstQaReviewableElementQuerySchema) + .use(checkPermission("project", "viewer"), (input) => input.projectId) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: db }, + } = context; + + return await executeQuery({ db }, getFirstQaReviewableElement, input); + }); + +/** + * @zh 提交 QA 工作台动作,并在分支模式下写入 translation overlay。 + * @en Submit QA workbench action and write translation overlay in branch mode. + */ +export const submitAction = authed + .input(SubmitQaReviewActionInputSchema) + .use(checkPermission("project", "editor"), (input) => input.projectId) + .use(withBranchContext, (input) => ({ + branchId: input.branchId ?? undefined, + projectId: input.projectId, + })) + .output(QaReviewActionResultSchema) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: drizzle }, + user, + } = context; + + const result = await drizzle.transaction(async (tx) => { + const commandResult = await executeCommand( + { db: tx }, + submitQaReviewAction, + { + ...input, + branchId: input.branchId ?? null, + reviewerId: user.id, + }, + ); + + if (input.branchId !== null && input.branchId !== undefined) { + const branchWriteContext = await ensureBranchWriteContext({ + drizzle: tx, + branchId: context.branchId, + branchChangesetId: context.branchChangesetId, + branchProjectId: context.branchProjectId, + }); + + if (!branchWriteContext) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid branch context for QA approval.", + }); + } + + const { middleware } = createVCSRouteHelper(tx); + const timestamp = new Date().toISOString(); + + await Promise.all( + commandResult.branchApprovalOverlayMutations.map(async (mutation) => { + const translationRow = await loadTranslationOverlayPayload( + tx, + mutation.translationId, + input.languageId, + ); + + await middleware.interceptWrite( + branchWriteContext, + "translation", + String(mutation.translationId), + "UPDATE", + EditorOverlayTranslationStateSchema.parse({ + ...translationRow, + approved: !mutation.approved, + updatedAt: timestamp, + }), + EditorOverlayTranslationStateSchema.parse({ + ...translationRow, + approved: mutation.approved, + updatedAt: timestamp, + }), + async () => undefined, + ); + }), + ); + } + + const next = await executeQuery({ db: tx }, getFirstQaReviewableElement, { + projectId: input.projectId, + languageToId: input.languageId, + branchId: input.branchId ?? undefined, + contentNodeIds: [], + searchQuery: "", + statusFilter: "all", + sortMode: "structure", + pageSize: input.navigation?.pageSize ?? 16, + queueFilters: { + queueStatus: [], + riskBucket: [], + findingAction: [], + includeResolved: false, + }, + afterElementId: input.navigation?.afterElementId ?? input.elementId, + }); + + return { + decisionId: commandResult.decisionId, + annotationId: commandResult.annotationId, + queueItemId: commandResult.queueItemId, + queueStatus: commandResult.queueStatus, + approvedTranslationId: commandResult.approvedTranslationId, + affectedSiblingQueueItemIds: commandResult.affectedSiblingQueueItemIds, + nextTarget: next + ? ({ kind: "element", elementId: next.elementId } as const) + : ({ kind: "empty" } as const), + }; + }); + + if ( + (input.branchId === null || input.branchId === undefined) && + result.approvedTranslationId !== null + ) { + try { + await promoteApprovedTranslationMemoryOp({ + translationId: result.approvedTranslationId, + approvedById: user.id, + }); + } catch (error) { + logger + .withSituation("RPC") + .error( + error, + `qa-review promotion failed: ${result.approvedTranslationId}`, + ); + } + } + + return result; + }); diff --git a/apps/app-api/src/orpc/routers/router.contract.test.ts b/apps/app-api/src/orpc/routers/router.contract.test.ts index 55d88801d..6859bc5f7 100644 --- a/apps/app-api/src/orpc/routers/router.contract.test.ts +++ b/apps/app-api/src/orpc/routers/router.contract.test.ts @@ -198,6 +198,14 @@ describe("app router contract", () => { expect(legacyListAlias in router.project).toBe(false); }); + test("qaReview exposes workbench APIs", () => { + expect(router.qaReview).toHaveProperty("listReviewableElements"); + expect(router.qaReview).toHaveProperty("countReviewableElements"); + expect(router.qaReview).toHaveProperty("getReviewableElement"); + expect(router.qaReview).toHaveProperty("getFirstReviewableElement"); + expect(router.qaReview).toHaveProperty("submitAction"); + }); + test("translation.onCreate accepts editor scope input and checks project viewer permission", async () => { const context = createMockContext(); diff --git a/apps/app-api/src/orpc/routers/suggestion.ts b/apps/app-api/src/orpc/routers/suggestion.ts index c2b41cf14..40d285ace 100644 --- a/apps/app-api/src/orpc/routers/suggestion.ts +++ b/apps/app-api/src/orpc/routers/suggestion.ts @@ -1,12 +1,12 @@ import { executeQuery, getElementWithChunkIds, - listMemoryIdsByProject, + listEffectiveMemoryIdsByProject, listMemoryItemIdsByElement, listProjectGlossaryIds, } from "@cat/domain"; import { - collectMemoryRecallOp, + collectEffectiveMemoryRecallOp, createSuggestionCollector, llmTranslateOp, nlpSegmentOp, @@ -31,6 +31,26 @@ import * as z from "zod"; import { authed, checkElementPermission } from "@/orpc/server"; +type EffectiveMemoryIds = { + projectMemoryIds: string[]; + personalMemoryIds: string[]; + allMemoryIds: string[]; +}; + +const normalizeEffectiveMemoryIds = ( + input: EffectiveMemoryIds | string[], +): EffectiveMemoryIds => { + if (Array.isArray(input)) { + return { + projectMemoryIds: input, + personalMemoryIds: [], + allMemoryIds: input, + }; + } + + return input; +}; + export const onNew = authed .input( z.object({ @@ -74,15 +94,23 @@ export const onNew = authed throw new Error(`Element with ID ${elementId} not found`); } - const [glossaryIds, memoryIds] = await Promise.all([ + const [glossaryIds, effectiveMemoryIdsRaw] = await Promise.all([ executeQuery({ db: drizzle }, listProjectGlossaryIds, { projectId: element.projectId, }), - executeQuery({ db: drizzle }, listMemoryIdsByProject, { + executeQuery({ db: drizzle }, listEffectiveMemoryIdsByProject, { projectId: element.projectId, + userId: context.user.id, }), ]); + const effectiveMemoryIds = normalizeEffectiveMemoryIds( + effectiveMemoryIdsRaw, + ); + + const { projectMemoryIds, personalMemoryIds, allMemoryIds } = + effectiveMemoryIds; + // ── Query memory item IDs for self-exclusion ────────────────────── const excludeMemoryItemIds = await executeQuery( { db: drizzle }, @@ -115,13 +143,14 @@ export const onNew = authed // ── Assemble suggestion context once (shared by Smart Suggest + advisors) ─ const [recalledMemories, termContext] = await Promise.all([ - memoryIds.length > 0 - ? collectMemoryRecallOp( + allMemoryIds.length > 0 + ? collectEffectiveMemoryRecallOp( { text: element.value, sourceLanguageId: element.languageId, translationLanguageId: languageId, - memoryIds, + projectMemoryIds, + personalMemoryIds, chunkIds: element.chunkIds, excludeMemoryItemIds, sourceNlpTokens, @@ -252,7 +281,7 @@ export const onNew = authed { text: element.value, glossaryIds, - memoryIds, + memoryIds: allMemoryIds, advisorId: advisor.dbId, sourceLanguageId: element.languageId, translationLanguageId: languageId, diff --git a/apps/app-api/src/orpc/routers/translation.schemas.ts b/apps/app-api/src/orpc/routers/translation.schemas.ts new file mode 100644 index 000000000..b336f457b --- /dev/null +++ b/apps/app-api/src/orpc/routers/translation.schemas.ts @@ -0,0 +1,52 @@ +import { TranslationSchema } from "@cat/shared"; +import * as z from "zod"; + +const TranslationDataSchema = TranslationSchema.omit({ + updatedAt: true, + stringId: true, +}).extend({ + vote: z.int(), + text: z.string(), +}); + +/** + * @zh 主线翻译 DTO。 + * @en DTO for a mainline translation. + */ +export const MainTranslationDataSchema = TranslationDataSchema.extend({ + kind: z.literal("main").default("main"), +}); + +/** + * @zh 仅存在于分支 overlay 中的翻译 DTO。 + * @en DTO for a translation that only exists in a branch overlay. + */ +export const BranchOverlayTranslationDataSchema = z.object({ + kind: z.literal("branch-overlay"), + overlayEntityId: z.string(), + translatableElementId: z.int(), + languageId: z.string(), + text: z.string(), + translatorId: z.uuidv4().nullable(), + approved: z.boolean().default(false), + vote: z.literal(0), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +/** + * @zh 可区分主线与分支 overlay 的翻译 DTO。 + * @en Branch-aware translation DTO that distinguishes mainline rows from branch overlays. + */ +export const BranchAwareTranslationDataSchema = z.discriminatedUnion("kind", [ + MainTranslationDataSchema, + BranchOverlayTranslationDataSchema, +]); + +/** + * @zh 分支感知翻译 DTO 类型。 + * @en Branch-aware translation DTO type. + */ +export type BranchAwareTranslationData = z.infer< + typeof BranchAwareTranslationDataSchema +>; diff --git a/apps/app-api/src/orpc/routers/translation.test.ts b/apps/app-api/src/orpc/routers/translation.test.ts new file mode 100644 index 000000000..8f04b6bb6 --- /dev/null +++ b/apps/app-api/src/orpc/routers/translation.test.ts @@ -0,0 +1,552 @@ +import type { SerializableType } from "@cat/shared"; + +import { + addChangesetEntry, + createChangeset, + createContentNodeUnderParent, + createElements, + createPR, + createProject, + createRootContentNode, + createUser, + ensureCoreRelationTypes, + ensureLanguages, + createVectorizedStrings, + executeCommand, +} from "@cat/domain"; +import { PluginManager } from "@cat/plugin-core"; +import { + createAuthedTestContext, + setupTestDB, + type TestDB, +} from "@cat/test-utils"; +import { getBranchChangesetId, type VCSContext } from "@cat/vcs"; +import { call } from "@orpc/server"; +import { randomUUID } from "node:crypto"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { Context } from "@/utils/context"; + +const mocks = vi.hoisted(() => ({ + permissionCheck: vi.fn(async () => true), + determineWriteMode: vi.fn< + (projectId?: string) => Promise<"direct" | "isolation" | "no_access"> + >(async () => "direct"), + interceptWrite: vi.fn( + async ( + _ctx: VCSContext, + _entityType: string, + _entityId: string, + _action: "CREATE" | "UPDATE" | "DELETE", + _before: SerializableType, + _after: SerializableType, + writeFn: () => Promise, + ): Promise => await writeFn(), + ), + runGraph: vi.fn(async () => ({ translationIds: [101] })), + firstOrGivenService: vi.fn((_: unknown, kind: string) => ({ + id: kind === "VECTOR_STORAGE" ? 1 : 2, + })), +})); + +vi.mock("@cat/permissions", async () => { + const actual = + await vi.importActual( + "@cat/permissions", + ); + + return { + ...actual, + getPermissionEngine: () => ({ + check: mocks.permissionCheck, + }), + determineWriteMode: mocks.determineWriteMode, + loadUserSystemRoles: async () => [], + }; +}); + +vi.mock("@cat/server-shared", async () => { + const actual = + await vi.importActual( + "@cat/server-shared", + ); + + return { + ...actual, + firstOrGivenService: mocks.firstOrGivenService, + }; +}); + +vi.mock("@cat/workflow/tasks", async () => { + const actual = await vi.importActual( + "@cat/workflow/tasks", + ); + + return { + ...actual, + runGraph: mocks.runGraph, + }; +}); + +vi.mock("@/utils/vcs-route-helper", async () => { + const actual = await vi.importActual< + typeof import("@/utils/vcs-route-helper") + >("@/utils/vcs-route-helper"); + + return { + ...actual, + createVCSRouteHelper: () => ({ + middleware: { + interceptWrite: mocks.interceptWrite, + }, + }), + }; +}); + +import { create, getAll } from "./translation.ts"; + +let testDb: TestDB; +let creatorId: string; + +const insertString = async (value: string, languageId: string) => { + const [stringId] = await executeCommand( + { db: testDb.client }, + createVectorizedStrings, + { + data: [ + { + text: value, + languageId, + }, + ], + }, + ); + + return stringId; +}; + +const seedProjectElement = async (label: string) => { + const project = await executeCommand({ db: testDb.client }, createProject, { + name: `${label}-${randomUUID()}`, + description: null, + creatorId, + }); + const root = await executeCommand( + { db: testDb.client }, + createRootContentNode, + { + projectId: project.id, + creatorId, + }, + ); + const file = await executeCommand( + { db: testDb.client }, + createContentNodeUnderParent, + { + projectId: project.id, + creatorId, + parentContentNodeId: root.id, + kind: "FILE", + displayLabel: `${label}.json`, + importerId: "test-json", + sourceRootRef: "root", + stableSourceNodeRef: `${label}-node-${randomUUID()}`, + exportRole: "FILE", + boundaryType: "FILE", + localOrder: 0, + }, + ); + const sourceStringId = await insertString(`${label}-source`, "en"); + const [elementId] = await executeCommand( + { db: testDb.client }, + createElements, + { + data: [ + { + projectId: project.id, + primaryContentNodeId: file.id, + importerId: "test-json", + sourceRootRef: "root", + sourceNodeRef: `${label}.greeting`, + stableSourceRef: `${label}-element-${randomUUID()}`, + stringId: sourceStringId, + localOrder: 0, + }, + ], + }, + ); + + return { + projectId: project.id, + elementId, + }; +}; + +const createContext = (): Context => { + const base = createAuthedTestContext( + { + id: creatorId, + email: "translation-router@test.local", + name: "Translation Router Tester", + emailVerified: true, + avatarFileId: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + }, + { + drizzleDB: testDb, + pluginManager: new PluginManager("GLOBAL", ""), + helpers: { + setCookie: () => undefined, + delCookie: () => undefined, + getCookie: () => null, + getQueryParam: () => undefined, + getReqHeader: (name) => { + if (name === "x-csrf-token") return "csrf-token"; + return undefined; + }, + setResHeader: () => undefined, + }, + }, + ); + + return { + ...base, + auth: { + subjectType: "user", + subjectId: creatorId, + systemRoles: [], + scopes: null, + traceId: undefined, + ip: undefined, + userAgent: undefined, + }, + csrfToken: "csrf-token", + isSSR: true, + isWebSocket: false, + requestSignal: new AbortController().signal, + } as Context; +}; + +beforeAll(async () => { + testDb = await setupTestDB(); + await executeCommand({ db: testDb.client }, ensureCoreRelationTypes, {}); + await executeCommand({ db: testDb.client }, ensureLanguages, { + languageIds: ["en", "zh-Hans"], + }); + const user = await executeCommand({ db: testDb.client }, createUser, { + email: `translation-router-${randomUUID()}@example.com`, + name: "Translation Router Tester", + }); + creatorId = user.id; +}); + +afterAll(async () => { + await testDb.cleanup(); +}); + +describe("translation router branch-aware writes", () => { + beforeEach(() => { + mocks.permissionCheck.mockReset(); + mocks.permissionCheck.mockResolvedValue(true); + mocks.determineWriteMode.mockReset(); + mocks.determineWriteMode.mockResolvedValue("direct"); + mocks.interceptWrite.mockClear(); + mocks.runGraph.mockClear(); + mocks.firstOrGivenService.mockClear(); + }); + + it("rejects main writes when isolation is required and no branchId is provided", async () => { + const fixture = await seedProjectElement("isolation-main"); + mocks.determineWriteMode.mockResolvedValue("isolation"); + + await expect( + call( + create, + { + projectId: fixture.projectId, + elementId: fixture.elementId, + languageId: "zh-Hans", + text: "需要分支的译文", + createMemory: false, + }, + { context: createContext() }, + ), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "isolation_forced: branchId is required for writes", + }); + + expect(mocks.runGraph).not.toHaveBeenCalled(); + expect(mocks.interceptWrite).not.toHaveBeenCalled(); + }); + + it("writes branch translations into the branch changeset with explicit projectId and branchId", async () => { + const fixture = await seedProjectElement("branch-create"); + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId: fixture.projectId, + title: "Branch workspace", + body: "Branch workspace fixture", + authorId: creatorId, + reviewers: [], + branchName: "feature/branch-workspace", + }); + const branchChangeset = await executeCommand( + { db: testDb.client }, + createChangeset, + { + projectId: fixture.projectId, + branchId: pr.branchId, + status: "PENDING", + }, + ); + + await expect( + call( + create, + { + projectId: fixture.projectId, + branchId: pr.branchId, + elementId: fixture.elementId, + languageId: "zh-Hans", + text: "分支译文", + createMemory: false, + }, + { context: createContext() }, + ), + ).resolves.toBeUndefined(); + + expect(mocks.interceptWrite).toHaveBeenCalledWith( + { + mode: "isolation", + projectId: fixture.projectId, + branchId: pr.branchId, + branchChangesetId: branchChangeset.id, + }, + "translation", + expect.any(String), + "CREATE", + null, + expect.objectContaining({ + translatableElementId: fixture.elementId, + languageId: "zh-Hans", + text: "分支译文", + }), + expect.any(Function), + ); + expect(mocks.runGraph).not.toHaveBeenCalled(); + }); + + it("creates an initial branch changeset on the first branch translation write", async () => { + const fixture = await seedProjectElement("branch-first-write"); + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId: fixture.projectId, + title: "Branch first write", + body: "Branch first write fixture", + authorId: creatorId, + reviewers: [], + branchName: "feature/branch-first-write", + }); + + expect(await getBranchChangesetId(testDb.client, pr.branchId)).toBeNull(); + + await expect( + call( + create, + { + projectId: fixture.projectId, + branchId: pr.branchId, + elementId: fixture.elementId, + languageId: "zh-Hans", + text: "首次分支译文", + createMemory: false, + }, + { context: createContext() }, + ), + ).resolves.toBeUndefined(); + + const branchChangesetId = await getBranchChangesetId( + testDb.client, + pr.branchId, + ); + + expect(branchChangesetId).not.toBeNull(); + expect(mocks.interceptWrite).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "isolation", + projectId: fixture.projectId, + branchId: pr.branchId, + branchChangesetId, + }), + "translation", + expect.any(String), + "CREATE", + null, + expect.objectContaining({ + translatableElementId: fixture.elementId, + languageId: "zh-Hans", + text: "首次分支译文", + }), + expect.any(Function), + ); + }); + + it("rejects writes when input.projectId does not match the element project", async () => { + const projectA = await seedProjectElement("project-a"); + const projectB = await seedProjectElement("project-b"); + + await expect( + call( + create, + { + projectId: projectA.projectId, + elementId: projectB.elementId, + languageId: "zh-Hans", + text: "跨项目译文", + createMemory: false, + }, + { context: createContext() }, + ), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: `Element ${projectB.elementId} does not belong to project ${projectA.projectId}`, + }); + }); + + it("rejects branch writes when the branch belongs to another project", async () => { + const projectA = await seedProjectElement("branch-project-a"); + const projectB = await seedProjectElement("branch-project-b"); + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId: projectA.projectId, + title: "Cross project branch", + body: "Cross project branch fixture", + authorId: creatorId, + reviewers: [], + branchName: "feature/cross-project", + }); + await executeCommand({ db: testDb.client }, createChangeset, { + projectId: projectA.projectId, + branchId: pr.branchId, + status: "PENDING", + }); + + await expect( + call( + create, + { + projectId: projectB.projectId, + branchId: pr.branchId, + elementId: projectB.elementId, + languageId: "zh-Hans", + text: "错误分支译文", + createMemory: false, + }, + { context: createContext() }, + ), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: `Branch ${pr.branchId} does not belong to project ${projectB.projectId}`, + }); + }); + + it("returns branch-only overlay translations as discriminated DTOs", async () => { + const fixture = await seedProjectElement("branch-overlay"); + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId: fixture.projectId, + title: "Branch overlay read", + body: "Branch overlay read fixture", + authorId: creatorId, + reviewers: [], + branchName: "feature/branch-overlay", + }); + const branchChangeset = await executeCommand( + { db: testDb.client }, + createChangeset, + { + projectId: fixture.projectId, + branchId: pr.branchId, + status: "PENDING", + }, + ); + const overlayEntityId = randomUUID(); + + await executeCommand({ db: testDb.client }, addChangesetEntry, { + changesetId: branchChangeset.id, + entityType: "translation", + entityId: overlayEntityId, + action: "CREATE", + after: { + translatableElementId: fixture.elementId, + languageId: "zh-Hans", + text: "仅存在于分支的译文", + translatorId: creatorId, + approved: false, + createdAt: new Date("2024-01-01T00:00:00.000Z").toISOString(), + updatedAt: new Date("2024-01-01T00:00:00.000Z").toISOString(), + }, + riskLevel: "LOW", + }); + + const result = await call( + getAll, + { + elementId: fixture.elementId, + languageId: "zh-Hans", + branchId: pr.branchId, + }, + { context: createContext() }, + ); + + expect(result).toEqual([ + expect.objectContaining({ + kind: "branch-overlay", + overlayEntityId, + translatableElementId: fixture.elementId, + languageId: "zh-Hans", + text: "仅存在于分支的译文", + }), + ]); + expect(result[0] && "id" in result[0]).toBe(false); + }); + + it("rejects cross-project branch reads in getAll", async () => { + const projectA = await seedProjectElement("get-all-a"); + const projectB = await seedProjectElement("get-all-b"); + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId: projectA.projectId, + title: "Cross project read", + body: "Cross project read fixture", + authorId: creatorId, + reviewers: [], + branchName: "feature/cross-project-read", + }); + await executeCommand({ db: testDb.client }, createChangeset, { + projectId: projectA.projectId, + branchId: pr.branchId, + status: "PENDING", + }); + + await expect( + call( + getAll, + { + elementId: projectB.elementId, + languageId: "zh-Hans", + branchId: pr.branchId, + }, + { context: createContext() }, + ), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: `Branch ${pr.branchId} does not belong to element project ${projectB.projectId}`, + }); + }); +}); diff --git a/apps/app-api/src/orpc/routers/translation.ts b/apps/app-api/src/orpc/routers/translation.ts index 1bf0d4ae3..8971fa60c 100644 --- a/apps/app-api/src/orpc/routers/translation.ts +++ b/apps/app-api/src/orpc/routers/translation.ts @@ -15,7 +15,8 @@ import { getProjectTargetLanguages, getSelfTranslationVote, getTranslationVoteTotal, - listMemoryIdsByProject, + listBranchChangesetEntries, + listEffectiveMemoryIdsByProject, listProjectGlossaryIds, listQaResultItems, listQaResultsByTranslation, @@ -24,7 +25,12 @@ import { unapproveTranslation, upsertTranslationVote, } from "@cat/domain"; -import { resolveOperationScopeElementsOp } from "@cat/operations"; +import { + promoteApprovedTranslationMemoryOp, + resolveOperationScopeElementsOp, + writePersonalTranslationMemoryOp, +} from "@cat/operations"; +import { determineWriteMode, getPermissionEngine } from "@cat/permissions"; import { AsyncMessageQueue, firstOrGivenService } from "@cat/server-shared"; import { serverLogger as logger } from "@cat/server-shared"; import { @@ -35,7 +41,7 @@ import { } from "@cat/shared"; import { TranslationSchema, TranslationVoteSchema } from "@cat/shared"; import { JSONObjectSchema } from "@cat/shared"; -import { EditorOverlayTranslationStateSchema, listWithOverlay } from "@cat/vcs"; +import { EditorOverlayTranslationStateSchema } from "@cat/vcs"; import { CreateTranslationPubPayloadSchema, batchAutoTranslateGraph, @@ -47,6 +53,10 @@ import { ORPCError } from "@orpc/client"; import * as z from "zod"; import { withBranchContext } from "@/orpc/middleware/with-branch-context"; +import { + BranchAwareTranslationDataSchema, + type BranchAwareTranslationData, +} from "@/orpc/routers/translation.schemas"; import { authed, checkElementPermission, @@ -54,7 +64,10 @@ import { checkTranslationPermission, } from "@/orpc/server"; import { getGraphRuntime } from "@/utils/graph-runtime"; -import { createVCSRouteHelper } from "@/utils/vcs-route-helper"; +import { + createVCSRouteHelper, + ensureBranchWriteContext, +} from "@/utils/vcs-route-helper"; const TranslationDataSchema = TranslationSchema.omit({ updatedAt: true, @@ -69,6 +82,29 @@ type CreateTranslationPubPayload = z.infer< typeof CreateTranslationPubPayloadSchema >; +const toMainTranslationData = ( + item: TranslationData, +): BranchAwareTranslationData => ({ + kind: "main", + ...item, +}); + +const toBranchOverlayTranslationData = ( + overlayEntityId: string, + overlay: z.infer, +): BranchAwareTranslationData => ({ + kind: "branch-overlay", + overlayEntityId, + translatableElementId: overlay.translatableElementId, + languageId: overlay.languageId, + text: overlay.text, + translatorId: overlay.translatorId ?? null, + approved: overlay.approved ?? false, + vote: 0, + createdAt: new Date(overlay.createdAt), + updatedAt: new Date(overlay.updatedAt), +}); + export const translationRouter = authed .input( z.object({ @@ -88,6 +124,7 @@ export const translationRouter = authed export const create = authed .input( z.object({ + projectId: z.uuidv4(), elementId: z.int(), languageId: z.string(), text: z.string(), @@ -96,7 +133,10 @@ export const create = authed }), ) .use(checkElementPermission("editor"), (i) => i.elementId) - .use(withBranchContext, (i) => ({ branchId: i.branchId })) + .use(withBranchContext, (i) => ({ + branchId: i.branchId, + projectId: i.projectId, + })) .output(z.void()) .handler(async ({ context, input }) => { const { @@ -106,26 +146,49 @@ export const create = authed } = context; const { elementId, languageId, text, createMemory } = input; - // Isolation write: record translation in branch changeset + const element = await executeQuery( + { db: drizzle }, + getElementWithChunkIds, + { + elementId, + }, + ); + + if (!element) { + throw new ORPCError("NOT_FOUND", { + message: `Element ${elementId} not found`, + }); + } + + if (element.projectId !== input.projectId) { + throw new ORPCError("BAD_REQUEST", { + message: `Element ${elementId} does not belong to project ${input.projectId}`, + }); + } + if ( - context.branchId !== undefined && - context.branchChangesetId !== undefined + context.branchProjectId !== undefined && + context.branchProjectId !== element.projectId ) { - if (context.branchProjectId === undefined) { - throw new Error( - "branchProjectId missing when branch context is active", - ); - } + throw new ORPCError("BAD_REQUEST", { + message: `Branch ${context.branchId} does not belong to element project ${element.projectId}`, + }); + } + + // Isolation write: record translation in branch changeset + const branchWriteContext = await ensureBranchWriteContext({ + drizzle, + branchId: context.branchId, + branchChangesetId: context.branchChangesetId, + branchProjectId: context.branchProjectId, + }); + + if (branchWriteContext) { const { middleware } = createVCSRouteHelper(drizzle); const entityId = crypto.randomUUID(); const timestamp = new Date().toISOString(); await middleware.interceptWrite( - { - mode: "isolation", - projectId: context.branchProjectId, - branchId: context.branchId, - branchChangesetId: context.branchChangesetId, - }, + branchWriteContext, "translation", entityId, "CREATE", @@ -144,6 +207,17 @@ export const create = authed return; } + const writeMode = await determineWriteMode( + getPermissionEngine(), + context.auth, + element.projectId, + ); + if (writeMode !== "direct") { + throw new ORPCError("FORBIDDEN", { + message: "isolation_forced: branchId is required for writes", + }); + } + const storage = firstOrGivenService(pluginManager, "VECTOR_STORAGE"); const vectorizer = firstOrGivenService(pluginManager, "TEXT_VECTORIZER"); @@ -153,27 +227,7 @@ export const create = authed }); } - const element = await executeQuery( - { db: drizzle }, - getElementWithChunkIds, - { - elementId, - }, - ); - - if (!element) { - throw new ORPCError("NOT_FOUND", { - message: `Element ${elementId} not found`, - }); - } - - const memoryIds = await executeQuery( - { db: drizzle }, - listMemoryIdsByProject, - { projectId: element.projectId }, - ); - - await runGraph( + const result = await runGraph( createTranslationGraph, { data: [ @@ -184,7 +238,7 @@ export const create = authed translatorId: user.id, }, ], - memoryIds: createMemory ? memoryIds : [], + memoryIds: [], vectorStorageId: storage.id, vectorizerId: vectorizer.id, translatorId: user.id, @@ -199,6 +253,20 @@ export const create = authed vcsMiddleware: createVCSRouteHelper(drizzle).middleware, }, ); + + if (createMemory && result.translationIds.length > 0) { + try { + await writePersonalTranslationMemoryOp({ + translationIds: result.translationIds, + userId: user.id, + projectId: element.projectId, + }); + } catch (error) { + logger + .withSituation("RPC") + .error(error, "personal memory write failed"); + } + } }); export const onCreate = authed @@ -296,13 +364,36 @@ export const getAll = authed ) .use(checkElementPermission("viewer"), (i) => i.elementId) .use(withBranchContext, (i) => ({ branchId: i.branchId })) - .output(z.array(TranslationDataSchema)) + .output(z.array(BranchAwareTranslationDataSchema)) .handler(async ({ context, input }) => { const { drizzleDB: { client: drizzle }, } = context; const { elementId, languageId } = input; + const element = await executeQuery( + { db: drizzle }, + getElementWithChunkIds, + { + elementId, + }, + ); + + if (!element) { + throw new ORPCError("NOT_FOUND", { + message: `Element ${elementId} not found`, + }); + } + + if ( + context.branchProjectId !== undefined && + context.branchProjectId !== element.projectId + ) { + throw new ORPCError("BAD_REQUEST", { + message: `Branch ${context.branchId} does not belong to element project ${element.projectId}`, + }); + } + const mainItems = await executeQuery( { db: drizzle }, listTranslationsByElement, @@ -312,17 +403,86 @@ export const getAll = authed }, ); - if (context.branchId !== undefined) { - return await listWithOverlay( - drizzle, - context.branchId, - "translation", - mainItems, - (item) => String(item.id), + if (context.branchId === undefined) { + return mainItems.map(toMainTranslationData); + } + + const branchEntries = await executeQuery( + { db: drizzle }, + listBranchChangesetEntries, + { + branchId: context.branchId, + entityType: "translation", + }, + ); + + const latestEntries = new Map< + string, + { + action: string; + after: unknown; + } + >(); + + for (const entry of branchEntries) { + if (latestEntries.has(entry.entityId)) continue; + latestEntries.set(entry.entityId, { + action: entry.action, + after: entry.after, + }); + } + + const result: BranchAwareTranslationData[] = []; + + for (const item of mainItems) { + const entityId = String(item.id); + const branchEntry = latestEntries.get(entityId); + + if (!branchEntry) { + result.push(toMainTranslationData(item)); + continue; + } + + latestEntries.delete(entityId); + + if (branchEntry.action === "DELETE") { + continue; + } + + const parsedMain = TranslationDataSchema.safeParse(branchEntry.after); + if (parsedMain.success) { + result.push(toMainTranslationData(parsedMain.data)); + continue; + } + + const overlay = EditorOverlayTranslationStateSchema.parse( + branchEntry.after, + ); + result.push(toBranchOverlayTranslationData(entityId, overlay)); + } + + for (const [entityId, branchEntry] of latestEntries.entries()) { + if ( + branchEntry.action !== "CREATE" || + branchEntry.after === null || + branchEntry.after === undefined + ) { + continue; + } + + const parsedMain = TranslationDataSchema.safeParse(branchEntry.after); + if (parsedMain.success) { + result.push(toMainTranslationData(parsedMain.data)); + continue; + } + + const overlay = EditorOverlayTranslationStateSchema.parse( + branchEntry.after, ); + result.push(toBranchOverlayTranslationData(entityId, overlay)); } - return mainItems; + return result; }); export const vote = authed @@ -397,6 +557,7 @@ export const autoApprove = authed .handler(async ({ context, input }) => { const { drizzleDB: { client: drizzle }, + user, } = context; const { elements } = await resolveOperationScopeElementsOp({ @@ -407,7 +568,7 @@ export const autoApprove = authed if (elements.length === 0) return 0; - return await executeCommand( + const result = await executeCommand( { db: drizzle }, autoApproveOperationScopeTranslations, { @@ -415,6 +576,26 @@ export const autoApprove = authed languageId: input.languageId, }, ); + + await Promise.allSettled( + result.approvedTranslationIds.map(async (translationId) => { + try { + await promoteApprovedTranslationMemoryOp({ + translationId, + approvedById: user.id, + }); + } catch (error) { + logger + .withSituation("RPC") + .error( + error, + `approved translation memory promotion failed: ${translationId}`, + ); + } + }), + ); + + return result.count; }); export const approve = authed @@ -428,8 +609,23 @@ export const approve = authed .handler(async ({ context, input }) => { const { drizzleDB: { client: drizzle }, + user, } = context; await executeCommand({ db: drizzle }, approveTranslation, input); + + try { + await promoteApprovedTranslationMemoryOp({ + translationId: input.translationId, + approvedById: user.id, + }); + } catch (error) { + logger + .withSituation("RPC") + .error( + error, + `approved translation memory promotion failed: ${input.translationId}`, + ); + } }); export const unapprove = authed @@ -506,15 +702,20 @@ export const autoTranslate = authed }); } - const [memoryIds, glossaryIds] = await Promise.all([ - executeQuery({ db: drizzle }, listMemoryIdsByProject, { + const [effectiveMemoryIds, glossaryIds] = await Promise.all([ + executeQuery({ db: drizzle }, listEffectiveMemoryIdsByProject, { projectId: scope.projectId, + userId: user.id, }), executeQuery({ db: drizzle }, listProjectGlossaryIds, { projectId: scope.projectId, }), ]); + const memoryIds = Array.isArray(effectiveMemoryIds) + ? effectiveMemoryIds + : effectiveMemoryIds.allMemoryIds; + // 查找或创建 auto-translate AgentDefinition let existingDef = await executeQuery( { db: drizzle }, diff --git a/apps/app-api/src/orpc/routers/vcs-branch-entrypoints.spec.ts b/apps/app-api/src/orpc/routers/vcs-branch-entrypoints.spec.ts new file mode 100644 index 000000000..abf5730d9 --- /dev/null +++ b/apps/app-api/src/orpc/routers/vcs-branch-entrypoints.spec.ts @@ -0,0 +1,302 @@ +import { + createChangeset, + createPR, + createProject, + createRootContentNode, + createUser, + executeCommand, +} from "@cat/domain"; +import { PluginManager } from "@cat/plugin-core"; +import { + createAuthedTestContext, + setupTestDB, + type TestDB, +} from "@cat/test-utils"; +import { call } from "@orpc/server"; +import { randomUUID } from "node:crypto"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { Context } from "@/utils/context"; + +const mocks = vi.hoisted(() => ({ + permissionCheck: vi.fn(async () => true), +})); + +vi.mock("@cat/permissions", async () => { + const actual = + await vi.importActual( + "@cat/permissions", + ); + + return { + ...actual, + getPermissionEngine: () => ({ + check: mocks.permissionCheck, + }), + determineWriteMode: async () => "direct" as const, + loadUserSystemRoles: async () => [], + }; +}); + +import { comment, getRootComments } from "./comment.ts"; +import { get as getContentNode } from "./content-node.ts"; +import { insertTerm } from "./glossary.ts"; +import { create as createMemory } from "./memory.ts"; + +let testDb: TestDB; +let creatorId: string; + +const createContext = (): Context => { + const base = createAuthedTestContext( + { + id: creatorId, + email: "vcs-entrypoints@test.local", + name: "VCS Entrypoints Tester", + emailVerified: true, + avatarFileId: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + }, + { + drizzleDB: testDb, + pluginManager: new PluginManager("GLOBAL", ""), + helpers: { + setCookie: () => undefined, + delCookie: () => undefined, + getCookie: () => null, + getQueryParam: () => undefined, + getReqHeader: () => undefined, + setResHeader: () => undefined, + }, + }, + ); + + return { + ...base, + auth: { + subjectType: "user", + subjectId: creatorId, + systemRoles: [], + scopes: null, + traceId: undefined, + ip: undefined, + userAgent: undefined, + }, + csrfToken: "csrf-token", + isSSR: true, + isWebSocket: false, + requestSignal: new AbortController().signal, + } as Context; +}; + +const seedProject = async (label: string) => { + const project = await executeCommand({ db: testDb.client }, createProject, { + name: `${label}-${randomUUID()}`, + description: null, + creatorId, + }); + + return project; +}; + +beforeAll(async () => { + testDb = await setupTestDB(); + const user = await executeCommand({ db: testDb.client }, createUser, { + email: `vcs-entrypoints-${randomUUID()}@example.com`, + name: "VCS Entrypoints Tester", + }); + creatorId = user.id; +}); + +afterAll(async () => { + await testDb.cleanup(); +}); + +describe("VCS branch-aware entrypoint guards", () => { + beforeEach(() => { + mocks.permissionCheck.mockReset(); + mocks.permissionCheck.mockResolvedValue(true); + }); + + it("rejects branch comments without an explicit projectId", async () => { + const project = await seedProject("comment-branch-project"); + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId: project.id, + title: "Comment branch", + body: "Comment branch fixture", + authorId: creatorId, + reviewers: [], + branchName: "feature/comment-branch", + }); + await executeCommand({ db: testDb.client }, createChangeset, { + projectId: project.id, + branchId: pr.branchId, + status: "PENDING", + }); + + await expect( + call( + comment, + { + targetType: "ELEMENT", + targetId: 1, + content: "branch comment", + languageId: "en", + branchId: pr.branchId, + }, + { context: createContext() }, + ), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "projectId is required when branchId is provided", + }); + }); + + it("rejects branch root-comment reads without an explicit projectId", async () => { + const project = await seedProject("comment-read-project"); + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId: project.id, + title: "Comment read branch", + body: "Comment read branch fixture", + authorId: creatorId, + reviewers: [], + branchName: "feature/comment-read", + }); + await executeCommand({ db: testDb.client }, createChangeset, { + projectId: project.id, + branchId: pr.branchId, + status: "PENDING", + }); + + await expect( + call( + getRootComments, + { + targetType: "ELEMENT", + targetId: 1, + pageIndex: 0, + pageSize: 10, + branchId: pr.branchId, + }, + { context: createContext() }, + ), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "projectId is required when branchId is provided", + }); + }); + + it("rejects branch content-node reads across projects", async () => { + const projectA = await seedProject("content-node-a"); + const projectB = await seedProject("content-node-b"); + const rootB = await executeCommand( + { db: testDb.client }, + createRootContentNode, + { + projectId: projectB.id, + creatorId, + }, + ); + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId: projectA.id, + title: "Content node branch", + body: "Content node branch fixture", + authorId: creatorId, + reviewers: [], + branchName: "feature/content-node-branch", + }); + await executeCommand({ db: testDb.client }, createChangeset, { + projectId: projectA.id, + branchId: pr.branchId, + status: "PENDING", + }); + + await expect( + call( + getContentNode, + { + contentNodeId: rootB.id, + branchId: pr.branchId, + }, + { context: createContext() }, + ), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: `Branch ${pr.branchId} does not belong to content node project ${projectB.id}`, + }); + }); + + it("rejects branch memory creation with multiple projectIds", async () => { + const projectA = await seedProject("memory-a"); + const projectB = await seedProject("memory-b"); + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId: projectA.id, + title: "Memory branch", + body: "Memory branch fixture", + authorId: creatorId, + reviewers: [], + branchName: "feature/memory-branch", + }); + await executeCommand({ db: testDb.client }, createChangeset, { + projectId: projectA.id, + branchId: pr.branchId, + status: "PENDING", + }); + + await expect( + call( + createMemory, + { + name: "Branch Memory", + projectIds: [projectA.id, projectB.id], + branchId: pr.branchId, + }, + { context: createContext() }, + ), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: + "Branch memory creation requires exactly one projectId matching the active branch project", + }); + }); + + it("rejects branch glossary term inserts without an explicit projectId", async () => { + const project = await seedProject("glossary-project"); + const pr = await executeCommand({ db: testDb.client }, createPR, { + projectId: project.id, + title: "Glossary branch", + body: "Glossary branch fixture", + authorId: creatorId, + reviewers: [], + branchName: "feature/glossary-branch", + }); + await executeCommand({ db: testDb.client }, createChangeset, { + projectId: project.id, + branchId: pr.branchId, + status: "PENDING", + }); + + await expect( + call( + insertTerm, + { + glossaryId: randomUUID(), + termsData: [], + branchId: pr.branchId, + }, + { context: createContext() }, + ), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "projectId is required when branchId is provided", + }); + }); +}); diff --git a/apps/app-api/src/utils/vcs-route-helper.ts b/apps/app-api/src/utils/vcs-route-helper.ts index 373a00de0..b76464a23 100644 --- a/apps/app-api/src/utils/vcs-route-helper.ts +++ b/apps/app-api/src/utils/vcs-route-helper.ts @@ -1,5 +1,4 @@ -import type { DbHandle } from "@cat/domain"; - +import { createChangeset, executeCommand, type DbHandle } from "@cat/domain"; import { ChangeSetService, getDefaultRegistries, @@ -25,3 +24,44 @@ export const createVCSRouteHelper = ( const middleware = new VCSMiddleware(csService, diffRegistry); return { csService, middleware, diffRegistry, appMethodRegistry }; }; + +/** + * @zh 解析 branch 写上下文;若分支尚无 changeset,则懒创建一个初始 changeset。 + * @en Resolve branch write context and lazily create an initial changeset when the branch has none yet. + */ +export const ensureBranchWriteContext = async (input: { + drizzle: DbHandle; + branchId?: number; + branchChangesetId?: number; + branchProjectId?: string; +}): Promise<{ + mode: "isolation"; + projectId: string; + branchId: number; + branchChangesetId: number; +} | null> => { + if (input.branchId === undefined) { + return null; + } + + if (input.branchProjectId === undefined) { + throw new Error("branchProjectId missing when branch context is active"); + } + + const branchChangesetId = + input.branchChangesetId ?? + ( + await executeCommand({ db: input.drizzle }, createChangeset, { + projectId: input.branchProjectId, + branchId: input.branchId, + status: "PENDING", + }) + ).id; + + return { + mode: "isolation", + projectId: input.branchProjectId, + branchId: input.branchId, + branchChangesetId, + }; +}; diff --git a/apps/app-e2e/global-setup.ts b/apps/app-e2e/global-setup.ts index a8b6e6bdb..e8a216fd8 100644 --- a/apps/app-e2e/global-setup.ts +++ b/apps/app-e2e/global-setup.ts @@ -1,8 +1,27 @@ import type { FullConfig } from "@playwright/test"; // oxlint-disable no-console -- intentional diagnostic logging in globalSetup -import { DrizzleDB, ensureDB, RedisConnection, sql } from "@cat/db"; -import { loadDevSeed, runSeedPipeline, truncateAllTables } from "@cat/seed"; +import { + DrizzleDB, + ensureDB, + RedisConnection, + sql, + vectorizedString, +} from "@cat/db"; +import { + createPR, + createQaReviewRunWithFindings, + createTranslations, + executeCommand, + materializeQaReviewQueueItem, + updatePRStatus, +} from "@cat/domain"; +import { + type RefResolver, + loadDevSeed, + runSeedPipeline, + truncateAllTables, +} from "@cat/seed"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; @@ -50,6 +69,140 @@ const validateDatabaseUrl = (): string => { return url; }; +const insertString = async ( + db: DrizzleDB["client"], + value: string, + languageId: string, +) => { + const [row] = await db + .insert(vectorizedString) + .values({ value, languageId }) + .returning({ id: vectorizedString.id }); + + return row.id; +}; + +const seedQaReviewWorkbench = async ( + execCtx: { db: DrizzleDB["client"] }, + refs: RefResolver, +) => { + const projectId = refs.getStringId("project"); + const adminId = refs.getStringId("user:admin"); + const firstElementId = refs.getNumericId("el:001"); + const secondElementId = refs.getNumericId("el:002"); + + const [firstStringId, secondStringId] = await Promise.all([ + insertString(execCtx.db, "QA approved candidate", "zh-Hans"), + insertString(execCtx.db, "QA rejected candidate", "zh-Hans"), + ]); + + const [firstTranslationId, secondTranslationId] = await executeCommand( + execCtx, + createTranslations, + { + data: [ + { + translatableElementId: firstElementId, + translatorId: adminId, + stringId: firstStringId, + }, + { + translatableElementId: secondElementId, + translatorId: adminId, + stringId: secondStringId, + }, + ], + }, + ); + + await Promise.all( + [ + { + elementId: firstElementId, + translationId: firstTranslationId, + message: "Missing placeholder", + action: "BLOCK_APPROVAL" as const, + riskScore: 100, + }, + { + elementId: secondElementId, + translationId: secondTranslationId, + message: "Needs style review", + action: "NEEDS_REVIEW" as const, + riskScore: 60, + }, + ].map(async (item) => { + await executeCommand(execCtx, createQaReviewRunWithFindings, { + projectId, + elementId: item.elementId, + translationId: item.translationId, + branchId: null, + layer: "DETERMINISTIC", + status: "COMPLETED", + riskScore: item.riskScore, + summary: item.message, + findings: [ + { + layer: "DETERMINISTIC", + checkerServiceId: null, + qaResultItemId: null, + ruleId: "e2e.qa", + ruleFamily: "e2e", + severity: item.action === "BLOCK_APPROVAL" ? "error" : "warning", + action: item.action, + disposition: "OPEN", + confidenceBasisPoints: 10000, + riskScore: item.riskScore, + message: item.message, + explanation: null, + sourceSpan: null, + targetSpan: null, + suggestedText: null, + meta: null, + }, + ], + }); + + await executeCommand(execCtx, materializeQaReviewQueueItem, { + projectId, + languageId: "zh-Hans", + elementId: item.elementId, + translationId: item.translationId, + branchId: null, + }); + }), + ); + + refs.set("qa:element:approve", firstElementId); + refs.set("qa:element:reject", secondElementId); +}; + +const seedBranchWorkspace = async ( + execCtx: { db: DrizzleDB["client"] }, + refs: RefResolver, +) => { + const projectId = refs.getStringId("project"); + const adminId = refs.getStringId("user:admin"); + + const pr = await executeCommand(execCtx, createPR, { + projectId, + title: "E2E branch workspace", + body: "Branch workspace E2E fixture", + authorId: adminId, + reviewers: [], + branchName: "e2e/branch-workspace", + }); + + const opened = await executeCommand(execCtx, updatePRStatus, { + prId: pr.id, + status: "OPEN", + }); + + refs.set("pr:branch-workspace", opened.id); + refs.set("pr:branch-workspace:number", opened.number); + refs.set("branch:workspace", opened.branchId); +}; + /** * With Direction B, initialization is performed at server startup in * server/initialize.ts before the HTTP server starts. By the time Playwright's @@ -139,6 +292,9 @@ const globalSetup = async (_config: FullConfig): Promise => { `${result.summary.memoryItems} memory items.`, ); + await seedQaReviewWorkbench(execCtx, result.refs); + await seedBranchWorkspace(execCtx, result.refs); + // 7. Write refs JSON const refs: Record = {}; for (const [ref, id] of result.refs.entries()) { diff --git a/apps/app-e2e/package.json b/apps/app-e2e/package.json index 592d47b34..6481d8812 100644 --- a/apps/app-e2e/package.json +++ b/apps/app-e2e/package.json @@ -5,6 +5,7 @@ "type": "module", "devDependencies": { "@cat/db": "workspace:*", + "@cat/domain": "workspace:*", "@cat/seed": "workspace:*", "@playwright/test": "^1.60.0", "@types/node": "catalog:", diff --git a/apps/app-e2e/tests/branch-workspace.spec.ts b/apps/app-e2e/tests/branch-workspace.spec.ts new file mode 100644 index 000000000..0d27b6f70 --- /dev/null +++ b/apps/app-e2e/tests/branch-workspace.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "@/fixtures"; + +test.describe("Branch workspace", () => { + test("keeps selected branch after refresh and writes translation to branch", async ({ + page, + editorPage, + refs, + }) => { + const projectId = refs["project"]; + const contentNodeId = refs["content-node:elements"]; + const prNumber = refs["pr:branch-workspace:number"]; + const branchId = refs["branch:workspace"]; + const translationText = `branch translation ${Date.now()}`; + const branchTrigger = () => + page + .locator("button") + .filter({ + hasText: new RegExp(`main|pr-${prNumber}|branch-${branchId}`), + }) + .first(); + + await editorPage.navigateToProjectEditor(projectId, "zh-Hans", [ + contentNodeId, + ]); + await branchTrigger().click(); + await page + .getByRole("option", { + name: new RegExp(`^pr-${prNumber}\\s+—`), + }) + .click(); + await expect(page).toHaveURL(/branchId=/); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page).toHaveURL(new RegExp(`branchId=${branchId}`)); + await expect(branchTrigger()).toContainText( + new RegExp(`pr-${prNumber}|branch-${branchId}`), + ); + + await editorPage.selectElement(0, { waitForWritable: true }); + await editorPage.inputTranslation(translationText); + await editorPage.submitTranslation(); + + await page.goto(`/project/${projectId}/pull-requests/${prNumber}`); + await page.getByRole("tab", { name: "变更" }).click(); + await expect(page.getByText(translationText)).toBeVisible({ + timeout: 15_000, + }); + + await editorPage.navigateToProjectEditor(projectId, "zh-Hans", [ + contentNodeId, + ]); + await expect(branchTrigger()).toBeVisible(); + + if ( + (await branchTrigger().textContent())?.includes(`pr-${prNumber}`) || + (await branchTrigger().textContent())?.includes(`branch-${branchId}`) + ) { + await branchTrigger().click(); + await page.getByRole("option", { name: /^main$/ }).click(); + } + + await expect(branchTrigger()).toContainText("main"); + await editorPage.selectElement(0); + + const translationsSection = page + .locator("h3", { hasText: "所有翻译" }) + .locator(".."); + await expect(translationsSection.getByText(translationText)).toHaveCount(0); + }); +}); diff --git a/apps/app-e2e/tests/fixtures.ts b/apps/app-e2e/tests/fixtures.ts index b76ade3a4..6cbee33b8 100644 --- a/apps/app-e2e/tests/fixtures.ts +++ b/apps/app-e2e/tests/fixtures.ts @@ -4,6 +4,7 @@ import { resolve } from "node:path"; import { EditorPage } from "@/pages/editor-page"; import { LoginPage } from "@/pages/login-page"; +import { QaReviewPage } from "@/pages/qa-review-page"; // ── Types ──────────────────────────────────────────────────────────── @@ -16,6 +17,8 @@ interface E2EFixtures { loginPage: LoginPage; /** EditorPage Page Object for the current page */ editorPage: EditorPage; + /** QaReviewPage Page Object for QA review workbench */ + qaReviewPage: QaReviewPage; /** Pre-built URL to the seeded project dashboard */ projectUrl: string; } @@ -114,6 +117,10 @@ export const test = baseTest.extend({ await use(new EditorPage(page)); }, + qaReviewPage: async ({ page }, use) => { + await use(new QaReviewPage(page)); + }, + projectUrl: async ({ refs }, use) => { await use(`/project/${refs["project"]}`); }, diff --git a/apps/app-e2e/tests/pages/editor-page.ts b/apps/app-e2e/tests/pages/editor-page.ts index d22542468..a831223ff 100644 --- a/apps/app-e2e/tests/pages/editor-page.ts +++ b/apps/app-e2e/tests/pages/editor-page.ts @@ -63,6 +63,10 @@ export class EditorPage { await this.getElementItems() .first() .waitFor({ state: "visible", timeout: 30_000 }); + + await expect + .poll(() => this.page.url(), { timeout: 30_000 }) + .toMatch(/\/editor\/project\/[^/]+\/[^/]+\/(?:empty|\d+)(?:\?.*)?$/); } /** @@ -92,19 +96,26 @@ export class EditorPage { /** * Click an element in the sidebar by its index (0-based). - * After the URL updates, waits for the translate button to be enabled, - * confirming the element store has settled before the caller starts typing. + * After the URL updates, waits for the editor toolbar to render. Callers + * that plan to type can additionally require writable mode. */ - async selectElement(index: number): Promise { + async selectElement( + index: number, + options?: { waitForWritable?: boolean }, + ): Promise { const items = this.getElementItems(); await items.nth(index).click(); // Wait for the element to be selected (URL should update) await this.page.waitForURL(/\/editor\/project\/[^/]+\/[^/]+\/\d+/); - // Wait for the translate button — confirms toElement() completed and - // elementId/context are stable before typing starts. - await this.page - .getByRole("button", { name: "提交", exact: true }) - .waitFor({ state: "visible", timeout: 5_000 }); + const submitButton = this.page.getByRole("button", { + name: "提交", + exact: true, + }); + await submitButton.waitFor({ state: "visible", timeout: 5_000 }); + + if (options?.waitForWritable) { + await expect(submitButton).toBeEnabled({ timeout: 15_000 }); + } } /** @@ -112,6 +123,12 @@ export class EditorPage { * CodeMirror uses a contenteditable div with role="textbox". */ async inputTranslation(text: string): Promise { + await expect(this.page.locator(".translation-editor")).toHaveAttribute( + "aria-disabled", + "false", + { timeout: 15_000 }, + ); + // Narrow to the editable CodeMirror editor (contenteditable="true"), // as the page may also contain readonly source/suggestion editors. const editor = this.page.locator( diff --git a/apps/app-e2e/tests/pages/qa-review-page.ts b/apps/app-e2e/tests/pages/qa-review-page.ts new file mode 100644 index 000000000..da439d4b2 --- /dev/null +++ b/apps/app-e2e/tests/pages/qa-review-page.ts @@ -0,0 +1,44 @@ +import { expect, type Page } from "@playwright/test"; + +export class QaReviewPage { + constructor(private readonly page: Page) {} + + async navigateToQa(projectId: string, languageToId: string): Promise { + await this.page.goto( + `/qa-review/project/${projectId}/${languageToId}/auto`, + ); + await this.page.waitForURL( + /\/qa-review\/project\/[^/]+\/[^/]+\/(?:empty|\d+)(?:\?.*)?$/, + ); + } + + async selectFirstCandidate(): Promise { + await this.page + .getByRole("button", { name: /选择候选/ }) + .first() + .click(); + await expect( + this.page.getByRole("button", { name: /同意|批注并同意/ }), + ).toBeVisible(); + } + + async addNote(text: string): Promise { + await this.page.getByPlaceholder("写入审校批注").fill(text); + } + + async approve(): Promise { + await this.page.getByRole("button", { name: /批注并同意|同意/ }).click(); + const dialog = this.page.getByRole("dialog", { name: /确认覆盖阻断风险/ }); + if (await dialog.isVisible().catch(() => false)) { + await dialog.getByRole("button", { name: "确认同意" }).click(); + } + } + + async reject(): Promise { + await this.page.getByRole("button", { name: /批注并拒绝|拒绝/ }).click(); + } + + async defer(): Promise { + await this.page.getByRole("button", { name: /批注并跳过|跳过/ }).click(); + } +} diff --git a/apps/app-e2e/tests/project-shell-refresh.spec.ts b/apps/app-e2e/tests/project-shell-refresh.spec.ts new file mode 100644 index 000000000..a5bc58182 --- /dev/null +++ b/apps/app-e2e/tests/project-shell-refresh.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from "@/fixtures"; + +test.describe("Project shell SSR refresh", () => { + test("refreshes project pull request list without losing header project data", async ({ + page, + refs, + }) => { + const projectId = refs["project"]; + + await page.goto(`/project/${projectId}/pull-requests`); + await page.reload({ waitUntil: "networkidle" }); + + await expect(page.getByRole("navigation")).toContainText("拉取请求"); + await expect(page.getByText("main").first()).toBeVisible(); + }); + + test("refreshes project workflow list without losing shell navbar", async ({ + page, + refs, + }) => { + const projectId = refs["project"]; + + await page.goto(`/project/${projectId}/workflows`); + await page.reload({ waitUntil: "networkidle" }); + + await expect(page.getByRole("navigation")).toContainText("工作流"); + await expect(page.getByText("main").first()).toBeVisible(); + }); +}); diff --git a/apps/app-e2e/tests/qa-review-workbench.spec.ts b/apps/app-e2e/tests/qa-review-workbench.spec.ts new file mode 100644 index 000000000..3c5123c62 --- /dev/null +++ b/apps/app-e2e/tests/qa-review-workbench.spec.ts @@ -0,0 +1,278 @@ +import { and, DrizzleDB, eq, vectorizedString } from "@cat/db"; +import { + createContentNodeUnderParent, + createElements, + createProject, + createQaReviewRunWithFindings, + createRootContentNode, + createTranslations, + executeCommand, + materializeQaReviewQueueItem, +} from "@cat/domain"; +import { randomUUID } from "node:crypto"; + +import { test, expect, type E2ERefs } from "./fixtures"; + +type QaScenarioItem = { + sourceText: string; + candidateText: string; + message: string; + action: "BLOCK_APPROVAL" | "NEEDS_REVIEW"; + riskScore: number; +}; + +const insertString = async ( + db: DrizzleDB["client"], + value: string, + languageId: string, +) => { + const [row] = await db + .insert(vectorizedString) + .values({ value, languageId }) + .onConflictDoNothing() + .returning({ id: vectorizedString.id }); + + if (row?.id) { + return row.id; + } + + const [existing] = await db + .select({ id: vectorizedString.id }) + .from(vectorizedString) + .where( + and( + eq(vectorizedString.value, value), + eq(vectorizedString.languageId, languageId), + ), + ) + .limit(1); + + if (!existing?.id) { + throw new Error( + `Failed to resolve vectorized string for ${languageId}:${value}`, + ); + } + + return existing.id; +}; + +const seedQaReviewProject = async ( + refs: E2ERefs, + items: QaScenarioItem[], +): Promise<{ projectId: string; elementIds: number[] }> => { + const drizzleDB = new DrizzleDB(); + await drizzleDB.connect(); + + try { + const execCtx = { db: drizzleDB.client }; + const adminId = refs["user:admin"]; + + if (!adminId) { + throw new Error("Missing user:admin ref for QA review E2E setup"); + } + + const project = await executeCommand(execCtx, createProject, { + name: `qa-review-e2e-${randomUUID()}`, + description: null, + creatorId: adminId, + }); + const root = await executeCommand(execCtx, createRootContentNode, { + projectId: project.id, + creatorId: adminId, + }); + const file = await executeCommand(execCtx, createContentNodeUnderParent, { + projectId: project.id, + creatorId: adminId, + parentContentNodeId: root.id, + kind: "FILE", + displayLabel: `qa-review-e2e-${randomUUID()}.json`, + importerId: "test-json", + sourceRootRef: "root", + stableSourceNodeRef: `qa-review-file-${randomUUID()}`, + exportRole: "FILE", + boundaryType: "FILE", + localOrder: 0, + }); + + const elementIds = await Promise.all( + items.map(async (item, index) => { + const sourceStringId = await insertString( + drizzleDB.client, + item.sourceText, + "en", + ); + const [elementId] = await executeCommand(execCtx, createElements, { + data: [ + { + projectId: project.id, + primaryContentNodeId: file.id, + importerId: "test-json", + sourceRootRef: "root", + sourceNodeRef: `qa-review-e2e-${index}`, + stableSourceRef: `qa-review-e2e-element-${randomUUID()}`, + stringId: sourceStringId, + localOrder: index, + }, + ], + }); + const candidateStringId = await insertString( + drizzleDB.client, + item.candidateText, + "zh-Hans", + ); + const [translationId] = await executeCommand( + execCtx, + createTranslations, + { + data: [ + { + translatableElementId: elementId, + translatorId: adminId, + stringId: candidateStringId, + }, + ], + }, + ); + + await executeCommand(execCtx, createQaReviewRunWithFindings, { + projectId: project.id, + elementId, + translationId, + branchId: null, + layer: "DETERMINISTIC", + status: "COMPLETED", + riskScore: item.riskScore, + summary: item.message, + findings: [ + { + layer: "DETERMINISTIC", + checkerServiceId: null, + qaResultItemId: null, + ruleId: "e2e.qa", + ruleFamily: "e2e", + severity: item.action === "BLOCK_APPROVAL" ? "error" : "warning", + action: item.action, + disposition: "OPEN", + confidenceBasisPoints: 10000, + riskScore: item.riskScore, + message: item.message, + explanation: null, + sourceSpan: null, + targetSpan: null, + suggestedText: null, + meta: null, + }, + ], + }); + + await executeCommand(execCtx, materializeQaReviewQueueItem, { + projectId: project.id, + languageId: "zh-Hans", + elementId, + translationId, + branchId: null, + }); + + return elementId; + }), + ); + + return { projectId: project.id, elementIds }; + } finally { + await drizzleDB.disconnect(); + } +}; + +test.describe("QA review workbench", () => { + test("approves, rejects, and reaches empty state", async ({ + refs, + qaReviewPage, + page, + }) => { + const scenario = await seedQaReviewProject(refs, [ + { + sourceText: "Hello World", + candidateText: "QA approved candidate", + message: "Missing placeholder", + action: "BLOCK_APPROVAL", + riskScore: 100, + }, + { + sourceText: "Save Changes", + candidateText: "QA rejected candidate", + message: "Needs style review", + action: "NEEDS_REVIEW", + riskScore: 60, + }, + ]); + const [approveElementId, rejectElementId] = scenario.elementIds; + + await qaReviewPage.navigateToQa(scenario.projectId, "zh-Hans"); + await page.waitForURL( + new RegExp( + `/qa-review/project/${scenario.projectId}/zh-Hans/${approveElementId}(?:\\?.*)?$`, + ), + ); + await expect(page.getByText("阻断批准").first()).toBeVisible({ + timeout: 15_000, + }); + await qaReviewPage.selectFirstCandidate(); + await qaReviewPage.addNote("E2E approve note"); + await qaReviewPage.approve(); + await page.waitForURL( + new RegExp( + `/qa-review/project/${scenario.projectId}/zh-Hans/${rejectElementId}(?:\\?.*)?$`, + ), + { timeout: 15_000 }, + ); + + await qaReviewPage.selectFirstCandidate(); + await qaReviewPage.addNote("E2E reject note"); + await qaReviewPage.reject(); + await page.waitForURL( + new RegExp( + `/qa-review/project/${scenario.projectId}/zh-Hans/empty(?:\\?.*)?$`, + ), + { timeout: 15_000 }, + ); + + await expect(page.getByText("当前筛选已处理完")).toBeVisible({ + timeout: 15_000, + }); + }); + + test("defer keeps the candidate visible for later processing", async ({ + refs, + qaReviewPage, + page, + }) => { + const scenario = await seedQaReviewProject(refs, [ + { + sourceText: "Profile", + candidateText: "QA deferred candidate", + message: "Needs reviewer follow-up", + action: "NEEDS_REVIEW", + riskScore: 55, + }, + ]); + const [deferElementId] = scenario.elementIds; + + await page.goto( + `/qa-review/project/${scenario.projectId}/zh-Hans/${deferElementId}`, + ); + await page.waitForURL( + new RegExp( + `/qa-review/project/${scenario.projectId}/zh-Hans/${deferElementId}(?:\\?.*)?$`, + ), + ); + await qaReviewPage.selectFirstCandidate(); + await qaReviewPage.defer(); + + await page.goto( + `/qa-review/project/${scenario.projectId}/zh-Hans/${deferElementId}`, + ); + await expect( + page.getByRole("button", { name: /选择候选/ }).first(), + ).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/apps/app/locales/en_us.json b/apps/app/locales/en_us.json index e1c372c34..ea3b8c743 100644 --- a/apps/app/locales/en_us.json +++ b/apps/app/locales/en_us.json @@ -42,7 +42,7 @@ "未找到内容节点": "No content nodes found", "移除内容节点过滤器": "Remove content node filter", "打开编辑工作台": "Open editor workbench", - "打开 QA 审校工作台": "Open QA Review Workbench", + "打开审校工作台": "Open Review Workbench", "QA 审校工作台": "QA Review Workbench", "按风险优先处理机器 QA 与人工审校队列": "Prioritize machine QA and human review queues by risk", "{count} 个待审校项": "{count} review items", diff --git a/apps/app/moon.yml b/apps/app/moon.yml index 5da2dfb47..f8dcbcc56 100644 --- a/apps/app/moon.yml +++ b/apps/app/moon.yml @@ -63,7 +63,7 @@ tasks: persistent: true typecheck: - script: "tsc --noEmit -p tsconfig.json && tsc --noEmit -p scripts/tsconfig.json && vue-tsc --noEmit -p tsconfig.json" + script: "tsc --noEmit -p tsconfig.json && tsc --noEmit -p scripts/tsconfig.json && vue-tsc --noEmit -p tsconfig.json && vue-tsc --noEmit -p tsconfig.spec.json" copy-drizzle: command: "tsx scripts/copy-drizzle.ts" diff --git a/apps/app/package.json b/apps/app/package.json index d4e6e230a..f11d1bdf0 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -82,6 +82,7 @@ "dompurify": "^3.4.5", "dotenv": "^17.4.2", "hono": "^4.12.22", + "isomorphic-dompurify": "^3.14.0", "marked": "^18.0.4", "partysocket": "^1.1.19", "pinia": "^3.0.4", diff --git a/apps/app/src/components/Comments.vue b/apps/app/src/components/Comments.vue index 590692367..1df9fd2d7 100644 --- a/apps/app/src/components/Comments.vue +++ b/apps/app/src/components/Comments.vue @@ -19,6 +19,7 @@ import { useI18n } from "vue-i18n"; import MarkdownEditor from "@/components/editor/MarkdownEditor.vue"; import TextTooltip from "@/components/tooltip/TextTooltip.vue"; import { orpc } from "@/rpc/orpc"; +import { useEditorContextStore } from "@/stores/editor/context"; import { useEditorTableStore } from "@/stores/editor/table"; import Comment from "./Comment.vue"; @@ -31,18 +32,33 @@ const props = defineProps<{ const { t } = useI18n(); const { elementId } = storeToRefs(useEditorTableStore()); +const { scope } = storeToRefs(useEditorContextStore()); const { state, refetch } = useQuery({ - key: ["rootComments", elementId.value, 10, 0], + key: () => [ + "rootComments", + props.targetType, + props.targetId, + scope.value?.projectId ?? null, + scope.value?.branchId ?? null, + 10, + 0, + ], placeholderData: [], - query: () => - orpc.comment.getRootComments({ + query: async () => { + const currentScope = scope.value; + if (!currentScope) return []; + + return await orpc.comment.getRootComments({ targetType: props.targetType, targetId: props.targetId, pageIndex: 0, pageSize: 10, - }), - enabled: !import.meta.env.SSR, + projectId: currentScope.projectId, + branchId: currentScope.branchId, + }); + }, + enabled: () => !import.meta.env.SSR && scope.value !== null, }); const openEditor = defineModel("openEditor", { default: false }); @@ -50,19 +66,23 @@ const openEditor = defineModel("openEditor", { default: false }); const content = ref(""); const comment = async () => { - if (!elementId.value) return; + const currentScope = scope.value; + if (!elementId.value || !currentScope) return; await orpc.comment.comment({ targetType: props.targetType, targetId: props.targetId, content: content.value, languageId: "en", + projectId: currentScope.projectId, + branchId: currentScope.branchId, }); + content.value = ""; refetch(); }; -const handleDelete = (commentId: number) => { +const handleDelete = () => { refetch(); }; diff --git a/apps/app/src/components/Markdown.spec.ts b/apps/app/src/components/Markdown.spec.ts new file mode 100644 index 000000000..8788774bb --- /dev/null +++ b/apps/app/src/components/Markdown.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { h } from "vue"; +import { renderToString } from "vue/server-renderer"; + +import Markdown from "./Markdown.vue"; + +describe("Markdown", () => { + it("renders markdown safely during SSR", async () => { + const html = await renderToString( + h(Markdown, { content: "**hello** " }), + ); + + expect(html).toContain("hello"); + expect(html).not.toContain("