diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c06c486a..576d5aed 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -65,6 +65,7 @@ import { createGithubBindingRoutes } from "./routes/github-bindings.js"; import { createDriveVersionRoutes } from "./routes/drive-versions.js"; import { createSpotlightIntentRoutes } from "./routes/spotlight-intent.js"; import { createPersonalProjectRoutes } from "./routes/personal-projects.js"; +import { createWorkspaceAuditRoutes } from "./routes/workspace-audit.js"; import { TaskPlanApprovalError } from "./services/task-plan-approval.js"; import { ProjectServiceError } from "./services/projects.js"; import { PilotDay1MetricsServiceError } from "./services/pilot-day1-metrics.js"; @@ -313,6 +314,8 @@ app.route("/api", createGithubBindingRoutes()); app.route("/api/drive", createDriveVersionRoutes()); app.route("/api", createSpotlightIntentRoutes()); app.route("/api", createPersonalProjectRoutes()); +// R20 P2A(R19-21):工作区级审计列表(GET /api/workspace/audit,仅管理员,工作区硬隔离)。 +app.route("/api", createWorkspaceAuditRoutes()); app.route("/api/pilot", createPilotRoutes()); app.route("/api/ai-worklog", createAiWorklogRoutes()); diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts index 4bd8bd8d..3799384f 100644 --- a/apps/api/src/auth.test.ts +++ b/apps/api/src/auth.test.ts @@ -2489,7 +2489,8 @@ test("desktop-bootstrap mints a device token for an existing admin nickname with assert.deepEqual(await who.json(), { id: admin.id, is_admin: true }); }); -test("desktop-bootstrap is disabled (404) in password mode", async () => { +test("desktop-bootstrap (password mode) refuses nickname self-provision without a session (404)", async () => { + // P1-02(REL-5):密码模式不再无条件 404——但无会话(首启尚未凭据登录)仍拒。桌面据此 404 渲凭据登录门。 const app = withErrors(new Hono()); app.route("/api/auth", createAuthRoutes(deps([], [], settings({ AUTH_MODE: "password" })))); const res = await app.request("/api/auth/desktop-bootstrap", { @@ -2499,3 +2500,55 @@ test("desktop-bootstrap is disabled (404) in password mode", async () => { }); assert.equal(res.status, 404, "password mode must require credentials, not nickname self-provision"); }); + +test("desktop-bootstrap (password mode) exchanges a valid session for a device token", async () => { + // 修复前:密码模式对本请求无条件 404(无可用登录链路)。修复后:已凭据登录(持有效会话)→ 换设备令牌。 + const runtimeSettings = settings({ AUTH_MODE: "password" }); + const alice = user({ nickname: "alice" }); + const sessions = new MemorySessions(); + const authDeps: AuthDependencies = { ...deps([alice], [], runtimeSettings), sessions }; + const app = withErrors(new Hono()); + app.route("/api/auth", createAuthRoutes(authDeps)); + app.get("/who", createCurrentUserMiddleware(authDeps), (c) => c.json({ id: c.var.currentUser.id })); + + // 模拟「已通过 /api/auth/login」:建一条会话,其 secret 走 signed cookie(与 issueSessionCookie 同键)。 + const { token: sessionToken } = await mintSession(authDeps, alice, { authMethod: "password" }); + const res = await app.request("/api/auth/desktop-bootstrap", { + method: "POST", + headers: { + "content-type": "application/json", + Cookie: await signedCookie(sessionToken, runtimeSettings) + }, + // nickname 在密码模式被忽略(身份来自会话);仍按 schema 传占位值(nickname 必填)。 + body: JSON.stringify({ nickname: "ignored-in-password-mode", device_name: "Alice Mac", platform: "desktop" }) + }); + assert.equal(res.status, 201, "valid session must exchange for a device token in password mode"); + const body = (await res.json()) as { + client_token: string; + identity: { id: string }; + device: { device_name: string; user_id: string }; + }; + assert.ok(body.client_token.length >= 32, "returns a usable device client token in the body"); + assert.equal(body.identity.id, alice.id); + assert.equal(body.device.user_id, alice.id); + assert.equal(body.device.device_name, "Alice Mac"); + + // 换到的设备令牌无 cookie 即可鉴权后续请求——这正是桌面跨源所需(同 nickname 引导的最终形态)。 + const who = await app.request("/who", { headers: { [LOCAL_CLIENT_HEADER]: body.client_token } }); + assert.equal(who.status, 200, "minted device token authenticates a follow-up request with no cookie"); + assert.deepEqual(await who.json(), { id: alice.id }); +}); + +test("desktop-bootstrap (password mode) rejects a garbage client token with 403", async () => { + // 呈递了 client-token header 却解析不到设备 → fail-closed 403(绝不落到 404/静默签发设备令牌)。 + const runtimeSettings = settings({ AUTH_MODE: "password" }); + const authDeps: AuthDependencies = { ...deps([], [], runtimeSettings), sessions: new MemorySessions() }; + const app = withErrors(new Hono()); + app.route("/api/auth", createAuthRoutes(authDeps)); + const res = await app.request("/api/auth/desktop-bootstrap", { + method: "POST", + headers: { "content-type": "application/json", [LOCAL_CLIENT_HEADER]: "garbage-token" }, + body: JSON.stringify({ nickname: "x", device_name: "x" }) + }); + assert.equal(res.status, 403, "bad client token must fail closed, not exchange for a device token"); +}); diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 6830cc5e..d85af0de 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -2489,6 +2489,35 @@ const bootstrapProjectResponse = { ]).responses["409"] } } as const; +// R20 P2A(R19-18 指派):work_item_assignments 行的响应形状(与 assignmentSchema 同源)。 +const workItemAssignmentResponseSchema = { + type: "object", + required: ["id", "work_item_id", "user_id", "role", "assigned_by_user_id", "created_at", "updated_at"], + properties: { + id: uuidStringSchema, + work_item_id: uuidStringSchema, + user_id: uuidStringSchema, + role: { type: "string", enum: ["lead", "collaborator"] }, + assigned_by_user_id: uuidStringSchema, + created_at: dateTimeStringSchema, + updated_at: dateTimeStringSchema + }, + additionalProperties: false +} as const; +// R20 P2A(R19-22 评论):comments 行的响应形状。 +const workItemCommentResponseSchema = { + type: "object", + required: ["id", "work_item_id", "author_nickname", "body", "created_at", "updated_at"], + properties: { + id: uuidStringSchema, + work_item_id: uuidStringSchema, + author_nickname: { type: "string", minLength: 1, maxLength: 64 }, + body: { type: "string", minLength: 1 }, + created_at: dateTimeStringSchema, + updated_at: dateTimeStringSchema + }, + additionalProperties: false +} as const; const drivePreviewResponse = { responses: { "200": { @@ -5913,6 +5942,51 @@ const updateWorkspaceMemberRoleResponses = { "500": conversationInternalResponse } } as const; +// R20 P2A(P1-08 修复 · workspace-scoped roster):GET /api/workspace/roster —— 任意工作区成员分页读本 +// 工作区花名册(取代消费端误用的全局 /api/users)。limit/offset 分页,回工作区成员总数 + 头像/在线态占位。 +const workspaceRosterQueryParameters = [ + { name: "limit", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 100, default: 50 } }, + { name: "offset", in: "query", required: false, schema: { type: "integer", minimum: 0, default: 0 } } +] as const; +const listWorkspaceRosterResponses = { + responses: { + "200": jsonDataResponse( + { + type: "object", + required: ["members", "total", "limit", "offset"], + properties: { + members: { + type: "array", + items: { + type: "object", + required: ["user_id", "nickname", "role", "joined_at", "is_self", "avatar_updated_at", "online"], + properties: { + user_id: uuidStringSchema, + nickname: { type: "string", minLength: 1, maxLength: 96 }, + role: workspaceMemberRoleSchema, + joined_at: dateTimeStringSchema, + is_self: { type: "boolean" }, + avatar_updated_at: { ...dateTimeStringSchema, nullable: true }, + online: { type: "boolean", nullable: true } + }, + additionalProperties: false + } + }, + total: { type: "integer", minimum: 0 }, + limit: { type: "integer", minimum: 1, maximum: 100 }, + offset: { type: "integer", minimum: 0 } + }, + additionalProperties: false + }, + "A page of the caller's workspace member roster" + ).responses["200"], + "401": conversationAuthRequiredResponse, + "403": jsonErrorStatusResponse("403", "Only members of the workspace may read its roster", [ + "roster_forbidden" + ]).responses["403"], + "500": conversationInternalResponse + } +} as const; const presenceUserIdsQueryParameter = { name: "user_ids", @@ -8296,6 +8370,14 @@ export function getOpenApiDocument() { ...listWorkspaceMembersResponses } }, + "/api/workspace/roster": { + get: { + tags: ["conversations"], + summary: "Page the caller's workspace roster (any member): nickname, role, joined-at, is-self, avatar/online placeholders", + parameters: [...workspaceRosterQueryParameters], + ...listWorkspaceRosterResponses + } + }, "/api/workspace/members/{userId}": { delete: { tags: ["conversations"], @@ -8311,6 +8393,58 @@ export function getOpenApiDocument() { ...updateWorkspaceMemberRoleResponses } }, + "/api/workspace/audit": { + get: { + tags: ["conversations"], + summary: "List workspace-scoped audit logs (admin only): actor/action/time filters, paginated, newest first", + parameters: [ + optionalUuidQueryParameter("actor_user_id"), + { + name: "action", + in: "query", + required: false, + schema: { type: "string", minLength: 1, maxLength: 64 } + }, + optionalDateTimeQueryParameter("from"), + optionalDateTimeQueryParameter("to"), + { + name: "limit", + in: "query", + required: false, + schema: { type: "integer", minimum: 1, maximum: 200 } + }, + optionalNonNegativeIntegerQueryParameter("offset") + ], + responses: { + "200": jsonDataResponse({ + type: "object", + required: ["generated_at", "workspace_id", "audit_logs", "page"], + properties: { + generated_at: dateTimeStringSchema, + workspace_id: uuidStringSchema, + audit_logs: { type: "array", items: { type: "object", additionalProperties: true } }, + page: { + type: "object", + required: ["limit", "offset", "count"], + properties: { + limit: { type: "integer", minimum: 1 }, + offset: { type: "integer", minimum: 0 }, + count: { type: "integer", minimum: 0 } + }, + additionalProperties: false + } + }, + additionalProperties: false + }, "Workspace audit log page").responses["200"], + "401": jsonErrorStatusResponse("401", "Workspace audit requires an authenticated user", [ + "not_identified" + ]).responses["401"], + "403": jsonErrorStatusResponse("403", "Workspace audit is admin-only", [ + "forbidden" + ]).responses["403"] + } + } + }, "/api/presence": { get: { tags: ["conversations"], @@ -9103,6 +9237,163 @@ export function getOpenApiDocument() { ...projectInstructionsPatchResponses } }, + "/api/projects/{id}/archive": { + post: { + tags: ["projects"], + summary: "Archive a project (soft archived=true; admin or project owner only)", + parameters: [pathUuidParameter("id")], + responses: { + "200": jsonDataResponse({ + type: "object", + required: ["project", "archived"], + properties: { + project: projectResponseSchema, + archived: { type: "boolean", const: true } + }, + additionalProperties: false + }, "Archived project").responses["200"], + "401": proposalNotIdentifiedResponse, + "403": jsonErrorStatusResponse("403", "Project is not manageable by the current user", [ + "project_forbidden" + ]).responses["403"], + "404": jsonErrorStatusResponse("404", "Project was not found or is already archived/deleted", [ + "project_not_found" + ]).responses["404"] + } + } + }, + "/api/projects/{id}/delete": { + post: { + tags: ["projects"], + summary: "Soft-delete a project (tombstone deletedAt; admin or project owner only)", + parameters: [pathUuidParameter("id")], + responses: { + "200": jsonDataResponse({ + type: "object", + required: ["project", "deleted"], + properties: { + project: projectResponseSchema, + deleted: { type: "boolean", const: true } + }, + additionalProperties: false + }, "Soft-deleted project").responses["200"], + "401": proposalNotIdentifiedResponse, + "403": jsonErrorStatusResponse("403", "Project is not manageable by the current user", [ + "project_forbidden" + ]).responses["403"], + "404": jsonErrorStatusResponse("404", "Project was not found or is already deleted", [ + "project_not_found" + ]).responses["404"] + } + } + }, + "/api/workitems/{id}/assign": { + post: { + tags: ["work-items"], + summary: "Assign a work item to a workspace member (writes work_item_assignments)", + parameters: [pathUuidParameter("id")], + ...jsonRequestBody({ + type: "object", + required: ["assignee_user_id"], + properties: { + assignee_user_id: uuidStringSchema, + role: { type: "string", enum: ["lead", "collaborator"] } + }, + additionalProperties: false + }), + responses: { + "201": jsonDataResponse({ + type: "object", + required: ["assignment"], + properties: { assignment: workItemAssignmentResponseSchema }, + additionalProperties: false + }, "Created or updated assignment").responses["200"], + "401": proposalNotIdentifiedResponse, + "403": jsonErrorStatusResponse("403", "Work item assignees are not manageable by the current user", [ + "forbidden" + ]).responses["403"], + "404": jsonErrorStatusResponse("404", "Work item was not found", ["not_found"]).responses["404"], + "409": jsonErrorStatusResponse("409", "Work item has no owning workspace yet", [ + "work_item_workspace_missing" + ]).responses["409"], + "422": jsonErrorStatusResponse("422", "Assignee is not a workspace member or payload is invalid", [ + "assignee_not_member", + "validation_error" + ]).responses["422"] + } + } + }, + "/api/workitems/{id}/claim": { + post: { + tags: ["work-items"], + summary: "Claim an ownerless work item as the current user (CAS: only when unclaimed)", + parameters: [pathUuidParameter("id")], + responses: { + "200": jsonDataResponse({ + type: "object", + required: ["work_item_id", "claimed_by_user_id"], + properties: { + work_item_id: uuidStringSchema, + claimed_by_user_id: uuidStringSchema + }, + additionalProperties: false + }, "Claimed work item").responses["200"], + "401": proposalNotIdentifiedResponse, + "403": jsonErrorStatusResponse("403", "Work item is not claimable by the current user", [ + "forbidden" + ]).responses["403"], + "404": jsonErrorStatusResponse("404", "Work item was not found", ["not_found"]).responses["404"], + "409": jsonErrorStatusResponse("409", "Work item is already claimed or no longer claimable", [ + "work_item_not_claimable" + ]).responses["409"] + } + } + }, + "/api/workitems/{id}/comments": { + get: { + tags: ["work-items"], + summary: "List a work item's comment thread (workspace members who can view the item)", + parameters: [pathUuidParameter("id")], + responses: { + "200": jsonDataResponse({ + type: "object", + required: ["work_item_id", "comments"], + properties: { + work_item_id: uuidStringSchema, + comments: { type: "array", items: workItemCommentResponseSchema } + }, + additionalProperties: false + }, "Work item comments").responses["200"], + "401": proposalNotIdentifiedResponse, + "403": jsonErrorStatusResponse("403", "Work item comments are not visible to the current user", [ + "forbidden" + ]).responses["403"], + "404": jsonErrorStatusResponse("404", "Work item was not found", ["not_found"]).responses["404"] + } + }, + post: { + tags: ["work-items"], + summary: "Add a comment to a work item (author is the current user)", + parameters: [pathUuidParameter("id")], + ...jsonRequestBody({ + type: "object", + required: ["body"], + properties: { body: { type: "string", minLength: 1, maxLength: 4000 } }, + additionalProperties: false + }), + responses: { + "201": jsonDataResponse(workItemCommentResponseSchema, "Created work item comment").responses["200"], + "401": proposalNotIdentifiedResponse, + "403": jsonErrorStatusResponse("403", "Work item comments are not visible to the current user", [ + "forbidden" + ]).responses["403"], + "404": jsonErrorStatusResponse("404", "Work item was not found", ["not_found"]).responses["404"], + "422": jsonErrorStatusResponse("422", "Comment body does not match the contract", [ + "validation_error" + ]).responses["422"] + } + } + }, "/api/workitems/{id}/dependencies": { post: { tags: ["work-items"], diff --git a/apps/api/src/project-ops.test.ts b/apps/api/src/project-ops.test.ts new file mode 100644 index 00000000..0365cdba --- /dev/null +++ b/apps/api/src/project-ops.test.ts @@ -0,0 +1,310 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Hono } from "hono"; +import { generateSignedCookie } from "hono/cookie"; +import { HTTPException } from "hono/http-exception"; +import { ZodError } from "zod"; + +import { loadSettings, type Settings } from "@workhub/config"; +import type { + ClientDeviceAuthRow, + ClientDeviceRepository, + ProjectRow, + UserAuthRow, + UserRepository, + WorkItemProjectRow +} from "@workhub/db"; + +import { COOKIE_NAME, type AuthActor, type AuthDependencies, type AuthEnv } from "./middleware/auth.js"; +import { httpErrorCodeFor } from "./http-error-codes.js"; +import { createProjectRoutes } from "./routes/projects.js"; +import { ProjectServiceError } from "./services/projects.js"; +import { createProjectOpsService, type ProjectOpsService } from "./services/project-ops.js"; + +const now = new Date("2026-07-15T00:00:00.000Z"); +const ownerId = "62000000-0000-4000-8000-000000000001"; +const projectId = "62000000-0000-4000-8000-0000000000aa"; + +function ownerActor(overrides: Partial = {}): AuthActor { + return { + kind: "human", + id: ownerId, + label: "owner", + userId: ownerId, + isAdmin: false, + orgId: "00000000-0000-4000-8000-000000000001", + workspaceId: "00000000-0000-4000-8000-000000000002", + ...overrides + }; +} + +function activeProject(overrides: Partial = {}): WorkItemProjectRow { + return { + id: projectId, + workspaceId: "00000000-0000-4000-8000-000000000002", + name: "渠道增长", + slug: "growth", + description: null, + ownerNickname: "owner", + ownerUserId: ownerId, + archived: false, + deletedAt: null, + isPersonal: false, + isDmContainer: false, + orgId: "00000000-0000-4000-8000-000000000001", + ...overrides + } as unknown as WorkItemProjectRow; +} + +function resultRow(overrides: Partial = {}): ProjectRow { + return { + id: projectId, + workspaceId: "00000000-0000-4000-8000-000000000002", + name: "渠道增长", + slug: "growth", + description: null, + ownerNickname: "owner", + ownerUserId: ownerId, + archived: true, + deletedAt: null, + isPersonal: false, + ...overrides + } as unknown as ProjectRow; +} + +function service(overrides: { + project?: WorkItemProjectRow | null; + archiveReturns?: ProjectRow | null; + deleteReturns?: ProjectRow | null; + calls?: { archive: string[]; softDelete: Array<{ projectId: string; deletedByNickname: string | null | undefined }> }; +} = {}) { + const calls = overrides.calls ?? { archive: [], softDelete: [] }; + return { + svc: createProjectOpsService({ + projectLookup: { + async findProjectById() { + return overrides.project === undefined ? activeProject() : overrides.project; + } + }, + projects: { + async archiveProject(input) { + calls.archive.push(input.projectId); + return overrides.archiveReturns === undefined ? resultRow() : overrides.archiveReturns; + }, + async softDeleteProject(input) { + calls.softDelete.push({ projectId: input.projectId, deletedByNickname: input.deletedByNickname }); + return overrides.deleteReturns === undefined ? resultRow({ deletedAt: now }) : overrides.deleteReturns; + } + }, + now: () => now + }), + calls + }; +} + +// ---- service unit tests ---- + +test("archiveProject: owner can archive; repo receives the project id and VM is returned", async () => { + const { svc, calls } = service(); + const result = await svc.archiveProject({ projectId, actor: ownerActor() }); + assert.equal(result.archived, true); + assert.equal(result.project.id, projectId); + assert.deepEqual(calls.archive, [projectId]); +}); + +test("archiveProject: admin (non-owner) can archive within the workspace", async () => { + const { svc, calls } = service({ project: activeProject({ ownerUserId: "someone-else" }) }); + const result = await svc.archiveProject({ + projectId, + actor: ownerActor({ isAdmin: true, userId: "admin-user", id: "admin-user" }) + }); + assert.equal(result.archived, true); + assert.deepEqual(calls.archive, [projectId]); +}); + +test("archiveProject: non-owner non-admin is forbidden and the repo is never called", async () => { + const { svc, calls } = service({ project: activeProject({ ownerUserId: "someone-else" }) }); + await assert.rejects( + () => svc.archiveProject({ projectId, actor: ownerActor({ userId: "intruder", id: "intruder" }) }), + (error: unknown) => error instanceof ProjectServiceError && error.status === 403 && error.code === "project_forbidden" + ); + assert.deepEqual(calls.archive, []); +}); + +test("archiveProject: missing project maps to 404 project_not_found", async () => { + const { svc } = service({ project: null }); + await assert.rejects( + () => svc.archiveProject({ projectId, actor: ownerActor() }), + (error: unknown) => error instanceof ProjectServiceError && error.status === 404 && error.code === "project_not_found" + ); +}); + +test("archiveProject: CAS miss after the permission gate maps to 404", async () => { + const { svc } = service({ archiveReturns: null }); + await assert.rejects( + () => svc.archiveProject({ projectId, actor: ownerActor() }), + (error: unknown) => error instanceof ProjectServiceError && error.status === 404 + ); +}); + +test("deleteProject: owner can soft-delete; deleter nickname is recorded", async () => { + const { svc, calls } = service(); + const result = await svc.deleteProject({ projectId, actor: ownerActor({ label: "owner-name" }) }); + assert.equal(result.deleted, true); + assert.equal(calls.softDelete.length, 1); + assert.equal(calls.softDelete[0]?.deletedByNickname, "owner-name"); +}); + +test("deleteProject: non-owner non-admin is forbidden", async () => { + const { svc, calls } = service({ project: activeProject({ ownerUserId: "someone-else" }) }); + await assert.rejects( + () => svc.deleteProject({ projectId, actor: ownerActor({ userId: "intruder", id: "intruder" }) }), + (error: unknown) => error instanceof ProjectServiceError && error.status === 403 + ); + assert.deepEqual(calls.softDelete, []); +}); + +// ---- route wiring tests ---- + +function settings(): Settings { + return loadSettings({ APP_ENV: "test", COOKIE_SECRET: "test-cookie-secret" }); +} + +function user(): UserAuthRow { + return { + id: ownerId, + nickname: "owner", + cookieToken: "cookie-owner", + preferredLocale: "zh-CN", + availabilityStatus: "free", + availabilityText: null, + availabilityUpdatedAt: null, + mutedNotificationTypes: [], + avatarWebp: null, + avatarUpdatedAt: null, + isAdmin: false, + deletedAt: null, + deletedByUserId: null, + createdAt: now, + updatedAt: now + }; +} + +class MemoryUsers implements UserRepository { + async findActiveById(id: string) { return id === ownerId ? user() : null; } + async findActiveByCookieToken(token: string) { return token === "cookie-owner" ? user() : null; } + async findActiveByNickname() { return null; } + async createUser(): Promise { throw new Error("not needed"); } + async getOrCreateActiveByNickname(): Promise<{ user: UserAuthRow; created: boolean }> { throw new Error("not needed"); } + async rotateCookieToken() { return null; } +} + +class MemoryDevices implements ClientDeviceRepository { + async findActiveByTokenHash() { return null; } + async findActiveByTokenHashForUser() { return null; } + async createClientDevice(): Promise { throw new Error("not needed"); } + async listByUser() { return []; } + async touchLastSeen() { return null; } + async revokeByIdForUser() { return null; } + async revokeByTokenHash() { return null; } +} + +function authDeps(runtimeSettings: Settings): AuthDependencies { + return { users: new MemoryUsers(), devices: new MemoryDevices(), settings: runtimeSettings, now: () => now }; +} + +async function cookie(runtimeSettings: Settings) { + return generateSignedCookie(COOKIE_NAME, "cookie-owner", runtimeSettings.auth.cookieSecret); +} + +function withErrors(app: Hono) { + app.onError((error, c) => { + if (error instanceof ZodError) { + return c.json({ ok: false, error: { code: "validation_error", message: "invalid payload" } }, 422); + } + if (error instanceof ProjectServiceError) { + return c.json({ ok: false, error: { code: error.code, message: error.message } }, error.status as 400); + } + if (error instanceof HTTPException) { + return c.json({ ok: false, error: { code: httpErrorCodeFor(error), message: error.message } }, error.status); + } + throw error; + }); + return app; +} + +function fakeProjectOps(): ProjectOpsService { + return { + async archiveProject({ projectId: id }) { + return { project: { id, workspace_id: null, name: "P", slug: "p", owner_nickname: "o", owner_user_id: null, is_personal: false }, archived: true }; + }, + async deleteProject({ projectId: id }) { + return { project: { id, workspace_id: null, name: "P", slug: "p", owner_nickname: "o", owner_user_id: null, is_personal: false }, deleted: true }; + } + }; +} + +function projectRoutesApp(projectOps: ProjectOpsService) { + const runtimeSettings = settings(); + const app = withErrors(new Hono()); + app.route("/api/projects", createProjectRoutes({ + auth: authDeps(runtimeSettings), + projects: { + async bootstrapProject() { throw new Error("unused"); }, + async listProjects() { return { generated_at: now.toISOString(), projects: [] }; }, + async createPersonalProject() { throw new Error("unused"); }, + async listPersonalProjects() { throw new Error("unused"); } + }, + projectOps + })); + return { app, runtimeSettings }; +} + +test("POST /api/projects/:id/archive returns 200 with the archived VM", async () => { + const { app, runtimeSettings } = projectRoutesApp(fakeProjectOps()); + const response = await app.request(`/api/projects/${projectId}/archive`, { + method: "POST", + headers: { Cookie: await cookie(runtimeSettings) } + }); + assert.equal(response.status, 200); + const body = await response.json() as { data: { archived: boolean; project: { id: string } } }; + assert.equal(body.data.archived, true); + assert.equal(body.data.project.id, projectId); +}); + +test("POST /api/projects/:id/delete surfaces service forbidden as 403", async () => { + const ops: ProjectOpsService = { + async archiveProject() { throw new Error("unused"); }, + async deleteProject() { throw new ProjectServiceError(403, "project_forbidden", "no"); } + }; + const { app, runtimeSettings } = projectRoutesApp(ops); + const response = await app.request(`/api/projects/${projectId}/delete`, { + method: "POST", + headers: { Cookie: await cookie(runtimeSettings) } + }); + assert.equal(response.status, 403); + const body = await response.json() as { error: { code: string } }; + assert.equal(body.error.code, "project_forbidden"); +}); + +test("POST /api/projects/:id/archive rejects a non-uuid id as 404 before the service", async () => { + let called = false; + const ops: ProjectOpsService = { + async archiveProject() { called = true; throw new Error("must not run"); }, + async deleteProject() { throw new Error("unused"); } + }; + const { app, runtimeSettings } = projectRoutesApp(ops); + const response = await app.request(`/api/projects/not-a-uuid/archive`, { + method: "POST", + headers: { Cookie: await cookie(runtimeSettings) } + }); + assert.equal(response.status, 404); + assert.equal(called, false); +}); + +test("POST /api/projects/:id/archive requires identity", async () => { + const { app } = projectRoutesApp(fakeProjectOps()); + const response = await app.request(`/api/projects/${projectId}/archive`, { method: "POST" }); + assert.equal(response.status, 401); +}); diff --git a/apps/api/src/projects-slug.test.ts b/apps/api/src/projects-slug.test.ts index 7ce7e0c9..46563659 100644 --- a/apps/api/src/projects-slug.test.ts +++ b/apps/api/src/projects-slug.test.ts @@ -43,6 +43,12 @@ function fakeRepo(): { repo: ProjectRepository; slugs: string[] } { }, async updateInstructions() { throw new Error("not needed for this test"); + }, + async archiveProject() { + throw new Error("not needed for this test"); + }, + async softDeleteProject() { + throw new Error("not needed for this test"); } }; return { repo, slugs }; diff --git a/apps/api/src/projects.test.ts b/apps/api/src/projects.test.ts index df4b9e6a..2b6d5cf1 100644 --- a/apps/api/src/projects.test.ts +++ b/apps/api/src/projects.test.ts @@ -421,6 +421,12 @@ test("project bootstrap derives a stable slug for repeated non-ascii project nam }, async updateInstructions() { throw new Error("not needed for this test"); + }, + async archiveProject() { + throw new Error("not needed for this test"); + }, + async softDeleteProject() { + throw new Error("not needed for this test"); } }; const service = createProjectService(repository, { settings: settings(), now: () => now }); @@ -473,6 +479,12 @@ test("project bootstrap maps archived/deleted slug occupancy to a recoverable co }, async updateInstructions() { throw new Error("not needed for this test"); + }, + async archiveProject() { + throw new Error("not needed for this test"); + }, + async softDeleteProject() { + throw new Error("not needed for this test"); } }; const service = createProjectService(repository, { settings: settings(), now: () => now }); diff --git a/apps/api/src/r20-routes-wiring.test.ts b/apps/api/src/r20-routes-wiring.test.ts new file mode 100644 index 00000000..4da599d4 --- /dev/null +++ b/apps/api/src/r20-routes-wiring.test.ts @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Hono } from "hono"; +import { generateSignedCookie } from "hono/cookie"; +import { HTTPException } from "hono/http-exception"; +import { ZodError } from "zod"; + +import { loadSettings, type Settings } from "@workhub/config"; +import type { + ClientDeviceAuthRow, + ClientDeviceRepository, + UserAuthRow, + UserRepository +} from "@workhub/db"; + +import { COOKIE_NAME, type AuthDependencies, type AuthEnv } from "./middleware/auth.js"; +import { httpErrorCodeFor } from "./http-error-codes.js"; +import { createInMemoryWorkItemService, WorkItemServiceError } from "./services/work-items.js"; +import { createWorkItemRoutes } from "./routes/workitems.js"; +import { createWorkspaceAuditRoutes } from "./routes/workspace-audit.js"; +import type { WorkItemAssignmentService } from "./services/work-item-assignment.js"; +import type { WorkItemCommentService } from "./services/work-item-comments.js"; +import type { WorkspaceAuditService } from "./services/workspace-audit.js"; + +const now = new Date("2026-07-15T00:00:00.000Z"); +const userId = "62000000-0000-4000-8000-000000000001"; +const workItemId = "62000000-0000-4000-8000-0000000000bb"; + +function settings(): Settings { + return loadSettings({ APP_ENV: "test", COOKIE_SECRET: "test-cookie-secret" }); +} + +function user(): UserAuthRow { + return { + id: userId, + nickname: "host", + cookieToken: "cookie-host", + preferredLocale: "zh-CN", + availabilityStatus: "free", + availabilityText: null, + availabilityUpdatedAt: null, + mutedNotificationTypes: [], + avatarWebp: null, + avatarUpdatedAt: null, + isAdmin: false, + deletedAt: null, + deletedByUserId: null, + createdAt: now, + updatedAt: now + }; +} + +class MemoryUsers implements UserRepository { + async findActiveById(id: string) { return id === userId ? user() : null; } + async findActiveByCookieToken(token: string) { return token === "cookie-host" ? user() : null; } + async findActiveByNickname() { return null; } + async createUser(): Promise { throw new Error("not needed"); } + async getOrCreateActiveByNickname(): Promise<{ user: UserAuthRow; created: boolean }> { throw new Error("not needed"); } + async rotateCookieToken() { return null; } +} + +class MemoryDevices implements ClientDeviceRepository { + async findActiveByTokenHash() { return null; } + async findActiveByTokenHashForUser() { return null; } + async createClientDevice(): Promise { throw new Error("not needed"); } + async listByUser() { return []; } + async touchLastSeen() { return null; } + async revokeByIdForUser() { return null; } + async revokeByTokenHash() { return null; } +} + +function authDeps(runtimeSettings: Settings): AuthDependencies { + return { users: new MemoryUsers(), devices: new MemoryDevices(), settings: runtimeSettings, now: () => now }; +} + +async function cookie(runtimeSettings: Settings) { + return generateSignedCookie(COOKIE_NAME, "cookie-host", runtimeSettings.auth.cookieSecret); +} + +function withErrors(app: Hono) { + app.onError((error, c) => { + if (error instanceof ZodError) { + return c.json({ ok: false, error: { code: "validation_error", message: "invalid payload" } }, 422); + } + if (error instanceof WorkItemServiceError) { + return c.json({ ok: false, error: { code: error.code, message: error.message } }, error.status as 400); + } + if (error instanceof HTTPException) { + return c.json({ ok: false, error: { code: httpErrorCodeFor(error), message: error.message } }, error.status); + } + throw error; + }); + return app; +} + +function assignmentsStub(overrides: Partial = {}): WorkItemAssignmentService { + return { + async assign() { + return { + assignment: { + id: "assign-1", + work_item_id: workItemId, + user_id: "62000000-0000-4000-8000-000000000002", + role: "collaborator", + assigned_by_user_id: userId, + created_at: now.toISOString(), + updated_at: now.toISOString() + } + }; + }, + async claim() { + return { work_item_id: workItemId, claimed_by_user_id: userId }; + }, + ...overrides + }; +} + +function commentsStub(overrides: Partial = {}): WorkItemCommentService { + return { + async list() { + return { + work_item_id: workItemId, + comments: [{ + id: "c-1", work_item_id: workItemId, author_nickname: "host", body: "hi", + created_at: now.toISOString(), updated_at: now.toISOString() + }] + }; + }, + async create() { + return { + id: "c-2", work_item_id: workItemId, author_nickname: "host", body: "new", + created_at: now.toISOString(), updated_at: now.toISOString() + }; + }, + ...overrides + }; +} + +function workItemApp(deps: { assignments?: WorkItemAssignmentService; comments?: WorkItemCommentService } = {}) { + const runtimeSettings = settings(); + const app = withErrors(new Hono()); + app.route("/api", createWorkItemRoutes({ + auth: authDeps(runtimeSettings), + workItems: createInMemoryWorkItemService({ now: () => now }), + assignments: deps.assignments ?? assignmentsStub(), + comments: deps.comments ?? commentsStub() + })); + return { app, runtimeSettings }; +} + +test("POST /api/workitems/:id/assign returns 201 with the assignment", async () => { + const { app, runtimeSettings } = workItemApp(); + const response = await app.request(`/api/workitems/${workItemId}/assign`, { + method: "POST", + headers: { Cookie: await cookie(runtimeSettings), "Content-Type": "application/json" }, + body: JSON.stringify({ assignee_user_id: "62000000-0000-4000-8000-000000000002", role: "lead" }) + }); + assert.equal(response.status, 201); + const body = await response.json() as { data: { assignment: { id: string } } }; + assert.equal(body.data.assignment.id, "assign-1"); +}); + +test("POST /api/workitems/:id/assign maps service forbidden to 403", async () => { + const { app, runtimeSettings } = workItemApp({ + assignments: assignmentsStub({ async assign() { throw new WorkItemServiceError(403, "forbidden", "no"); } }) + }); + const response = await app.request(`/api/workitems/${workItemId}/assign`, { + method: "POST", + headers: { Cookie: await cookie(runtimeSettings), "Content-Type": "application/json" }, + body: JSON.stringify({ assignee_user_id: "62000000-0000-4000-8000-000000000002" }) + }); + assert.equal(response.status, 403); +}); + +test("POST /api/workitems/:id/assign rejects an invalid role via 422", async () => { + const { app, runtimeSettings } = workItemApp(); + const response = await app.request(`/api/workitems/${workItemId}/assign`, { + method: "POST", + headers: { Cookie: await cookie(runtimeSettings), "Content-Type": "application/json" }, + body: JSON.stringify({ assignee_user_id: "62000000-0000-4000-8000-000000000002", role: "boss" }) + }); + assert.equal(response.status, 422); +}); + +test("POST /api/workitems/:id/claim maps 409 conflict through", async () => { + const { app, runtimeSettings } = workItemApp({ + assignments: assignmentsStub({ async claim() { throw new WorkItemServiceError(409, "work_item_not_claimable", "no"); } }) + }); + const response = await app.request(`/api/workitems/${workItemId}/claim`, { + method: "POST", + headers: { Cookie: await cookie(runtimeSettings) } + }); + assert.equal(response.status, 409); + const body = await response.json() as { error: { code: string } }; + assert.equal(body.error.code, "work_item_not_claimable"); +}); + +test("GET /api/workitems/:id/comments returns the thread", async () => { + const { app, runtimeSettings } = workItemApp(); + const response = await app.request(`/api/workitems/${workItemId}/comments`, { + headers: { Cookie: await cookie(runtimeSettings) } + }); + assert.equal(response.status, 200); + const body = await response.json() as { data: { comments: unknown[] } }; + assert.equal(body.data.comments.length, 1); +}); + +test("POST /api/workitems/:id/comments returns 201; blank body is 422", async () => { + const { app, runtimeSettings } = workItemApp(); + const ok = await app.request(`/api/workitems/${workItemId}/comments`, { + method: "POST", + headers: { Cookie: await cookie(runtimeSettings), "Content-Type": "application/json" }, + body: JSON.stringify({ body: "looks good" }) + }); + assert.equal(ok.status, 201); + + const blank = await app.request(`/api/workitems/${workItemId}/comments`, { + method: "POST", + headers: { Cookie: await cookie(runtimeSettings), "Content-Type": "application/json" }, + body: JSON.stringify({ body: " " }) + }); + assert.equal(blank.status, 422); +}); + +test("work item comment routes reject non-uuid ids as 404 and require identity", async () => { + const { app, runtimeSettings } = workItemApp(); + const badId = await app.request(`/api/workitems/not-a-uuid/comments`, { + headers: { Cookie: await cookie(runtimeSettings) } + }); + assert.equal(badId.status, 404); + + const noAuth = await app.request(`/api/workitems/${workItemId}/claim`, { method: "POST" }); + assert.equal(noAuth.status, 401); +}); + +// ---- workspace audit route ---- + +function workspaceAuditApp(svc: WorkspaceAuditService) { + const runtimeSettings = settings(); + const app = withErrors(new Hono()); + app.route("/api", createWorkspaceAuditRoutes({ auth: authDeps(runtimeSettings), workspaceAudit: svc })); + return { app, runtimeSettings }; +} + +test("GET /api/workspace/audit returns the audit page for an admin", async () => { + const { app, runtimeSettings } = workspaceAuditApp({ + async list({ actor, query }) { + return { + generated_at: now.toISOString(), + workspace_id: actor.workspaceId, + audit_logs: [], + page: { limit: query.limit ?? 50, offset: query.offset ?? 0, count: 0 } + }; + } + }); + const response = await app.request(`/api/workspace/audit?limit=10`, { + headers: { Cookie: await cookie(runtimeSettings) } + }); + assert.equal(response.status, 200); + const body = await response.json() as { data: { page: { limit: number } } }; + assert.equal(body.data.page.limit, 10); +}); + +test("GET /api/workspace/audit maps the admin-only HTTPException to 403 forbidden", async () => { + const { app, runtimeSettings } = workspaceAuditApp({ + async list() { throw new HTTPException(403, { message: "admin only" }); } + }); + const response = await app.request(`/api/workspace/audit`, { + headers: { Cookie: await cookie(runtimeSettings) } + }); + assert.equal(response.status, 403); + const body = await response.json() as { error: { code: string } }; + assert.equal(body.error.code, "forbidden"); +}); + +test("GET /api/workspace/audit rejects a malformed limit via 422 and requires identity", async () => { + const { app, runtimeSettings } = workspaceAuditApp({ async list() { throw new Error("must not run"); } }); + const bad = await app.request(`/api/workspace/audit?limit=0`, { + headers: { Cookie: await cookie(runtimeSettings) } + }); + assert.equal(bad.status, 422); + + const noAuth = await app.request(`/api/workspace/audit`); + assert.equal(noAuth.status, 401); +}); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 454c115c..5ffcd7d4 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -293,7 +293,39 @@ export function createAuthRoutes( routes.post("/desktop-bootstrap", async (c) => { const deps = resolveAuthDependencies(source); if (passwordModeEnabled(deps)) { - throw new HTTPException(404, { message: "桌面引导在当前认证模式下不可用" }); + // P1-02(REL-5):密码/hybrid 模式下桌面不能昵称自助引导,但仍需要一条可用登录链路。 + // 本端点在密码模式改为「凭已建立的会话换设备令牌」的 exchange:用户先经既有 /api/auth/login + // 建会话(cookie),桌面再打这一条把会话换成后续请求走 header 的 client_token——桌面跨源 + // (tauri://localhost→127.0.0.1) 不能靠 SameSite=Lax cookie 鉴权,必须持 token 走 header + // (见 middleware/auth.ts 顶注)。不新造鉴权:会话校验完全复用 resolveCurrentUser。 + // 本路径已在 CSRF 同源守卫豁免(middleware/csrf.ts 的 desktop-bootstrap),桌面跨源 POST 得以送达; + // 会话 cookie 的 SameSite=Lax 仍是这条豁免路径的 CSRF 防线(跨站攻击页发不出带会话的请求 → + // 下面解析不到用户 → 404 拒),故豁免不新增 CSRF 面。 + // 无有效会话(首启尚未登录 / 会话过期)→ 404 拒(沿用本端点既有「当前模式不可用」语义,桌面据此 + // 渲凭据登录表单,登录后重打本端点);持垃圾 client token → resolveOptionalCurrentUser 冒泡 403 拒。 + const user = await resolveOptionalCurrentUser(c, deps); + if (!user) { + throw new HTTPException(404, { message: "桌面引导在密码模式下需要先用凭据登录" }); + } + const payload = desktopBootstrapRequestSchema.parse(await readJsonObject(c)); + // ENV-01:同 nickname 引导幂等确保默认工作区 active membership,否则换到 token 也进不了任何会话。 + await ensureDefaultWorkspaceMembership(deps, user.id); + const token = makeClientToken(); + const device = await deps.devices.createClientDevice({ + userId: user.id, + deviceName: payload.device_name.trim(), + platform: (payload.platform ?? "desktop").trim().slice(0, 64) || "desktop", + clientTokenHash: hashClientToken(token), + lastSeenAt: (deps.now ?? (() => new Date()))() + }); + return c.json( + { + identity: toIdentityResponse(user, false), + device: toClientDeviceResponse(device), + client_token: token + }, + 201 + ); } const payload = desktopBootstrapRequestSchema.parse(await readJsonObject(c)); const nickname = validateNickname(payload.nickname); diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index 5cef27bd..8851d8f1 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -13,13 +13,28 @@ import { ProjectServiceError, type ProjectService } from "../services/projects.js"; +import { + getDefaultProjectOpsService, + type ProjectOpsService +} from "../services/project-ops.js"; import { readJsonObject } from "./json-body.js"; +import { isUuidParam } from "./uuid-param.js"; export type ProjectRoutesDependencies = { auth?: AuthDependencySource; projects?: ProjectService; + projectOps?: ProjectOpsService; }; +// 路由 uuid 形参先校验:非 uuid 串原本直达服务层的 uuid 列 → PG 22P02 → 误报 500; +// 非法即抛与「合法但不存在」同样的 404(ProjectServiceError,经 app.onError 收口)。 +function requireProjectId(value: string): string { + if (!isUuidParam(value)) { + throw new ProjectServiceError(404, "project_not_found", "没有找到这个项目。"); + } + return value; +} + function handleProjectError(error: unknown): never { if (error instanceof ProjectServiceError) { throw error; @@ -31,6 +46,7 @@ export function createProjectRoutes(deps: ProjectRoutesDependencies = {}) { const routes = new Hono(); const authSource = deps.auth ?? getDefaultAuthDependencies; const projects = deps.projects ?? getDefaultProjectService(); + const projectOps = deps.projectOps ?? getDefaultProjectOpsService(); routes.get("/", createCurrentUserMiddleware(authSource), async (c) => { try { @@ -51,5 +67,27 @@ export function createProjectRoutes(deps: ProjectRoutesDependencies = {}) { } }); + // R20 P2A(R19-19):归档——软置 archived=true,从团队项目列表隐去。管理员/项目所有者门控。 + routes.post("/:id/archive", createCurrentUserMiddleware(authSource), async (c) => { + const projectId = requireProjectId(c.req.param("id")); + try { + const data = await projectOps.archiveProject({ projectId, actor: c.var.actor }); + return c.json({ ok: true, data }); + } catch (error) { + handleProjectError(error); + } + }); + + // R20 P2A(R19-19):软删——软置 deletedAt(墓碑)。管理员/项目所有者门控。 + routes.post("/:id/delete", createCurrentUserMiddleware(authSource), async (c) => { + const projectId = requireProjectId(c.req.param("id")); + try { + const data = await projectOps.deleteProject({ projectId, actor: c.var.actor }); + return c.json({ ok: true, data }); + } catch (error) { + handleProjectError(error); + } + }); + return routes; } diff --git a/apps/api/src/routes/workitems.ts b/apps/api/src/routes/workitems.ts index 6d0e200b..35030b1c 100644 --- a/apps/api/src/routes/workitems.ts +++ b/apps/api/src/routes/workitems.ts @@ -4,6 +4,8 @@ import { readFile } from "node:fs/promises"; import { Hono } from "hono"; import { + assignWorkItemRequestSchema, + createWorkItemCommentRequestSchema, createWorkItemRequestSchema, normalizeWorkHubLocale, type WorkHubLocale, @@ -21,12 +23,22 @@ import { WorkItemServiceError, type WorkItemService } from "../services/work-items.js"; +import { + getDefaultWorkItemAssignmentService, + type WorkItemAssignmentService +} from "../services/work-item-assignment.js"; +import { + getDefaultWorkItemCommentService, + type WorkItemCommentService +} from "../services/work-item-comments.js"; import { readJsonObject } from "./json-body.js"; import { isUuidParam } from "./uuid-param.js"; export type WorkItemRoutesDependencies = { auth?: AuthDependencySource; workItems?: WorkItemService; + assignments?: WorkItemAssignmentService; + comments?: WorkItemCommentService; }; function handleWorkItemError(error: unknown): never { @@ -163,6 +175,8 @@ export function createWorkItemRoutes(deps: WorkItemRoutesDependencies = {}) { const routes = new Hono(); const authSource = deps.auth ?? getDefaultAuthDependencies; const workItems = deps.workItems ?? getDefaultWorkItemService(); + const assignments = deps.assignments ?? getDefaultWorkItemAssignmentService(); + const comments = deps.comments ?? getDefaultWorkItemCommentService(); routes.post("/workitems", createCurrentUserMiddleware(authSource), async (c) => { const locale = requestLocale(c); @@ -264,5 +278,56 @@ export function createWorkItemRoutes(deps: WorkItemRoutesDependencies = {}) { } }); + // R20 P2A(R19-18):指派——把工作项显式指派给某工作区成员(写 work_item_assignments)。 + routes.post("/workitems/:id/assign", createCurrentUserMiddleware(authSource), async (c) => { + try { + const workItemId = requireWorkItemId(c.req.param("id")); + const payload = assignWorkItemRequestSchema.parse(await readJsonObject(c)); + const data = await assignments.assign({ + workItemId, + assigneeUserId: payload.assignee_user_id, + ...(payload.role ? { role: payload.role } : {}), + actor: c.var.actor + }); + return c.json({ ok: true, data }, 201); + } catch (error) { + handleWorkItemError(error); + } + }); + + // R20 P2A(R19-18):认领——当前登录用户认领一个无主工作项(复用 claimOwnerlessWorkItem 的 CAS)。 + routes.post("/workitems/:id/claim", createCurrentUserMiddleware(authSource), async (c) => { + try { + const workItemId = requireWorkItemId(c.req.param("id")); + const data = await assignments.claim({ workItemId, actor: c.var.actor }); + return c.json({ ok: true, data }); + } catch (error) { + handleWorkItemError(error); + } + }); + + // R20 P2A(R19-22):读工作项评论流(能看见这个工作项的工作区成员皆可)。 + routes.get("/workitems/:id/comments", createCurrentUserMiddleware(authSource), async (c) => { + try { + const workItemId = requireWorkItemId(c.req.param("id")); + const data = await comments.list({ workItemId, actor: c.var.actor }); + return c.json({ ok: true, data }); + } catch (error) { + handleWorkItemError(error); + } + }); + + // R20 P2A(R19-22):发一条工作项评论(author_nickname 取当前 actor,不接受伪造)。 + routes.post("/workitems/:id/comments", createCurrentUserMiddleware(authSource), async (c) => { + try { + const workItemId = requireWorkItemId(c.req.param("id")); + const payload = createWorkItemCommentRequestSchema.parse(await readJsonObject(c)); + const data = await comments.create({ workItemId, payload, actor: c.var.actor }); + return c.json({ ok: true, data }, 201); + } catch (error) { + handleWorkItemError(error); + } + }); + return routes; } diff --git a/apps/api/src/routes/workspace-audit.ts b/apps/api/src/routes/workspace-audit.ts new file mode 100644 index 00000000..11b8057a --- /dev/null +++ b/apps/api/src/routes/workspace-audit.ts @@ -0,0 +1,43 @@ +// R20 P2A(R19-21 工作区审计列表 · 仅管理员):GET /api/workspace/audit 的 HTTP 出口。 +// 查询串(actor_user_id / action / from / to / limit / offset)经 zod 收口;workspace 恒取自认证身份, +// 不从查询串读(服务层硬隔离)。非管理员由服务层抛 HTTPException 403,经 app.onError 统一映射。 +import { Hono } from "hono"; + +import { workspaceAuditQuerySchema } from "@workhub/contracts"; + +import { + createCurrentUserMiddleware, + getDefaultAuthDependencies, + type AuthDependencySource, + type AuthEnv +} from "../middleware/auth.js"; +import { + getDefaultWorkspaceAuditService, + type WorkspaceAuditService +} from "../services/workspace-audit.js"; + +export type WorkspaceAuditRoutesDependencies = { + auth?: AuthDependencySource; + workspaceAudit?: WorkspaceAuditService; +}; + +export function createWorkspaceAuditRoutes(deps: WorkspaceAuditRoutesDependencies = {}) { + const routes = new Hono(); + const authSource = deps.auth ?? getDefaultAuthDependencies; + const workspaceAudit = deps.workspaceAudit ?? getDefaultWorkspaceAuditService(); + + routes.get("/workspace/audit", createCurrentUserMiddleware(authSource), async (c) => { + const query = workspaceAuditQuerySchema.parse({ + ...(c.req.query("actor_user_id") ? { actor_user_id: c.req.query("actor_user_id") } : {}), + ...(c.req.query("action") ? { action: c.req.query("action") } : {}), + ...(c.req.query("from") ? { from: c.req.query("from") } : {}), + ...(c.req.query("to") ? { to: c.req.query("to") } : {}), + ...(c.req.query("limit") !== undefined ? { limit: c.req.query("limit") } : {}), + ...(c.req.query("offset") !== undefined ? { offset: c.req.query("offset") } : {}) + }); + const data = await workspaceAudit.list({ actor: c.var.actor, query }); + return c.json({ ok: true, data }); + }); + + return routes; +} diff --git a/apps/api/src/routes/workspace-members.test.ts b/apps/api/src/routes/workspace-members.test.ts index f04a385d..04a22a71 100644 --- a/apps/api/src/routes/workspace-members.test.ts +++ b/apps/api/src/routes/workspace-members.test.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import assert from "node:assert/strict"; import test from "node:test"; @@ -7,11 +8,13 @@ import { HTTPException } from "hono/http-exception"; import { ZodError } from "zod"; import { loadSettings, type Settings } from "@workhub/config"; +import type { WorkspaceRosterResultVM } from "@workhub/contracts"; import type { ClientDeviceAuthRow, ClientDeviceRepository, UserAuthRow, - UserRepository + UserRepository, + WorkspaceMembershipRow } from "@workhub/db"; import { COOKIE_NAME, type AuthDependencies, type AuthEnv } from "../middleware/auth.js"; @@ -19,7 +22,7 @@ import { WorkspaceMemberServiceError, type WorkspaceMemberService } from "../services/workspace-members.js"; -import { createWorkspaceMemberRoutes } from "./workspace-members.js"; +import { createWorkspaceMemberRoutes, type WorkspaceRosterReader } from "./workspace-members.js"; const now = new Date("2026-07-16T09:00:00.000Z"); const adminUserId = "70000000-0000-4000-8000-0000000000a1"; @@ -323,3 +326,175 @@ test("PATCH member role rejects an unknown role with 422 before the service", as assert.equal(response.status, 422); assert.equal(calls, 0); }); + +// ── R20 P2A(P1-08 修复 · GET /api/workspace/roster) ────────────────────────────────────────── +// 内存假 roster 读者:以「带 workspaceId 的成员种子」为真源,findActiveForUserWorkspace 判归属、 +// listActiveRosterPageByWorkspace 按工作区过滤 + 昵称排序 + limit/offset 切片——足以在路由边界断言 +// 工作区隔离、分页、无 200 截断、非成员 403,无需真 DB。 + +type RosterSeed = { + workspaceId: string; + userId: string; + nickname: string; + role: "member" | "admin" | "owner"; + joinedAt: Date; + avatarUpdatedAt: Date | null; +}; + +function rosterReader(seed: RosterSeed[]): { + reader: WorkspaceRosterReader; + calls: Array<{ workspaceId: string; limit: number; offset: number }>; +} { + const calls: Array<{ workspaceId: string; limit: number; offset: number }> = []; + const reader: WorkspaceRosterReader = { + async findActiveForUserWorkspace(userId, workspaceId) { + const member = seed.find((row) => row.userId === userId && row.workspaceId === workspaceId); + return member ? ({ id: "membership", userId, workspaceId } as unknown as WorkspaceMembershipRow) : null; + }, + async listActiveRosterPageByWorkspace(workspaceId, page) { + calls.push({ workspaceId, limit: page.limit, offset: page.offset }); + const all = seed + .filter((row) => row.workspaceId === workspaceId) + .sort((a, b) => a.nickname.localeCompare(b.nickname)); + const slice = all.slice(page.offset, page.offset + page.limit); + return { + total: all.length, + members: slice.map((row) => ({ + userId: row.userId, + nickname: row.nickname, + role: row.role, + joinedAt: row.joinedAt, + avatarUpdatedAt: row.avatarUpdatedAt + })) + }; + } + }; + return { reader, calls }; +} + +function rosterApp(runtimeSettings: Settings, reader: WorkspaceRosterReader) { + const app = withErrors(new Hono()); + app.route( + "/api", + createWorkspaceMemberRoutes({ auth: authDeps(runtimeSettings), members: memberService(), roster: reader }) + ); + return app; +} + +const otherWorkspaceId = "00000000-0000-4000-8000-0000000000ff"; + +test("GET roster requires authentication before reaching the reader", async () => { + const runtimeSettings = settings(); + const { reader, calls } = rosterReader([]); + const app = rosterApp(runtimeSettings, reader); + + const response = await app.request("/api/workspace/roster"); + + assert.equal(response.status, 401); + assert.equal(calls.length, 0); +}); + +test("GET roster 403s a caller who is not an active member of the workspace", async () => { + const runtimeSettings = settings(); + const { reader, calls } = rosterReader([ + { workspaceId: otherWorkspaceId, userId: randomUUID(), nickname: "外人", role: "member", joinedAt: now, avatarUpdatedAt: null } + ]); + const app = rosterApp(runtimeSettings, reader); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request("/api/workspace/roster", { headers }); + + assert.equal(response.status, 403); + assert.equal(((await response.json()) as { error: { code: string } }).error.code, "roster_forbidden"); + assert.equal(calls.length, 0, "a non-member is rejected before the roster query runs"); +}); + +test("GET roster returns only the caller's workspace members with avatar/online placeholders", async () => { + const runtimeSettings = settings(); + const wsA = runtimeSettings.auth.defaultWorkspaceId; + const peerId = randomUUID(); + const avatarAt = new Date("2026-07-14T00:00:00.000Z"); + const { reader, calls } = rosterReader([ + { workspaceId: wsA, userId: adminUserId, nickname: "r17-admin", role: "owner", joinedAt: now, avatarUpdatedAt: avatarAt }, + { workspaceId: wsA, userId: peerId, nickname: "小赵", role: "member", joinedAt: now, avatarUpdatedAt: null }, + { workspaceId: otherWorkspaceId, userId: randomUUID(), nickname: "他区成员", role: "member", joinedAt: now, avatarUpdatedAt: null } + ]); + const app = rosterApp(runtimeSettings, reader); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request("/api/workspace/roster", { headers }); + + assert.equal(response.status, 200); + const body = (await response.json()) as { ok: true; data: WorkspaceRosterResultVM }; + // 隔离:只用 actor 的工作区查、只回该工作区成员——他区成员绝不泄露。 + assert.equal(calls.length, 1); + assert.equal(calls[0]?.workspaceId, wsA); + assert.equal(body.data.total, 2); + assert.deepEqual( + body.data.members.map((member) => member.user_id).sort(), + [adminUserId, peerId].sort() + ); + // 占位字段:online 恒 null;avatar_updated_at 有头像回 ISO、无头像回 null;is_self 正确标本人。 + const self = body.data.members.find((member) => member.user_id === adminUserId); + assert.ok(self); + assert.equal(self.is_self, true); + assert.equal(self.online, null); + assert.equal(self.avatar_updated_at, avatarAt.toISOString()); + const peer = body.data.members.find((member) => member.user_id === peerId); + assert.ok(peer); + assert.equal(peer.is_self, false); + assert.equal(peer.avatar_updated_at, null); +}); + +test("GET roster paginates via limit/offset", async () => { + const runtimeSettings = settings(); + const wsA = runtimeSettings.auth.defaultWorkspaceId; + const { reader, calls } = rosterReader([ + { workspaceId: wsA, userId: adminUserId, nickname: "a-self", role: "owner", joinedAt: now, avatarUpdatedAt: null }, + { workspaceId: wsA, userId: randomUUID(), nickname: "b-two", role: "member", joinedAt: now, avatarUpdatedAt: null }, + { workspaceId: wsA, userId: randomUUID(), nickname: "c-three", role: "member", joinedAt: now, avatarUpdatedAt: null } + ]); + const app = rosterApp(runtimeSettings, reader); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request("/api/workspace/roster?limit=1&offset=1", { headers }); + + assert.equal(response.status, 200); + const body = (await response.json()) as { data: WorkspaceRosterResultVM }; + assert.deepEqual(calls[0], { workspaceId: wsA, limit: 1, offset: 1 }); + assert.equal(body.data.total, 3); + assert.equal(body.data.limit, 1); + assert.equal(body.data.offset, 1); + assert.equal(body.data.members.length, 1); + assert.equal(body.data.members[0]?.nickname, "b-two"); +}); + +test("GET roster surfaces members beyond the old 200 cap via offset", async () => { + const runtimeSettings = settings(); + const wsA = runtimeSettings.auth.defaultWorkspaceId; + const seed: RosterSeed[] = [ + { workspaceId: wsA, userId: adminUserId, nickname: "zz-admin", role: "owner", joinedAt: now, avatarUpdatedAt: null } + ]; + for (let index = 0; index < 250; index += 1) { + seed.push({ + workspaceId: wsA, + userId: randomUUID(), + nickname: `member-${String(index).padStart(3, "0")}`, + role: "member", + joinedAt: now, + avatarUpdatedAt: null + }); + } + const { reader } = rosterReader(seed); + const app = rosterApp(runtimeSettings, reader); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request("/api/workspace/roster?limit=100&offset=200", { headers }); + + assert.equal(response.status, 200); + const body = (await response.json()) as { data: WorkspaceRosterResultVM }; + assert.equal(body.data.total, 251, "total counts every active member, not a 200 cap"); + // 第 201 个及以后的成员现在可经 offset 翻到(/api/users 硬 .limit(200) 时代它们对消费端永不可见)。 + const nicknames = body.data.members.map((member) => member.nickname); + assert.ok(nicknames.includes("member-249"), "the 250th member is reachable past offset 200"); +}); diff --git a/apps/api/src/routes/workspace-members.ts b/apps/api/src/routes/workspace-members.ts index 82ca15d3..0fcc6dba 100644 --- a/apps/api/src/routes/workspace-members.ts +++ b/apps/api/src/routes/workspace-members.ts @@ -1,6 +1,16 @@ import { Hono } from "hono"; -import { updateWorkspaceMemberRoleRequestSchema } from "@workhub/contracts"; +import { + updateWorkspaceMemberRoleRequestSchema, + workspaceRosterQuerySchema, + type WorkspaceRosterMemberVM, + type WorkspaceRosterResultVM +} from "@workhub/contracts"; +import { + createWorkspaceMembershipRepository, + getSharedDatabaseClient, + type WorkspaceMembershipRepository +} from "@workhub/db"; import { createCurrentUserMiddleware, @@ -21,11 +31,40 @@ import { isUuidParam } from "./uuid-param.js"; // 最后一名特权成员)全在 services/workspace-members.ts 里做,路由层只做 param/body 校验与成形响应。 // 错误经 app.onError 的 WorkspaceMemberServiceError 分支映射。 +// R20 P2A(P1-08 修复 · workspace-scoped roster):读花名册所需的成员仓库子集——门控用 +// findActiveForUserWorkspace(确认发起人确属本工作区),取数用 listActiveRosterPageByWorkspace(分页 join)。 +// 仓库接口把 listActiveRosterPageByWorkspace 标为 OPTIONAL(不逼各处假仓库实现),但 roster 端点必须拿到它, +// 故这里用 NonNullable 把它收窄为必备——默认读者在解析时做存在性兜底,单测注入的假读者也恒实现。 +export type WorkspaceRosterReader = { + findActiveForUserWorkspace: WorkspaceMembershipRepository["findActiveForUserWorkspace"]; + listActiveRosterPageByWorkspace: NonNullable; +}; + export type WorkspaceMemberRoutesDependencies = { auth?: AuthDependencySource; members?: WorkspaceMemberService; + // OPTIONAL——未注入则请求命中 roster 端点时懒解析共享 DB 仓库;单测注入内存假读者。 + roster?: WorkspaceRosterReader; }; +// 懒解析默认 roster 读者:只在真正命中 roster 端点且未注入读者时才触库,避免 createWorkspaceMemberRoutes +// 构造期就连 DB(否则不注入 roster 的既有单测会误连库)。 +let defaultRosterReader: WorkspaceRosterReader | undefined; +function getDefaultWorkspaceRosterReader(): WorkspaceRosterReader { + if (!defaultRosterReader) { + const repo = createWorkspaceMembershipRepository(getSharedDatabaseClient().db); + const listActiveRosterPageByWorkspace = repo.listActiveRosterPageByWorkspace; + if (!listActiveRosterPageByWorkspace) { + throw new Error("workspace membership repository does not support roster paging"); + } + defaultRosterReader = { + findActiveForUserWorkspace: repo.findActiveForUserWorkspace, + listActiveRosterPageByWorkspace + }; + } + return defaultRosterReader; +} + function requireTargetUserId(value: string) { if (!isUuidParam(value)) { throw new WorkspaceMemberServiceError(404, "member_not_found", "没有找到这个工作区里的这个成员。"); @@ -46,6 +85,54 @@ export function createWorkspaceMemberRoutes(deps: WorkspaceMemberRoutesDependenc return c.json({ ok: true, data }); }); + // R20 P2A(P1-08 修复 · workspace-scoped roster):任意工作区成员读本工作区花名册,分页返回。 + // 取代消费端误用的全局 /api/users(跨租户泄露 + 全局排序 + 硬 200 截断)。门控=调用者须为本工作区 + // active 真人成员(capability:工作区成员可读,比 /workspace/members 的管理员门更宽);数据经 membership + // join 严格按 actor.workspaceId 隔离。roster 读者懒解析,避免不注入时构造期触库。 + routes.get("/workspace/roster", requireCurrentUser, async (c) => { + const roster = deps.roster ?? getDefaultWorkspaceRosterReader(); + const actor = c.var.actor; + const workspaceId = actor.workspaceId?.trim(); + const actorUserId = actor.userId?.trim(); + // 非真人 / 无 userId / 无工作区 → 无从判定归属,直接拒。 + if (actor.kind !== "human" || !actorUserId || !workspaceId) { + throw new WorkspaceMemberServiceError(403, "roster_forbidden", "需要已加入工作区的真人用户才能查看成员名册。"); + } + // capability 门控:确认发起人确有本工作区 active 成员行——非成员(含被移出的墓碑)一律 403, + // 绝不落到取数(fail-closed,与 SEC-1 resolveHumanActor 同philosophy,且不依赖中间件已做过同款校验)。 + const membership = await roster.findActiveForUserWorkspace(actorUserId, workspaceId); + if (!membership) { + throw new WorkspaceMemberServiceError(403, "roster_forbidden", "只有本工作区成员可以查看成员名册。"); + } + const query = workspaceRosterQuerySchema.parse({ + limit: c.req.query("limit"), + offset: c.req.query("offset") + }); + const pageResult = await roster.listActiveRosterPageByWorkspace(workspaceId, { + limit: query.limit, + offset: query.offset + }); + const selfId = actorUserId.toLowerCase(); + const rosterMembers: WorkspaceRosterMemberVM[] = pageResult.members.map((row) => ({ + user_id: row.userId, + nickname: row.nickname, + role: row.role, + joined_at: row.joinedAt.toISOString(), + is_self: row.userId.toLowerCase() === selfId, + // 头像占位:非空=有头像(客户端据此取 /api/users/:id/avatar 并用作缓存键);此处不带二进制。 + avatar_updated_at: row.avatarUpdatedAt ? row.avatarUpdatedAt.toISOString() : null, + // 在线态占位:presence 接线在后续批次,字段先就位、恒 null。 + online: null + })); + const data: WorkspaceRosterResultVM = { + members: rosterMembers, + total: pageResult.total, + limit: query.limit, + offset: query.offset + }; + return c.json({ ok: true, data }); + }); + routes.delete("/workspace/members/:userId", requireCurrentUser, async (c) => { const targetUserId = requireTargetUserId(c.req.param("userId")); const data = await members.removeMember({ actor: c.var.actor, targetUserId }); diff --git a/apps/api/src/services/project-ops.ts b/apps/api/src/services/project-ops.ts new file mode 100644 index 00000000..30b54761 --- /dev/null +++ b/apps/api/src/services/project-ops.ts @@ -0,0 +1,115 @@ +// R20 P2A(R19-19 项目归档/删除 · 纯后端):POST /api/projects/:id/archive 与 /delete 的服务层。 +// 归档/删除是破坏性的项目生命周期操作——门比「管项目内容」(canManageProjectDrive 放行任意同工作区成员) +// 更严:仅**管理员或项目所有者**(且同租户作用域内)。项目不存在/已归档/已软删(findProjectById 只回活跃项目) +// → 404;有身份但非管理员且非所有者 → 403。落地写走 ProjectRepository 的 archiveProject/softDeleteProject +// (CAS 只命中活跃/未删行)。复用 ProjectServiceError(app.onError 已映射其 status/code),不新起错误类型、 +// 不碰 app.ts 的错误处理表。 +import type { ArchiveProjectResult, DeleteProjectResult, ProjectVM } from "@workhub/contracts"; +import { + createProjectRepository, + createWorkItemRepository, + getSharedDatabaseClient, + type ProjectRepository, + type ProjectRow, + type WorkItemDataRepository, + type WorkItemProjectRow, + type WorkHubDatabaseClient +} from "@workhub/db"; + +import type { AuthActor } from "../middleware/auth.js"; +import { ProjectServiceError } from "./projects.js"; + +export type ProjectOpsService = { + archiveProject: (input: { projectId: string; actor: AuthActor }) => Promise; + deleteProject: (input: { projectId: string; actor: AuthActor }) => Promise; +}; + +export type ProjectOpsServiceDependencies = { + // findProjectById 只回「活跃」项目(archived=false 且未软删)——正是归档/删除前的可操作目标。 + projectLookup: Pick; + projects: Pick; + now?: () => Date; +}; + +function toProjectVm(project: Pick): ProjectVM { + return { + id: project.id, + workspace_id: project.workspaceId ?? null, + name: project.name, + slug: project.slug, + ...(project.description ? { description: project.description } : {}), + owner_nickname: project.ownerNickname, + owner_user_id: project.ownerUserId ?? null, + is_personal: project.isPersonal + }; +} + +export function createProjectOpsService(deps: ProjectOpsServiceDependencies): ProjectOpsService { + const now = deps.now ?? (() => new Date()); + + // 仅管理员或项目所有者,且同租户作用域内(workspace/org 不跨界)。 + function canManageLifecycle(project: WorkItemProjectRow, actor: AuthActor): boolean { + // 租户作用域:项目与 actor 的 workspace/org 都不得跨界(任一侧为空则不设限,兼容历史 NULL 列)。 + if (project.workspaceId && actor.workspaceId && project.workspaceId !== actor.workspaceId) { + return false; + } + if (project.orgId && actor.orgId && project.orgId !== actor.orgId) { + return false; + } + if (actor.isAdmin) { + return true; + } + const actorUserId = actor.userId ?? actor.id; + return project.ownerUserId != null && project.ownerUserId === actorUserId; + } + + async function requireManageableProject(projectId: string, actor: AuthActor): Promise { + const project = await deps.projectLookup.findProjectById(projectId); + if (!project) { + throw new ProjectServiceError(404, "project_not_found", "没有找到这个项目。"); + } + if (!canManageLifecycle(project, actor)) { + throw new ProjectServiceError(403, "project_forbidden", "你没有权限管理这个项目。"); + } + return project; + } + + return { + async archiveProject({ projectId, actor }) { + await requireManageableProject(projectId, actor); + const row = await deps.projects.archiveProject({ projectId, now: now() }); + if (!row) { + // 权限门刚过、CAS 又落空——竞态窗口内被别的请求先归档/软删了,按 404 收口。 + throw new ProjectServiceError(404, "project_not_found", "没有找到这个项目。"); + } + return { project: toProjectVm(row), archived: true }; + }, + + async deleteProject({ projectId, actor }) { + await requireManageableProject(projectId, actor); + const row = await deps.projects.softDeleteProject({ + projectId, + deletedByNickname: actor.label, + now: now() + }); + if (!row) { + throw new ProjectServiceError(404, "project_not_found", "没有找到这个项目。"); + } + return { project: toProjectVm(row), deleted: true }; + } + }; +} + +let defaultDbClient: WorkHubDatabaseClient | undefined; +let defaultService: ProjectOpsService | undefined; + +export function getDefaultProjectOpsService(): ProjectOpsService { + if (!defaultService) { + defaultDbClient = getSharedDatabaseClient(); + defaultService = createProjectOpsService({ + projectLookup: createWorkItemRepository(defaultDbClient.db), + projects: createProjectRepository(defaultDbClient.db) + }); + } + return defaultService; +} diff --git a/apps/api/src/services/work-item-assignment.ts b/apps/api/src/services/work-item-assignment.ts new file mode 100644 index 00000000..90e1275c --- /dev/null +++ b/apps/api/src/services/work-item-assignment.ts @@ -0,0 +1,154 @@ +// R20 P2A(R19-18 指派/认领 · 纯后端):POST /api/workitems/:id/assign 与 /claim 的服务层。 +// 生产此前 work_item_assignments 零 INSERT、claimedByUserId 唯一写口只在找人交互卡里——这里把两件事 +// 开放成人可发起的端点: +// * assign:把工作项显式指派给某成员(写 work_item_assignments)。门=canManageWorkItemAssignees +// (管理员 / 提交人 / 现任 lead),且被指派人必须是本工作区 active 成员(防越租户指派)。 +// * claim:当前登录用户认领一个无主工作项(复用既有 claimOwnerlessWorkItem 的 CAS 语义)。门= +// canClaimWorkItem(spec_ready、未被他人独占指派 / 管理员)。 +// 复用 WorkItemServiceError(app.onError 已映射),不新起错误类型。 +import { + ASSIGNMENT_ROLES, + canClaimWorkItem, + canManageWorkItemAssignees +} from "@workhub/permissions"; +import type { Assignment, AssignWorkItemResult, ClaimWorkItemResult } from "@workhub/contracts"; +import { + createWorkItemAssignmentRepository, + createWorkItemRepository, + createWorkspaceMembershipRepository, + getSharedDatabaseClient, + type WorkItemAccessRow, + type WorkItemAssignmentRepository, + type WorkItemAssignmentRow, + type WorkItemDataRepository, + type WorkspaceMembershipRepository, + type WorkHubDatabaseClient +} from "@workhub/db"; + +import type { AuthActor } from "../middleware/auth.js"; +import { WorkItemServiceError } from "./work-items.js"; + +export type AssignmentRole = (typeof ASSIGNMENT_ROLES)[number]; + +export type WorkItemAssignmentService = { + assign: (input: { + workItemId: string; + assigneeUserId: string; + role?: AssignmentRole; + actor: AuthActor; + }) => Promise; + claim: (input: { workItemId: string; actor: AuthActor }) => Promise; +}; + +export type WorkItemAssignmentServiceDependencies = { + workItems: Pick; + assignments: WorkItemAssignmentRepository; + memberships: Pick; + now?: () => Date; +}; + +function actorUserId(actor: AuthActor): string { + return actor.userId ?? actor.id; +} + +// WorkItemAccessRow 与 @workhub/permissions 的 WorkItemAccessRecord 结构同源,直接喂进权限判定。 +function permissionUser(actor: AuthActor) { + return { id: actorUserId(actor), isAdmin: actor.isAdmin }; +} + +function permissionScope(actor: AuthActor) { + // 仅按 workspace 作用域(硬租户边界);不传 orgId——work_items/projects 无 orgId 列, + // 传了会把合法读误判成越界(与 work-items.ts canReadWorkItemAccessRow 的注释同因)。 + return actor.workspaceId ? { workspaceId: actor.workspaceId } : undefined; +} + +function assignmentVm(row: WorkItemAssignmentRow): Assignment { + return { + id: row.id, + work_item_id: row.workItemId, + user_id: row.userId, + role: row.role as AssignmentRole, + assigned_by_user_id: row.assignedByUserId, + created_at: row.createdAt.toISOString(), + updated_at: row.updatedAt.toISOString() + }; +} + +export function createWorkItemAssignmentService( + deps: WorkItemAssignmentServiceDependencies +): WorkItemAssignmentService { + const now = deps.now ?? (() => new Date()); + + async function requireAccessRow(workItemId: string): Promise { + const row = await deps.workItems.findWorkItemAccessRecord(workItemId); + if (!row) { + throw new WorkItemServiceError(404, "not_found", "没有找到这个事项。(Work item not found.)"); + } + return row; + } + + return { + async assign({ workItemId, assigneeUserId, role, actor }) { + const accessRow = await requireAccessRow(workItemId); + const allowed = canManageWorkItemAssignees(accessRow, permissionUser(actor), permissionScope(actor)); + if (!allowed) { + throw new WorkItemServiceError(403, "forbidden", "你没有权限指派这个事项。"); + } + // 被指派人必须是这个工作项所属工作区的 active 成员——否则会把工作指派给不在本租户的人。 + const workspaceId = accessRow.workspaceId ?? accessRow.project?.workspaceId ?? null; + if (!workspaceId) { + throw new WorkItemServiceError(409, "work_item_workspace_missing", "这个事项还没有归属工作区,暂时无法指派。"); + } + const membership = await deps.memberships.findActiveForUserWorkspace(assigneeUserId, workspaceId); + if (!membership) { + throw new WorkItemServiceError(422, "assignee_not_member", "被指派人不是这个工作区的成员。"); + } + const row = await deps.assignments.assignWorkItem({ + workItemId, + userId: assigneeUserId, + role: role ?? "collaborator", + assignedByUserId: actorUserId(actor), + at: now() + }); + return { assignment: assignmentVm(row) }; + }, + + async claim({ workItemId, actor }) { + const accessRow = await requireAccessRow(workItemId); + const allowed = canClaimWorkItem(accessRow, permissionUser(actor), permissionScope(actor)); + if (!allowed) { + throw new WorkItemServiceError(403, "forbidden", "你现在还不能认领这个事项。"); + } + const workspaceId = accessRow.workspaceId ?? actor.workspaceId; + const claimed = await deps.workItems.claimOwnerlessWorkItem({ + workItemId, + workspaceId, + userId: actorUserId(actor), + at: now() + }); + if (!claimed) { + // CAS 落空:已被别人认领 / 已离开可认领状态(并发或过期点击)——不覆盖既有认领人。 + throw new WorkItemServiceError(409, "work_item_not_claimable", "这个事项已被认领或已不在可认领状态。"); + } + return { + work_item_id: claimed.id, + claimed_by_user_id: claimed.claimedByUserId ?? actorUserId(actor) + }; + } + }; +} + +let defaultDbClient: WorkHubDatabaseClient | undefined; +let defaultService: WorkItemAssignmentService | undefined; + +export function getDefaultWorkItemAssignmentService(): WorkItemAssignmentService { + if (!defaultService) { + defaultDbClient = getSharedDatabaseClient(); + defaultService = createWorkItemAssignmentService({ + workItems: createWorkItemRepository(defaultDbClient.db), + assignments: createWorkItemAssignmentRepository(defaultDbClient.db), + memberships: createWorkspaceMembershipRepository(defaultDbClient.db) + }); + } + return defaultService; +} diff --git a/apps/api/src/services/work-item-comments.ts b/apps/api/src/services/work-item-comments.ts new file mode 100644 index 00000000..7b0789fc --- /dev/null +++ b/apps/api/src/services/work-item-comments.ts @@ -0,0 +1,101 @@ +// R20 P2A(R19-22 工作项评论 · 纯后端):GET/POST /api/workitems/:id/comments 的服务层。 +// 通用 comments 表(work_item_id + author_nickname + body)全库此前零读写,这里补上读/写两口。 +// 门=canViewWorkItemRecord(能看见这个工作项的工作区成员就能读/评)——私有态(intake/ai_clarifying/ +// spec_ready)仅提交人/指派人/管理员可见可评,非私有态同工作区成员皆可。author_nickname 取当前 actor +// 的展示名,不接受客户端伪造。复用 WorkItemServiceError(app.onError 已映射)。 +import { canViewWorkItemRecord } from "@workhub/permissions"; +import type { CreateWorkItemCommentRequest, WorkItemComment, WorkItemCommentsResult } from "@workhub/contracts"; +import { + createCommentRepository, + createWorkItemRepository, + getSharedDatabaseClient, + type CommentRepository, + type CommentRow, + type WorkItemAccessRow, + type WorkItemDataRepository, + type WorkHubDatabaseClient +} from "@workhub/db"; + +import type { AuthActor } from "../middleware/auth.js"; +import { WorkItemServiceError } from "./work-items.js"; + +export type WorkItemCommentService = { + list: (input: { workItemId: string; actor: AuthActor }) => Promise; + create: (input: { + workItemId: string; + payload: CreateWorkItemCommentRequest; + actor: AuthActor; + }) => Promise; +}; + +export type WorkItemCommentServiceDependencies = { + workItems: Pick; + comments: CommentRepository; + now?: () => Date; +}; + +function commentVm(row: CommentRow): WorkItemComment { + return { + id: row.id, + work_item_id: row.workItemId, + author_nickname: row.authorNickname, + body: row.body, + created_at: row.createdAt.toISOString(), + updated_at: row.updatedAt.toISOString() + }; +} + +export function createWorkItemCommentService( + deps: WorkItemCommentServiceDependencies +): WorkItemCommentService { + const now = deps.now ?? (() => new Date()); + + async function requireVisibleWorkItem(workItemId: string, actor: AuthActor): Promise { + const row = await deps.workItems.findWorkItemAccessRecord(workItemId); + if (!row) { + throw new WorkItemServiceError(404, "not_found", "没有找到这个事项。(Work item not found.)"); + } + const allowed = canViewWorkItemRecord( + row, + { id: actor.userId ?? actor.id, isAdmin: actor.isAdmin }, + actor.workspaceId ? { workspaceId: actor.workspaceId } : undefined + ); + if (!allowed) { + throw new WorkItemServiceError(403, "forbidden", "你没有权限查看这个事项的评论。"); + } + return row; + } + + return { + async list({ workItemId, actor }) { + await requireVisibleWorkItem(workItemId, actor); + const rows = await deps.comments.listCommentsForWorkItem(workItemId); + return { work_item_id: workItemId, comments: rows.map(commentVm) }; + }, + + async create({ workItemId, payload, actor }) { + await requireVisibleWorkItem(workItemId, actor); + const row = await deps.comments.insertComment({ + workItemId, + authorNickname: actor.label, + body: payload.body, + at: now() + }); + return commentVm(row); + } + }; +} + +let defaultDbClient: WorkHubDatabaseClient | undefined; +let defaultService: WorkItemCommentService | undefined; + +export function getDefaultWorkItemCommentService(): WorkItemCommentService { + if (!defaultService) { + defaultDbClient = getSharedDatabaseClient(); + defaultService = createWorkItemCommentService({ + workItems: createWorkItemRepository(defaultDbClient.db), + comments: createCommentRepository(defaultDbClient.db) + }); + } + return defaultService; +} diff --git a/apps/api/src/services/workspace-audit.ts b/apps/api/src/services/workspace-audit.ts new file mode 100644 index 00000000..d7808691 --- /dev/null +++ b/apps/api/src/services/workspace-audit.ts @@ -0,0 +1,75 @@ +// R20 P2A(R19-21 工作区审计列表 · 纯后端 · 仅管理员):GET /api/workspace/audit 的服务层。 +// 此前审计只有 forEntity/forWorkItem 两个读口 + 单工作项时间线端点,缺一个「工作区级」跨工作项审计流。 +// 这里补上:仅管理员可读;按 actor.workspaceId 硬隔离(客户端不能传 workspace,杜绝越租户读); +// 支持操作者 / 动作 / 时间范围过滤 + 分页;时间倒序。非管理员抛 HTTPException 403(app.onError 已映射 +// 成 forbidden),不新起错误类型。 +import { HTTPException } from "hono/http-exception"; + +import { + WORKSPACE_AUDIT_DEFAULT_LIMIT, + WORKSPACE_AUDIT_MAX_LIMIT, + type WorkspaceAuditListVM, + type WorkspaceAuditQuery +} from "@workhub/contracts"; +import { + createWorkspaceAuditLogRepository, + getSharedDatabaseClient, + type WorkspaceAuditLogRepository, + type WorkHubDatabaseClient +} from "@workhub/db"; + +import type { AuthActor } from "../middleware/auth.js"; +import { toAuditLogFact } from "../pages/replay.js"; + +export type WorkspaceAuditService = { + list: (input: { actor: AuthActor; query: WorkspaceAuditQuery }) => Promise; +}; + +export type WorkspaceAuditServiceDependencies = { + auditLogs: WorkspaceAuditLogRepository; + now?: () => Date; +}; + +export function createWorkspaceAuditService(deps: WorkspaceAuditServiceDependencies): WorkspaceAuditService { + const now = deps.now ?? (() => new Date()); + + return { + async list({ actor, query }) { + // 仅管理员——工作区级审计流是特权视图。非管理员一律拒(不因过滤条件放行)。 + if (!actor.isAdmin) { + throw new HTTPException(403, { message: "需要管理员权限查看工作区审计。" }); + } + const limit = Math.min(Math.max(query.limit ?? WORKSPACE_AUDIT_DEFAULT_LIMIT, 1), WORKSPACE_AUDIT_MAX_LIMIT); + const offset = Math.max(query.offset ?? 0, 0); + const rows = await deps.auditLogs.listAuditLogsForWorkspace({ + // 硬隔离:workspace 恒取自认证身份,忽略任何客户端输入。 + workspaceId: actor.workspaceId, + actorUserId: query.actor_user_id, + action: query.action, + from: query.from ? new Date(query.from) : undefined, + to: query.to ? new Date(query.to) : undefined, + limit, + offset + }); + return { + generated_at: now().toISOString(), + workspace_id: actor.workspaceId, + audit_logs: rows.map(toAuditLogFact), + page: { limit, offset, count: rows.length } + }; + } + }; +} + +let defaultDbClient: WorkHubDatabaseClient | undefined; +let defaultService: WorkspaceAuditService | undefined; + +export function getDefaultWorkspaceAuditService(): WorkspaceAuditService { + if (!defaultService) { + defaultDbClient = getSharedDatabaseClient(); + defaultService = createWorkspaceAuditService({ + auditLogs: createWorkspaceAuditLogRepository(defaultDbClient.db) + }); + } + return defaultService; +} diff --git a/apps/api/src/work-item-assignment.test.ts b/apps/api/src/work-item-assignment.test.ts new file mode 100644 index 00000000..643b46dd --- /dev/null +++ b/apps/api/src/work-item-assignment.test.ts @@ -0,0 +1,203 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { WorkItemAccessRow, WorkItemAssignmentRow } from "@workhub/db"; + +import type { AuthActor } from "./middleware/auth.js"; +import { WorkItemServiceError } from "./services/work-items.js"; +import { + createWorkItemAssignmentService, + type WorkItemAssignmentServiceDependencies +} from "./services/work-item-assignment.js"; + +const now = new Date("2026-07-15T00:00:00.000Z"); +const workspaceId = "00000000-0000-4000-8000-000000000002"; +const submitterId = "62000000-0000-4000-8000-000000000001"; +const assigneeId = "62000000-0000-4000-8000-000000000002"; +const workItemId = "62000000-0000-4000-8000-0000000000bb"; + +function actor(overrides: Partial = {}): AuthActor { + return { + kind: "human", + id: submitterId, + label: "submitter", + userId: submitterId, + isAdmin: false, + orgId: "00000000-0000-4000-8000-000000000001", + workspaceId, + ...overrides + }; +} + +function accessRow(overrides: Partial = {}): WorkItemAccessRow { + return { + id: workItemId, + code: "WI-1", + title: "任务", + status: "spec_ready", + submitterUserId: submitterId, + claimedByUserId: null, + workspaceId, + project: { + archived: false, + deletedAt: null, + ownerUserId: submitterId, + workspaceId, + orgId: null, + name: "项目" + }, + assignments: [], + ...overrides + }; +} + +function assignmentRow(overrides: Partial = {}): WorkItemAssignmentRow { + return { + id: "assign-1", + workItemId, + userId: assigneeId, + role: "collaborator", + assignedByUserId: submitterId, + createdAt: now, + updatedAt: now, + ...overrides + } as WorkItemAssignmentRow; +} + +type Recorder = { + assigned: Array<{ workItemId: string; userId: string; role: string; assignedByUserId: string }>; + claimed: Array<{ workItemId: string; workspaceId: string; userId: string }>; +}; + +function service(config: { + row?: WorkItemAccessRow | null; + member?: boolean; + claimReturns?: { id: string; claimedByUserId: string | null } | null; +} = {}): { svc: ReturnType; rec: Recorder } { + const rec: Recorder = { assigned: [], claimed: [] }; + const deps: WorkItemAssignmentServiceDependencies = { + workItems: { + async findWorkItemAccessRecord() { + return config.row === undefined ? accessRow() : config.row; + }, + async claimOwnerlessWorkItem(input) { + rec.claimed.push({ workItemId: input.workItemId, workspaceId: input.workspaceId, userId: input.userId }); + return config.claimReturns === undefined + ? { id: input.workItemId, claimedByUserId: input.userId } + : config.claimReturns; + } + }, + assignments: { + async assignWorkItem(input) { + rec.assigned.push({ + workItemId: input.workItemId, + userId: input.userId, + role: input.role, + assignedByUserId: input.assignedByUserId + }); + return assignmentRow({ userId: input.userId, role: input.role, assignedByUserId: input.assignedByUserId }); + }, + async listAssignmentsForWorkItem() { + return []; + } + }, + memberships: { + async findActiveForUserWorkspace() { + return (config.member ?? true) ? ({ id: "m-1" } as never) : null; + } + }, + now: () => now + }; + return { svc: createWorkItemAssignmentService(deps), rec }; +} + +// ---- assign ---- + +test("assign: submitter can assign a member; defaults to collaborator and lands the row", async () => { + const { svc, rec } = service(); + const result = await svc.assign({ workItemId, assigneeUserId: assigneeId, actor: actor() }); + assert.equal(result.assignment.user_id, assigneeId); + assert.equal(result.assignment.role, "collaborator"); + assert.deepEqual(rec.assigned, [ + { workItemId, userId: assigneeId, role: "collaborator", assignedByUserId: submitterId } + ]); +}); + +test("assign: explicit lead role is honored", async () => { + const { svc, rec } = service(); + const result = await svc.assign({ workItemId, assigneeUserId: assigneeId, role: "lead", actor: actor() }); + assert.equal(result.assignment.role, "lead"); + assert.equal(rec.assigned[0]?.role, "lead"); +}); + +test("assign: unrelated non-admin is forbidden and nothing is written", async () => { + const { svc, rec } = service(); + await assert.rejects( + () => svc.assign({ workItemId, assigneeUserId: assigneeId, actor: actor({ id: "outsider", userId: "outsider" }) }), + (error: unknown) => error instanceof WorkItemServiceError && error.status === 403 + ); + assert.deepEqual(rec.assigned, []); +}); + +test("assign: assignee who is not a workspace member is rejected 422", async () => { + const { svc, rec } = service({ member: false }); + await assert.rejects( + () => svc.assign({ workItemId, assigneeUserId: assigneeId, actor: actor() }), + (error: unknown) => error instanceof WorkItemServiceError && error.status === 422 && error.code === "assignee_not_member" + ); + assert.deepEqual(rec.assigned, []); +}); + +test("assign: missing work item maps to 404", async () => { + const { svc } = service({ row: null }); + await assert.rejects( + () => svc.assign({ workItemId, assigneeUserId: assigneeId, actor: actor() }), + (error: unknown) => error instanceof WorkItemServiceError && error.status === 404 + ); +}); + +test("assign: admin can assign even when not the submitter", async () => { + const { svc, rec } = service({ row: accessRow({ submitterUserId: "someone-else" }) }); + await svc.assign({ + workItemId, + assigneeUserId: assigneeId, + actor: actor({ id: "admin", userId: "admin", isAdmin: true }) + }); + assert.equal(rec.assigned.length, 1); +}); + +// ---- claim ---- + +test("claim: eligible user claims an ownerless spec_ready item; claimedByUserId lands", async () => { + const claimer = actor({ id: assigneeId, userId: assigneeId, label: "claimer" }); + const { svc, rec } = service({ row: accessRow({ submitterUserId: "someone-else" }) }); + const result = await svc.claim({ workItemId, actor: claimer }); + assert.equal(result.work_item_id, workItemId); + assert.equal(result.claimed_by_user_id, assigneeId); + assert.deepEqual(rec.claimed, [{ workItemId, workspaceId, userId: assigneeId }]); +}); + +test("claim: non-spec_ready item is not claimable (403)", async () => { + const { svc, rec } = service({ row: accessRow({ status: "ai_working" }) }); + await assert.rejects( + () => svc.claim({ workItemId, actor: actor({ id: assigneeId, userId: assigneeId }) }), + (error: unknown) => error instanceof WorkItemServiceError && error.status === 403 + ); + assert.deepEqual(rec.claimed, []); +}); + +test("claim: CAS miss (already claimed) maps to 409 work_item_not_claimable", async () => { + const { svc } = service({ claimReturns: null }); + await assert.rejects( + () => svc.claim({ workItemId, actor: actor({ id: assigneeId, userId: assigneeId }) }), + (error: unknown) => error instanceof WorkItemServiceError && error.status === 409 && error.code === "work_item_not_claimable" + ); +}); + +test("claim: missing work item maps to 404", async () => { + const { svc } = service({ row: null }); + await assert.rejects( + () => svc.claim({ workItemId, actor: actor({ id: assigneeId, userId: assigneeId }) }), + (error: unknown) => error instanceof WorkItemServiceError && error.status === 404 + ); +}); diff --git a/apps/api/src/work-item-comments.test.ts b/apps/api/src/work-item-comments.test.ts new file mode 100644 index 00000000..6b17f68e --- /dev/null +++ b/apps/api/src/work-item-comments.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CommentRow, WorkItemAccessRow } from "@workhub/db"; + +import type { AuthActor } from "./middleware/auth.js"; +import { WorkItemServiceError } from "./services/work-items.js"; +import { + createWorkItemCommentService, + type WorkItemCommentServiceDependencies +} from "./services/work-item-comments.js"; + +const now = new Date("2026-07-15T00:00:00.000Z"); +const workspaceId = "00000000-0000-4000-8000-000000000002"; +const submitterId = "62000000-0000-4000-8000-000000000001"; +const memberId = "62000000-0000-4000-8000-000000000009"; +const workItemId = "62000000-0000-4000-8000-0000000000cc"; + +function actor(overrides: Partial = {}): AuthActor { + return { + kind: "human", + id: memberId, + label: "member", + userId: memberId, + isAdmin: false, + orgId: "00000000-0000-4000-8000-000000000001", + workspaceId, + ...overrides + }; +} + +function accessRow(overrides: Partial = {}): WorkItemAccessRow { + return { + id: workItemId, + code: "WI-1", + title: "任务", + // 非私有态:同工作区成员可见可评。 + status: "ai_working", + submitterUserId: submitterId, + claimedByUserId: null, + workspaceId, + project: { archived: false, deletedAt: null, ownerUserId: submitterId, workspaceId, orgId: null, name: "项目" }, + assignments: [], + ...overrides + }; +} + +function commentRow(overrides: Partial = {}): CommentRow { + return { + id: "c-1", + workItemId, + authorNickname: "member", + body: "hello", + createdAt: now, + updatedAt: now, + ...overrides + } as CommentRow; +} + +function service(config: { row?: WorkItemAccessRow | null; existing?: CommentRow[] } = {}) { + const inserted: Array<{ workItemId: string; authorNickname: string; body: string }> = []; + const svc = createWorkItemCommentService({ + workItems: { + async findWorkItemAccessRecord() { + return config.row === undefined ? accessRow() : config.row; + } + }, + comments: { + async listCommentsForWorkItem() { + return config.existing ?? [commentRow({ id: "c-1", body: "first" }), commentRow({ id: "c-2", body: "second" })]; + }, + async insertComment(input) { + inserted.push({ workItemId: input.workItemId, authorNickname: input.authorNickname, body: input.body }); + return commentRow({ authorNickname: input.authorNickname, body: input.body }); + } + }, + now: () => now + } satisfies WorkItemCommentServiceDependencies); + return { svc, inserted }; +} + +test("list: a workspace member reads the thread of a visible work item", async () => { + const { svc } = service(); + const result = await svc.list({ workItemId, actor: actor() }); + assert.equal(result.work_item_id, workItemId); + assert.equal(result.comments.length, 2); + assert.equal(result.comments[0]?.body, "first"); +}); + +test("create: comment is stored with the current actor's nickname, not a client-supplied one", async () => { + const { svc, inserted } = service(); + const result = await svc.create({ workItemId, payload: { body: " looks good " }, actor: actor({ label: "真名" }) }); + assert.equal(result.author_nickname, "真名"); + assert.equal(inserted.length, 1); + assert.equal(inserted[0]?.authorNickname, "真名"); + assert.equal(inserted[0]?.body, " looks good "); +}); + +test("create: reads back what was posted (GET reflects POST)", async () => { + // 先 create 再 list:把新评论也纳入 existing,验证读到写入的内容。 + const store: CommentRow[] = []; + const svc = createWorkItemCommentService({ + workItems: { async findWorkItemAccessRecord() { return accessRow(); } }, + comments: { + async listCommentsForWorkItem() { return [...store]; }, + async insertComment(input) { + const row = commentRow({ id: `c-${store.length + 1}`, authorNickname: input.authorNickname, body: input.body }); + store.push(row); + return row; + } + }, + now: () => now + }); + await svc.create({ workItemId, payload: { body: "从 POST 写入" }, actor: actor() }); + const listed = await svc.list({ workItemId, actor: actor() }); + assert.equal(listed.comments.length, 1); + assert.equal(listed.comments[0]?.body, "从 POST 写入"); +}); + +test("list: a non-member cannot see a private work item's comments (403)", async () => { + // 私有态(spec_ready) + actor 非提交人/指派人/管理员 → 不可见。 + const { svc } = service({ row: accessRow({ status: "spec_ready" }) }); + await assert.rejects( + () => svc.list({ workItemId, actor: actor() }), + (error: unknown) => error instanceof WorkItemServiceError && error.status === 403 + ); +}); + +test("create: forbidden on a private work item for a non-participant", async () => { + const { svc, inserted } = service({ row: accessRow({ status: "spec_ready" }) }); + await assert.rejects( + () => svc.create({ workItemId, payload: { body: "x" }, actor: actor() }), + (error: unknown) => error instanceof WorkItemServiceError && error.status === 403 + ); + assert.deepEqual(inserted, []); +}); + +test("list: missing work item maps to 404", async () => { + const { svc } = service({ row: null }); + await assert.rejects( + () => svc.list({ workItemId, actor: actor() }), + (error: unknown) => error instanceof WorkItemServiceError && error.status === 404 + ); +}); diff --git a/apps/api/src/workspace-audit.test.ts b/apps/api/src/workspace-audit.test.ts new file mode 100644 index 00000000..229e8b57 --- /dev/null +++ b/apps/api/src/workspace-audit.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { HTTPException } from "hono/http-exception"; + +import type { AuditLogRow, WorkspaceAuditLogFilter } from "@workhub/db"; + +import type { AuthActor } from "./middleware/auth.js"; +import { + createWorkspaceAuditService, + type WorkspaceAuditServiceDependencies +} from "./services/workspace-audit.js"; + +const now = new Date("2026-07-15T00:00:00.000Z"); +const workspaceId = "00000000-0000-4000-8000-000000000002"; +const otherWorkspaceId = "00000000-0000-4000-8000-000000000099"; + +function actor(overrides: Partial = {}): AuthActor { + return { + kind: "human", + id: "62000000-0000-4000-8000-000000000001", + label: "admin", + userId: "62000000-0000-4000-8000-000000000001", + isAdmin: true, + orgId: "00000000-0000-4000-8000-000000000001", + workspaceId, + ...overrides + }; +} + +function auditRow(overrides: Partial = {}): AuditLogRow { + return { + id: "a-1", + orgId: null, + workspaceId, + actorKind: "human", + actorUserId: "u-1", + actorNickname: "someone", + entityType: "work_item", + entityId: "wi-1", + action: "work_item.updated", + detailJson: {}, + snapshotId: null, + undoneAt: null, + createdAt: now, + updatedAt: now, + ...overrides + } as AuditLogRow; +} + +function service(rows: AuditLogRow[] = [auditRow()]): { + svc: ReturnType; + filters: WorkspaceAuditLogFilter[]; +} { + const filters: WorkspaceAuditLogFilter[] = []; + const deps: WorkspaceAuditServiceDependencies = { + auditLogs: { + async listAuditLogsForWorkspace(filter) { + filters.push(filter); + return rows; + } + }, + now: () => now + }; + return { svc: createWorkspaceAuditService(deps), filters }; +} + +test("admin gets the workspace audit page scoped to their own workspace", async () => { + const { svc, filters } = service(); + const result = await svc.list({ actor: actor(), query: {} }); + assert.equal(result.workspace_id, workspaceId); + assert.equal(result.audit_logs.length, 1); + assert.equal(result.audit_logs[0]?.action, "work_item.updated"); + // 工作区硬隔离:仓库过滤器的 workspaceId 恒取自 actor,不受任何客户端输入影响。 + assert.equal(filters[0]?.workspaceId, workspaceId); +}); + +test("a non-admin is forbidden (403)", async () => { + const { svc, filters } = service(); + await assert.rejects( + () => svc.list({ actor: actor({ isAdmin: false }), query: {} }), + (error: unknown) => error instanceof HTTPException && error.status === 403 + ); + // 拒绝发生在查询之前——仓库不应被调用。 + assert.deepEqual(filters, []); +}); + +test("filters and pagination pass through; page reflects effective limit/offset", async () => { + const { svc, filters } = service(); + const result = await svc.list({ + actor: actor(), + query: { + actor_user_id: "u-9", + action: "snapshot.reverted", + from: "2026-07-01T00:00:00.000Z", + to: "2026-07-10T00:00:00.000Z", + limit: 25, + offset: 50 + } + }); + assert.equal(filters[0]?.actorUserId, "u-9"); + assert.equal(filters[0]?.action, "snapshot.reverted"); + assert.deepEqual(filters[0]?.from, new Date("2026-07-01T00:00:00.000Z")); + assert.deepEqual(filters[0]?.to, new Date("2026-07-10T00:00:00.000Z")); + assert.equal(filters[0]?.limit, 25); + assert.equal(filters[0]?.offset, 50); + assert.deepEqual(result.page, { limit: 25, offset: 50, count: 1 }); +}); + +test("limit is clamped to the max and offset defaults to 0", async () => { + const { svc, filters } = service(); + const result = await svc.list({ actor: actor(), query: { limit: 9999 } }); + assert.equal(filters[0]?.limit, 200); + assert.equal(filters[0]?.offset, 0); + assert.equal(result.page.limit, 200); + assert.equal(result.page.offset, 0); +}); + +test("workspace isolation: an admin of another workspace only sees their own workspace's logs", async () => { + const { svc, filters } = service(); + await svc.list({ actor: actor({ workspaceId: otherWorkspaceId }), query: {} }); + assert.equal(filters[0]?.workspaceId, otherWorkspaceId); +}); + +test("audit facts preserve the repository order (newest-first as returned)", async () => { + const newest = auditRow({ id: "a-new", action: "b.second", createdAt: new Date("2026-07-15T00:00:00.000Z") }); + const older = auditRow({ id: "a-old", action: "a.first", createdAt: new Date("2026-07-01T00:00:00.000Z") }); + // 仓库已按时间倒序返回;服务层保序映射。 + const { svc } = service([newest, older]); + const result = await svc.list({ actor: actor(), query: {} }); + assert.deepEqual(result.audit_logs.map((log) => log.id), ["a-new", "a-old"]); +}); diff --git a/apps/desktop-webview/src/browser.ts b/apps/desktop-webview/src/browser.ts index a8a75dfc..bd13e618 100644 --- a/apps/desktop-webview/src/browser.ts +++ b/apps/desktop-webview/src/browser.ts @@ -105,6 +105,12 @@ import { type SpotlightResizeFn } from "./spotlight/controller.js"; import { isStaleDesktopClientTokenError } from "./auth-recovery.js"; +import { + bindDesktopCredentialGate, + isPasswordModeBootstrapError, + readDesktopAuthModeHint, + rememberDesktopAuthModeHint +} from "./desktop-login.js"; const root = document.getElementById("root"); type BrowserApiClient = ReturnType; @@ -176,7 +182,11 @@ function pushShellBadgeToShell(count: number, locale: WorkHubLocale): void { } } -async function bootstrapDesktopClientToken(client: BrowserApiClient): Promise { +// P1-02(REL-5):昵称模式成功=拿到 token(ready);密码/hybrid 模式 desktop-bootstrap 会 404 → +// 需要凭据登录(needs-credentials),不再当「离线」静默吞;后端不可达等=unavailable。 +type DesktopBootstrapOutcome = "ready" | "needs-credentials" | "unavailable"; + +async function bootstrapDesktopClientToken(client: BrowserApiClient): Promise { try { const result = await client.bootstrapDesktop({ nickname: "WorkHub Desktop", @@ -185,11 +195,19 @@ async function bootstrapDesktopClientToken(client: BrowserApiClient): Promise { +// P1-02(REL-5):返回鉴权门状态,让 boot 决定渲主窗还是凭据登录门。 +// - ready:已有可用 token; +// - needs-credentials:密码/hybrid 模式要凭据登录(fresh/stale 探到 404,或登出后按模式提示); +// - logged-out:昵称模式的显式登出态(保持既有「不自动 rebind」,不渲凭据门); +// - offline:拿不到 token 也非上述(后端不可达等),交给上层离线兜底。 +type DesktopAuthGateState = "ready" | "needs-credentials" | "logged-out" | "offline"; + +async function ensureDesktopClientToken(client: BrowserApiClient): Promise { if (desktopLoggedOut()) { - return; + // 登出态绝不自动昵称 rebind(否则登出形同虚设)。密码模式没有「自动」可言——按上次探得的模式提示: + // 密码模式渲凭据登录门;昵称/未知模式保持既有行为(不 bootstrap、不渲门)。 + return readDesktopAuthModeHint(window.localStorage) === "password" ? "needs-credentials" : "logged-out"; } if (!clientToken()) { - await bootstrapDesktopClientToken(client); + if ((await bootstrapDesktopClientToken(client)) === "needs-credentials") { + return "needs-credentials"; + } } else { // rank16:已有 token 也要探活一次——若被吊销/陈旧(not_identified),清掉重铸, // 否则主窗/桌宠会拿着死 token 永远静默拉不到数据(旧的只在「无 token」时引导,覆盖不到这种情况)。 @@ -219,14 +248,28 @@ async function ensureDesktopClientToken(client: BrowserApiClient): Promise } catch (error) { if (isStaleDesktopClientTokenError(error)) { window.localStorage.removeItem("workhub_client_token"); - await bootstrapDesktopClientToken(client); + if ((await bootstrapDesktopClientToken(client)) === "needs-credentials") { + return "needs-credentials"; + } } } } const token = clientToken(); if (token) { pushClientTokenToShell(token); + return "ready"; } + return "offline"; +} + +// 密码/hybrid 模式凭据登录门:接到既有 login → device-token exchange 流程,成功后 reload 走既有 token 流。 +function mountDesktopCredentialGate(rootEl: HTMLElement, client: BrowserApiClient, locale: WorkHubLocale): void { + bindDesktopCredentialGate(rootEl, { + client, + locale, + storage: window.localStorage, + onSuccess: () => window.location.reload() + }); } async function resolveBootLocale(client: BrowserApiClient, fallback: WorkHubLocale) { @@ -1293,7 +1336,13 @@ async function bootSpotlight() { getClientToken: clientToken }); // 跨源鉴权地基:先确保有 client token(goldPath/pages 才返回 LIVE 数据),并把令牌推给 Rust 壳(SSE /me 鉴权)。 - await ensureDesktopClientToken(client); + const gate = await ensureDesktopClientToken(client); + // P1-02(REL-5):密码/hybrid 模式没有昵称自助引导——渲凭据登录门(login → device-token exchange), + // 登录成功后 reload 走既有 token 流。昵称模式不进这一分支(gate 只在 desktop-bootstrap 404 时才是它)。 + if (gate === "needs-credentials") { + mountDesktopCredentialGate(root, client, locale); + return; + } // R12(首帧):resolveBootLocale 内部的 me() 与 ensureDesktopClientToken 的探活是同一请求的重复—— // 直接拿一次 me 结果解析 locale,省一拍串行往返。 const bootMe = await client.me().catch(() => null); diff --git a/apps/desktop-webview/src/desktop-login.test.ts b/apps/desktop-webview/src/desktop-login.test.ts new file mode 100644 index 00000000..a9d882c5 --- /dev/null +++ b/apps/desktop-webview/src/desktop-login.test.ts @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { WorkHubApiError } from "@workhub/api-client/client"; + +import { + describeDesktopLoginError, + isPasswordModeBootstrapError, + readDesktopAuthModeHint, + rememberDesktopAuthModeHint, + renderDesktopCredentialGateHtml, + runDesktopCredentialLogin, + type DesktopLoginClient +} from "./desktop-login.js"; + +// 这个 workspace 的测试运行器没有真实 DOM(node --import tsx --test)——只测纯渲染字符串 + 纯编排逻辑, +// bindDesktopCredentialGate 的 DOM 接线不在此单测(同 desktop-offline-card 只测 renderXHtml 的取舍)。 + +function fakeReadWriteStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + const removed: string[] = []; + const set: Array<[string, string]> = []; + return { + storage: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + set.push([key, value]); + }, + removeItem: (key: string) => { + values.delete(key); + removed.push(key); + } + }, + values, + removed, + set + }; +} + +test("renderDesktopCredentialGateHtml renders an email + password credential form (password mode)", () => { + const html = renderDesktopCredentialGateHtml({ locale: "zh-CN" }); + // 邮箱 + 密码字段可挂钩;密码用 type=password(不明文回显)。 + assert.match(html, /data-desktop-login-email[^>]+type="email"/u); + assert.match(html, /data-desktop-login-password[^>]+type="password"/u); + assert.match(html, /data-desktop-login-submit/u); + assert.match(html, /data-desktop-login-error/u); + assert.match(html, /data-desktop-login-form/u); + // 中文文案。 + assert.match(html, /登录/u); + assert.match(html, /密码/u); +}); + +test("renderDesktopCredentialGateHtml localizes to English and can seed a visible error", () => { + const html = renderDesktopCredentialGateHtml({ locale: "en-US", error: "Email or password is incorrect." }); + assert.match(html, /Password/u); + assert.match(html, /Sign in/u); + // 预置错误可见(不 hidden),且被 HTML 转义写入错误行。 + assert.match(html, /data-desktop-login-error[^>]*role="alert"/u); + assert.match(html, /Email or password is incorrect\./u); + assert.doesNotMatch(html, /data-desktop-login-error hidden/u); +}); + +test("isPasswordModeBootstrapError detects the desktop-bootstrap 404 (password mode) and nothing else", () => { + assert.equal(isPasswordModeBootstrapError(new WorkHubApiError(404, "not_found", "n/a")), true); + assert.equal(isPasswordModeBootstrapError(new WorkHubApiError(403, "forbidden", "n/a")), false); + assert.equal(isPasswordModeBootstrapError(new WorkHubApiError(500, "server_error", "n/a")), false); + assert.equal(isPasswordModeBootstrapError(new Error("Failed to fetch")), false); + assert.equal(isPasswordModeBootstrapError(undefined), false); +}); + +test("runDesktopCredentialLogin logs in, exchanges for a device token, stores it and clears the logged-out flag", async () => { + const calls: { login?: unknown; bootstrap?: unknown } = {}; + const client: DesktopLoginClient = { + login: async (payload) => { + calls.login = payload; + return { + id: "u1", + nickname: "alice", + display_name: "alice", + created: false, + locale: "zh-CN", + preferences: { locale: "zh-CN" }, + is_admin: false, + availability_status: "online" + }; + }, + bootstrapDesktop: async (payload) => { + calls.bootstrap = payload; + return { + identity: { + id: "u1", + nickname: "alice", + display_name: "alice", + created: false, + locale: "zh-CN", + preferences: { locale: "zh-CN" }, + is_admin: false, + availability_status: "online" + }, + device: { + id: "d1", + user_id: "u1", + device_name: "WorkHub Desktop", + platform: "desktop", + created_at: "2026-07-17T00:00:00.000Z", + updated_at: "2026-07-17T00:00:00.000Z" + }, + client_token: "device-token-that-is-long-enough-000000" + }; + } + }; + const { storage, values, removed } = fakeReadWriteStorage({ workhub_desktop_logged_out: "1" }); + + const result = await runDesktopCredentialLogin({ + client, + credentials: { email: " alice@example.com ", password: "hunter2-strong-pass" }, + storage + }); + + assert.equal(result.client_token, "device-token-that-is-long-enough-000000"); + // 邮箱去空白后作为请求体传给 login;密码原样(明文只走请求体)。 + assert.deepEqual(calls.login, { email: "alice@example.com", password: "hunter2-strong-pass" }); + // exchange 调 bootstrapDesktop(密码模式据会话换令牌)。 + assert.ok(calls.bootstrap, "must call bootstrapDesktop to exchange the session for a device token"); + // 令牌落库;登出标记被清(成功登录后不该再停在登出态)。 + assert.equal(values.get("workhub_client_token"), "device-token-that-is-long-enough-000000"); + assert.ok(removed.includes("workhub_desktop_logged_out")); +}); + +test("runDesktopCredentialLogin propagates a bad-credentials error and does not store a token", async () => { + const client: DesktopLoginClient = { + login: async () => { + throw new WorkHubApiError(401, "auth_error", "邮箱或密码不正确"); + }, + bootstrapDesktop: async () => { + throw new Error("must not reach exchange when login fails"); + } + }; + const { storage, values } = fakeReadWriteStorage(); + + await assert.rejects( + () => + runDesktopCredentialLogin({ + client, + credentials: { email: "alice@example.com", password: "wrong" }, + storage + }), + (error) => error instanceof WorkHubApiError && error.status === 401 + ); + assert.equal(values.get("workhub_client_token"), undefined, "no token stored on failed login"); +}); + +test("runDesktopCredentialLogin fails loudly when the exchange returns no client token", async () => { + const client = { + login: async () => ({ + id: "u1", + nickname: "alice", + display_name: "alice", + created: false, + locale: "zh-CN" as const, + preferences: { locale: "zh-CN" as const }, + is_admin: false, + availability_status: "online" + }), + bootstrapDesktop: async () => ({ client_token: "" }) as never + } as unknown as DesktopLoginClient; + const { storage, values } = fakeReadWriteStorage(); + + await assert.rejects(() => + runDesktopCredentialLogin({ + client, + credentials: { email: "alice@example.com", password: "hunter2-strong-pass" }, + storage + }) + ); + assert.equal(values.get("workhub_client_token"), undefined); +}); + +test("describeDesktopLoginError maps backend statuses to retryable, non-leaky messages", () => { + assert.match(describeDesktopLoginError(new WorkHubApiError(401, "auth_error", "x"), "zh-CN"), /不正确/u); + assert.match(describeDesktopLoginError(new WorkHubApiError(429, "rate_limited", "x"), "en-US"), /Too many/u); + assert.match(describeDesktopLoginError(new WorkHubApiError(404, "not_found", "x"), "en-US"), /isn't enabled/u); + // 网络错误(非 WorkHubApiError):通用连接错误提示,可重试。 + assert.match(describeDesktopLoginError(new Error("Failed to fetch"), "zh-CN"), /登录失败/u); +}); + +test("desktop auth-mode hint round-trips through storage and rejects junk", () => { + const { storage } = fakeReadWriteStorage(); + assert.equal(readDesktopAuthModeHint(storage), null); + rememberDesktopAuthModeHint(storage, "password"); + assert.equal(readDesktopAuthModeHint(storage), "password"); + rememberDesktopAuthModeHint(storage, "nickname"); + assert.equal(readDesktopAuthModeHint(storage), "nickname"); + storage.setItem("workhub_auth_mode", "bogus"); + assert.equal(readDesktopAuthModeHint(storage), null); +}); diff --git a/apps/desktop-webview/src/desktop-login.ts b/apps/desktop-webview/src/desktop-login.ts new file mode 100644 index 00000000..ec9eef17 --- /dev/null +++ b/apps/desktop-webview/src/desktop-login.ts @@ -0,0 +1,202 @@ +// WorkHub 桌面 · 密码/hybrid 模式凭据登录门(REL-5 / P1-02)。 +// 背景:昵称模式桌面走 desktop-bootstrap 一步换 client_token;但密码/hybrid 模式没有可用登录链路—— +// desktop-bootstrap 在这两种模式下会 404(见 apps/api/src/routes/auth.ts)。本模块补上: +// 1) isPasswordModeBootstrapError:据 desktop-bootstrap 的 404 判定「当前是密码模式,要凭据登录」; +// 2) renderDesktopCredentialGateHtml:凭据表单(邮箱+密码),密码只走 ,绝不进 URL; +// 3) runDesktopCredentialLogin:凭据登录 → 设备令牌 exchange(复用后端既有能力,前端不造鉴权); +// 4) bindDesktopCredentialGate:把表单接到上面的流程,错误可见、按钮可重试(参照 SEC-2 登出状态机风格)。 +// 昵称模式不经本模块——调用方按模式分支,昵称模式保持原样自动 bootstrap。 + +import { WorkHubApiError } from "@workhub/api-client/client"; +import type { PasswordLoginRequest, WorkHubApiClient } from "@workhub/api-client"; +import type { WorkHubLocale } from "@workhub/ui/gold-path"; + +// 与 browser.ts:128 / workbench/boot.ts:19 同一套令牌键 + 登出标记键——写令牌前清登出标记,落新键。 +const CLIENT_TOKEN_KEY = "workhub_client_token"; +const DESKTOP_LOGGED_OUT_FLAG = "workhub_desktop_logged_out"; +// 探得的认证模式提示:首启 bootstrap 成功=nickname、404=password(见 isPasswordModeBootstrapError)。 +// 仅用于登出后选对再登录门(密码模式渲凭据表单)——登出态绝不自动昵称 rebind,故不能靠再探一次 +// bootstrap(昵称模式会有建设备副作用)。只是提示:真正鉴权仍以服务端为准,模式若变登录会报错让用户重试。 +const AUTH_MODE_HINT_KEY = "workhub_auth_mode"; +export type DesktopAuthModeHint = "password" | "nickname"; + +export function readDesktopAuthModeHint(storage: Pick): DesktopAuthModeHint | null { + try { + const value = storage.getItem(AUTH_MODE_HINT_KEY); + return value === "password" || value === "nickname" ? value : null; + } catch { + return null; + } +} + +export function rememberDesktopAuthModeHint(storage: Pick, mode: DesktopAuthModeHint): void { + try { + storage.setItem(AUTH_MODE_HINT_KEY, mode); + } catch { + // storage 不可用:模式提示只是优化,丢失不影响 fresh-launch 的 404 探测路径。 + } +} + +// 桌面 exchange 只需要客户端的 login + bootstrapDesktop 两个能力——收窄依赖便于测试注入假客户端。 +export type DesktopLoginClient = Pick; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"]/gu, (char) => ( + { "&": "&", "<": "<", ">": ">", "\"": """ }[char] ?? char + )); +} + +// 首启探测:desktop-bootstrap 在密码/hybrid 模式回 404(会话未建立时)。据此判定要渲凭据登录表单, +// 而不是把 404 当「后端离线」静默吞掉。网络错误/5xx 不是本判定(那是离线,另有兜底)。 +export function isPasswordModeBootstrapError(error: unknown): boolean { + return error instanceof WorkHubApiError && error.status === 404; +} + +// 凭据登录 → 设备令牌 exchange 的纯逻辑(无 DOM,便于单测往返): +// 1) client.login:POST /api/auth/login 建会话 cookie(credentials: include)——复用后端既有密码登录,不重造鉴权; +// 2) client.bootstrapDesktop:密码模式下据会话换 client_token(服务端忽略 nickname 字段,用会话身份签发设备令牌); +// 3) 令牌落 localStorage,并清掉登出标记,后续同昵称流(getClientToken 每请求实时读它走 header)。 +// 明文密码只作为请求体传给 login,绝不进 URL/query。 +export async function runDesktopCredentialLogin(input: { + client: DesktopLoginClient; + credentials: PasswordLoginRequest; + deviceName?: string; + platform?: string; + storage: Pick; +}): Promise<{ client_token: string }> { + const email = input.credentials.email.trim(); + await input.client.login({ email, password: input.credentials.password }); + const exchange = await input.client.bootstrapDesktop({ + // 密码模式服务端据会话身份签发令牌、忽略 nickname;仍按 schema 传一个占位值(nickname 必填)。 + nickname: "WorkHub Desktop", + device_name: input.deviceName?.trim() || "WorkHub Desktop", + platform: input.platform ?? "desktop" + }); + if (!exchange?.client_token) { + throw new Error("desktop exchange did not return a client token"); + } + input.storage.removeItem(DESKTOP_LOGGED_OUT_FLAG); + input.storage.setItem(CLIENT_TOKEN_KEY, exchange.client_token); + return { client_token: exchange.client_token }; +} + +// 把服务端/网络错误翻成用户可读、可重试的一句话(不泄露账号是否存在——沿用后端 401 的统一口径)。 +export function describeDesktopLoginError(error: unknown, locale: WorkHubLocale): string { + const zh = locale === "zh-CN"; + if (error instanceof WorkHubApiError) { + if (error.status === 401) { + return zh ? "邮箱或密码不正确,请重试。" : "Email or password is incorrect. Please try again."; + } + if (error.status === 429) { + return zh ? "登录尝试过于频繁,请稍后再试。" : "Too many attempts. Please wait a moment and retry."; + } + if (error.status === 400 || error.status === 422) { + return zh ? "请填写有效的邮箱和密码。" : "Enter a valid email and password."; + } + if (error.status === 404) { + return zh ? "当前后端未启用密码登录。" : "Password login isn't enabled on this backend."; + } + } + return zh + ? "登录失败,请检查后端连接后重试。" + : "Sign-in failed — check the backend connection and retry."; +} + +// 密码/hybrid 模式凭据登录门的 HTML(自带 + `; +} + +// 把凭据登录门接到 DOM:提交 → runDesktopCredentialLogin;成功回 onSuccess(一般是 reload 走既有 token 流), +// 失败把可读原因写进错误行并重新启用按钮(可重试)。空字段就地提示,不发请求(也就不会把空密码送上网)。 +export function bindDesktopCredentialGate( + rootEl: HTMLElement, + input: { + client: DesktopLoginClient; + locale: WorkHubLocale; + storage: Pick; + onSuccess: () => void; + deviceName?: string; + platform?: string; + } +): void { + rootEl.innerHTML = renderDesktopCredentialGateHtml({ locale: input.locale }); + const zh = input.locale === "zh-CN"; + const form = rootEl.querySelector("[data-desktop-login-form]"); + const emailEl = rootEl.querySelector("[data-desktop-login-email]"); + const passwordEl = rootEl.querySelector("[data-desktop-login-password]"); + const submitEl = rootEl.querySelector("[data-desktop-login-submit]"); + const errorEl = rootEl.querySelector("[data-desktop-login-error]"); + const showError = (message: string) => { + if (errorEl) { + errorEl.textContent = message; + errorEl.hidden = false; + } + }; + emailEl?.focus({ preventScroll: true }); + form?.addEventListener("submit", (event) => { + event.preventDefault(); + const email = emailEl?.value.trim() ?? ""; + const password = passwordEl?.value ?? ""; + if (!email || !password) { + showError(zh ? "请填写邮箱和密码。" : "Enter your email and password."); + return; + } + if (submitEl) { + submitEl.disabled = true; + } + if (errorEl) { + errorEl.hidden = true; + } + void runDesktopCredentialLogin({ + client: input.client, + credentials: { email, password }, + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + ...(input.platform ? { platform: input.platform } : {}), + storage: input.storage + }) + .then(() => input.onSuccess()) + .catch((error: unknown) => { + if (submitEl) { + submitEl.disabled = false; + } + showError(describeDesktopLoginError(error, input.locale)); + }); + }); +} diff --git a/apps/desktop-webview/src/main.test.ts b/apps/desktop-webview/src/main.test.ts index 4512e4ca..0e0edfd9 100644 --- a/apps/desktop-webview/src/main.test.ts +++ b/apps/desktop-webview/src/main.test.ts @@ -226,6 +226,9 @@ function fakeClient(surface: DesktopTestSurface, session: SessionVM = intakeSess async identify() { throw new Error("not needed"); }, + async login() { + throw new Error("not needed"); + }, async bootstrapDesktop() { throw new Error("not needed"); }, diff --git a/apps/desktop-webview/src/workbench/boot.ts b/apps/desktop-webview/src/workbench/boot.ts index a84de15c..c72e969b 100644 --- a/apps/desktop-webview/src/workbench/boot.ts +++ b/apps/desktop-webview/src/workbench/boot.ts @@ -11,6 +11,12 @@ import type { WorkHubLocale } from "@workhub/ui/gold-path"; import { applyIdentityLocale, browserLocale, setDocumentLocale } from "@workhub/web-runtime"; import { isStaleDesktopClientTokenError } from "../auth-recovery.js"; +import { + bindDesktopCredentialGate, + isPasswordModeBootstrapError, + readDesktopAuthModeHint, + rememberDesktopAuthModeHint +} from "../desktop-login.js"; import { resolveDesktopTauriInvoke } from "../desktop-window-controls.js"; import { consumePendingWorkbenchDeepLink } from "./pending-deep-link.js"; import { mountWorkbenchShell, renderWorkbenchDocumentHead, type WorkbenchShellHandle } from "./shell.js"; @@ -54,34 +60,49 @@ function pushClientTokenToShell(token: string): void { } } -// 和 browser.ts 的 ensureDesktopClientToken 同语义:登出态不重新自动绑定;有 token 就探活一次, -// 陈旧/吊销(not_identified/invalid_client_token)才清掉重铸;网络/后端问题不动 token。 +// 和 browser.ts 的 ensureDesktopClientToken 同语义(含 P1-02 凭据登录门状态):登出态不重新自动绑定; +// 有 token 就探活一次,陈旧/吊销(not_identified/invalid_client_token)才清掉重铸;网络/后端问题不动 token。 // 顺带把探活拿到的 identity 返回,给 boot() 复用去解析 locale,省一次重复往返(R12 首帧同款优化)。 -export async function ensureWorkbenchClientToken(client: WorkHubApiClient): Promise { +// gate 语义同 browser.ts:ready/needs-credentials/logged-out/offline——密码/hybrid 模式 desktop-bootstrap +// 会 404 → needs-credentials,boot() 据此渲凭据登录门而非静默无 token。 +export type WorkbenchAuthGateState = "ready" | "needs-credentials" | "logged-out" | "offline"; + +export async function ensureWorkbenchClientToken( + client: WorkHubApiClient +): Promise<{ identity: IdentityResponse | null; gate: WorkbenchAuthGateState }> { if (isWorkbenchDesktopLoggedOut()) { - return null; + // 登出态绝不自动昵称 rebind。密码模式按上次探得的模式提示渲凭据登录门;昵称/未知保持既有行为。 + const gate = readDesktopAuthModeHint(window.localStorage) === "password" ? "needs-credentials" : "logged-out"; + return { identity: null, gate }; } let identity: IdentityResponse | null = null; if (!clientToken()) { - await bootstrapWorkbenchClientToken(client); + if ((await bootstrapWorkbenchClientToken(client)) === "needs-credentials") { + return { identity: null, gate: "needs-credentials" }; + } } else { try { identity = await client.me(); } catch (error) { if (isStaleDesktopClientTokenError(error)) { window.localStorage.removeItem("workhub_client_token"); - await bootstrapWorkbenchClientToken(client); + if ((await bootstrapWorkbenchClientToken(client)) === "needs-credentials") { + return { identity: null, gate: "needs-credentials" }; + } } } } const token = clientToken(); if (token) { pushClientTokenToShell(token); + return { identity, gate: "ready" }; } - return identity; + return { identity, gate: "offline" }; } -async function bootstrapWorkbenchClientToken(client: WorkHubApiClient): Promise { +type WorkbenchBootstrapOutcome = "ready" | "needs-credentials" | "unavailable"; + +async function bootstrapWorkbenchClientToken(client: WorkHubApiClient): Promise { try { const result = await client.bootstrapDesktop({ nickname: "WorkHub Desktop", @@ -90,9 +111,17 @@ async function bootstrapWorkbenchClientToken(client: WorkHubApiClient): Promise< }); if (result?.client_token) { window.localStorage.setItem("workhub_client_token", result.client_token); + rememberDesktopAuthModeHint(window.localStorage, "nickname"); + return "ready"; } + return "unavailable"; } catch (error) { + if (isPasswordModeBootstrapError(error)) { + rememberDesktopAuthModeHint(window.localStorage, "password"); + return "needs-credentials"; + } console.warn("WorkHub workbench desktop bootstrap failed; continuing without client token", error); + return "unavailable"; } } @@ -193,7 +222,20 @@ async function boot(): Promise { getClientToken: clientToken }); - const identity = await ensureWorkbenchClientToken(client).catch(() => null); + const auth = await ensureWorkbenchClientToken(client).catch( + () => ({ identity: null, gate: "offline" as const }) + ); + // P1-02(REL-5):密码/hybrid 模式渲凭据登录门(login → device-token exchange),成功后 reload 走既有 token 流。 + if (auth.gate === "needs-credentials") { + bindDesktopCredentialGate(root, { + client, + locale, + storage: window.localStorage, + onSuccess: () => window.location.reload() + }); + return; + } + const identity = auth.identity; locale = applyIdentityLocale(identity, locale); setDocumentLocale(locale); diff --git a/apps/web/src/main.test.ts b/apps/web/src/main.test.ts index 057bc9bc..e8e641cd 100644 --- a/apps/web/src/main.test.ts +++ b/apps/web/src/main.test.ts @@ -104,6 +104,9 @@ function fakeClient(surface: GoldPathSurfaceVM, session: SessionVM = intakeSessi async identify() { throw new Error("not needed"); }, + async login() { + throw new Error("not needed"); + }, async bootstrapDesktop() { throw new Error("not needed"); }, diff --git a/client-tauri/src-tauri/src/main.rs b/client-tauri/src-tauri/src/main.rs index 645e2dc5..47735fb5 100644 --- a/client-tauri/src-tauri/src/main.rs +++ b/client-tauri/src-tauri/src/main.rs @@ -1,5 +1,7 @@ use workhub_client_tauri::config::{load_shell_config_from_json_and_env, WorkHubShellConfig}; -use workhub_client_tauri::deep_link::{deep_link_plan_from_url, describe_deep_link_error}; +use workhub_client_tauri::deep_link::{ + deep_link_plan_from_url, describe_deep_link_error, ShellDeepLinkPlan, +}; use workhub_client_tauri::events::{event_channel_name, ShellEvent}; use workhub_client_tauri::locale::{ normalize_optional_workhub_locale, normalize_workhub_locale, WorkHubLocale, @@ -1559,6 +1561,59 @@ fn install_workhub_deep_links(app: &tauri::App) -> Result<(), String> { Ok(()) } +// P1-04:workbench 窗在 tauri.conf.json 里是 create:false(按需建)。除它以外的窗口(main/pet)启动时 +// 已经建好,深链落地前不需要"先建窗"这一步。抽成纯函数,不依赖 AppHandle,便于单测。 +fn deep_link_target_requires_window_creation(label: &str) -> bool { + label == "workbench" +} + +/// 深链落地要做的两件事:按需建窗 + 执行窗口控制(show/focus/...)。抽成 trait 是为了让下面的 +/// `apply_deep_link_plan` 脱离真实 `tauri::AppHandle` 也能单测——生产实现见 `TauriDeepLinkWindowHost`, +/// 测试里用一个纯内存假实现记录调用顺序,直接复现/验证 P1-04 的根因与修复。 +trait DeepLinkWindowHost { + fn create_window(&mut self, label: &str) -> Result<(), String>; + fn control_window(&mut self, control: &ShellWindowControlPlan) -> Result<(), String>; +} + +struct TauriDeepLinkWindowHost<'a> { + app: &'a tauri::AppHandle, +} + +impl DeepLinkWindowHost for TauriDeepLinkWindowHost<'_> { + fn create_window(&mut self, label: &str) -> Result<(), String> { + if label == "workbench" { + create_workbench_window_if_missing(self.app)?; + } + Ok(()) + } + + fn control_window(&mut self, control: &ShellWindowControlPlan) -> Result<(), String> { + execute_window_control(self.app, control.clone()).map(|_| ()) + } +} + +/// deep-link(冷启动 URL / 运行时 on_open_url)与 single-instance(第二实例)两条入口共享的落地路径: +/// 目标窗若按需创建,先确保它存在,再执行窗口控制。P1-04 根因:此前只有正常 deep-link 分支走这套 +/// create-if-missing,single-instance 分支直接执行窗口控制——应用已运行但 workbench 窗还没被建过时 +/// (用户从未点开过工作台),`execute_window_control` 报 "workbench window is not available",深链 +/// 目标直接丢失。现在两条入口都收敛到这一个函数,不再各自维护一份判断逻辑。 +fn apply_deep_link_plan( + host: &mut H, + plan: &ShellDeepLinkPlan, +) -> Result<(), String> { + if deep_link_target_requires_window_creation(&plan.window_control.label) { + host.create_window(&plan.window_control.label)?; + } + host.control_window(&plan.window_control) +} + +fn handle_deep_link_plan(app: &tauri::AppHandle, plan: &ShellDeepLinkPlan) -> Result<(), String> { + let mut host = TauriDeepLinkWindowHost { app }; + apply_deep_link_plan(&mut host, plan)?; + app.emit(event_channel_name(ShellEvent::DeepLink), plan.clone()) + .map_err(|error| format!("failed to emit deep-link event: {error}")) +} + fn handle_deep_link_url(app: &tauri::AppHandle, raw_url: &str) -> Result<(), String> { let locale = current_workhub_locale(app); let plan = deep_link_plan_from_url(raw_url).map_err(|error| { @@ -1568,13 +1623,7 @@ fn handle_deep_link_url(app: &tauri::AppHandle, raw_url: &str) -> Result<(), Str ) })?; - // 工作台窗按需创建(conf 里 create:false);先确保存在再执行 show/focus,否则冷启动深链会打空。 - if plan.window_control.label == "workbench" { - create_workbench_window_if_missing(app)?; - } - execute_window_control(app, plan.window_control.clone())?; - app.emit(event_channel_name(ShellEvent::DeepLink), plan) - .map_err(|error| format!("failed to emit deep-link event: {error}")) + handle_deep_link_plan(app, &plan) } fn handle_single_instance_launch( @@ -1586,10 +1635,9 @@ fn handle_single_instance_launch( if plan.deep_links.is_empty() { execute_window_control(app, plan.window_control.clone())?; } else { + // 第二实例带深链:复用与冷启动/运行时深链相同的落地路径(含按需建窗),不再绕开 create-if-missing。 for deep_link in &plan.deep_links { - execute_window_control(app, deep_link.window_control.clone())?; - app.emit(event_channel_name(ShellEvent::DeepLink), deep_link.clone()) - .map_err(|error| format!("failed to emit deep-link event: {error}"))?; + handle_deep_link_plan(app, deep_link)?; } } @@ -1842,6 +1890,96 @@ fn main() { #[cfg(test)] mod tests { use super::*; + use std::collections::HashSet; + use workhub_client_tauri::single_instance::single_instance_plan_from_args; + + // P1-04:纯内存假实现,记录调用顺序/次数,脱离真实 tauri::AppHandle 复现"第二实例带 workbench 深链、 + // workbench 窗尚未创建"场景,并验证 apply_deep_link_plan 是否先建窗再控制窗口。 + #[derive(Default)] + struct FakeDeepLinkWindowHost { + existing_windows: HashSet, + created_windows: Vec, + controlled_labels: Vec, + } + + impl DeepLinkWindowHost for FakeDeepLinkWindowHost { + fn create_window(&mut self, label: &str) -> Result<(), String> { + self.created_windows.push(label.to_string()); + self.existing_windows.insert(label.to_string()); + Ok(()) + } + + fn control_window(&mut self, control: &ShellWindowControlPlan) -> Result<(), String> { + if !self.existing_windows.contains(&control.label) { + return Err(format!("{} window is not available", control.label)); + } + self.controlled_labels.push(control.label.clone()); + Ok(()) + } + } + + #[test] + fn skipping_the_create_step_loses_a_workbench_deep_link_when_window_is_missing() { + // 复现 P1-04 根因:旧的 single-instance 分支直接执行窗口控制、不先建窗;应用已运行但用户从未 + // 点开过工作台(workbench 窗还不存在)时,窗口控制直接报"不可用",深链目标丢失。 + let mut host = FakeDeepLinkWindowHost::default(); + let plan = + deep_link_plan_from_url("workhub://workbench/86000000-0000-4000-8000-000000000001") + .unwrap(); + + let result = host.control_window(&plan.window_control); + + assert_eq!(result, Err("workbench window is not available".to_string())); + } + + #[test] + fn apply_deep_link_plan_creates_the_missing_workbench_window_before_navigating() { + // 修复后:deep-link 与 single-instance 共享的落地路径按需建窗后再执行窗口控制,深链不丢。 + let mut host = FakeDeepLinkWindowHost::default(); + let plan = + deep_link_plan_from_url("workhub://workbench/86000000-0000-4000-8000-000000000001") + .unwrap(); + + apply_deep_link_plan(&mut host, &plan).unwrap(); + + assert_eq!(host.created_windows, vec!["workbench".to_string()]); + assert_eq!(host.controlled_labels, vec!["workbench".to_string()]); + } + + #[test] + fn apply_deep_link_plan_does_not_create_the_always_present_main_window() { + // main 窗启动时已建好(create 未置 false),不需要走按需建窗这一步。 + let mut host = FakeDeepLinkWindowHost::default(); + host.existing_windows.insert("main".to_string()); + let plan = deep_link_plan_from_url("workhub://open/approvals").unwrap(); + + apply_deep_link_plan(&mut host, &plan).unwrap(); + + assert!(host.created_windows.is_empty()); + assert_eq!(host.controlled_labels, vec!["main".to_string()]); + } + + #[test] + fn single_instance_workbench_deep_link_creates_the_window_via_shared_apply_path() { + // 串联 single_instance 的纯规划(从第二实例 argv 提取 deep_links)与 main.rs 的共享落地路径: + // 第二实例带 workbench 深链时,即便 workbench 窗从未创建过,也不会丢链。 + let single_instance_plan = single_instance_plan_from_args( + &[ + "WorkHub.exe".to_string(), + "workhub://workbench/86000000-0000-4000-8000-000000000001".to_string(), + ], + "C:/WorkHub", + ); + assert_eq!(single_instance_plan.deep_links.len(), 1); + + let mut host = FakeDeepLinkWindowHost::default(); + for deep_link in &single_instance_plan.deep_links { + apply_deep_link_plan(&mut host, deep_link).unwrap(); + } + + assert_eq!(host.created_windows, vec!["workbench".to_string()]); + assert_eq!(host.controlled_labels, vec!["workbench".to_string()]); + } fn env_value(value: Option<&'static str>) -> impl Fn(&str) -> Option { move |_| value.map(str::to_string) diff --git a/packages/api-client/src/api-client.test.ts b/packages/api-client/src/api-client.test.ts index d6fe5737..b4ae1e05 100644 --- a/packages/api-client/src/api-client.test.ts +++ b/packages/api-client/src/api-client.test.ts @@ -32,6 +32,64 @@ test("api client unwraps WorkHub envelopes and injects the desktop token headers assert.equal(seenHeaders.legacy, "device-token"); }); +test("login posts credentials to /api/auth/login in the body (never the URL) and returns identity", async () => { + // P1-02(REL-5):桌面密码/hybrid 模式先用凭据登录建会话,再走 bootstrapDesktop 换设备令牌。 + let seenUrl: string | undefined; + let seenMethod: string | undefined; + let seenBody: string | undefined; + const client = createApiClient({ + baseUrl: "http://127.0.0.1:8787", + fetchFn: async (input, init) => { + seenUrl = typeof input === "string" ? input : String(input); + seenMethod = init?.method; + seenBody = typeof init?.body === "string" ? init.body : undefined; + return new Response( + JSON.stringify({ + ok: true, + data: { + id: "u1", + nickname: "alice", + display_name: "alice", + created: false, + locale: "zh-CN", + preferences: { locale: "zh-CN" }, + is_admin: false, + availability_status: "online" + } + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + }); + + const identity = await client.login({ email: "alice@example.com", password: "hunter2-strong-pass" }); + assert.equal(identity.id, "u1"); + assert.equal(identity.nickname, "alice"); + assert.equal(seenMethod, "POST"); + assert.equal(seenUrl, "http://127.0.0.1:8787/api/auth/login"); + assert.ok(!seenUrl?.includes("hunter2-strong-pass"), "password must never appear in the URL"); + assert.ok(!seenUrl?.includes("email="), "credentials must not be in the query string"); + assert.deepEqual(JSON.parse(seenBody ?? "{}"), { + email: "alice@example.com", + password: "hunter2-strong-pass" + }); +}); + +test("login surfaces a 401 from the backend as a WorkHubApiError (bad credentials, retryable)", async () => { + const client = createApiClient({ + fetchFn: async () => + new Response(JSON.stringify({ ok: false, error: { code: "auth_error", message: "邮箱或密码不正确" } }), { + status: 401, + headers: { "Content-Type": "application/json" } + }) + }); + + await assert.rejects( + () => client.login({ email: "alice@example.com", password: "wrong" }), + (error) => error instanceof WorkHubApiError && error.status === 401 + ); +}); + test("api client preserves raw ok health payloads that are not WorkHub envelopes", async () => { const client = createApiClient({ fetchFn: async () => diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 38d8d722..9239fe52 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -370,6 +370,12 @@ export function createApiClient(options: WorkHubApiClientOptions = {}): WorkHubA method: "POST", body: JSON.stringify(payload) }), + // 桌面凭据登录(密码/hybrid 模式):明文密码只走请求体,建会话 cookie(credentials: include),随后 bootstrapDesktop 据会话换 client_token。 + login: (payload) => + request("/api/auth/login", { + method: "POST", + body: JSON.stringify(payload) + }), bootstrapDesktop: (payload) => request("/api/auth/desktop-bootstrap", { method: "POST", diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index 3f2b7494..db58a57f 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -228,6 +228,13 @@ export type IdentifyRequest = { admin_secret?: string; }; +// R2 auth epic(密码登录):桌面在密码/hybrid 模式先用凭据登录建会话,再走 bootstrapDesktop 换设备令牌。 +// 明文密码只走请求体(POST /api/auth/login),绝不进 URL/query。 +export type PasswordLoginRequest = { + email: string; + password: string; +}; + export type IdentityResponse = { id: string; nickname: string; @@ -304,7 +311,9 @@ export type WorkHubApiClient = { health: () => Promise; openapi: () => Promise; identify: (payload: IdentifyRequest) => Promise; - // 桌面首启引导:昵称 identify + 设备注册一步到位,返回 client_token(仅昵称模式)。 + // 桌面凭据登录(密码/hybrid 模式):POST /api/auth/login 建会话 cookie,随后 bootstrapDesktop 据会话换 client_token。 + login: (payload: PasswordLoginRequest) => Promise; + // 桌面首启引导:昵称模式=昵称 identify + 设备注册一步到位;密码/hybrid 模式=凭已登录会话换设备令牌。均返回 client_token。 bootstrapDesktop: (payload: DesktopBootstrapRequest) => Promise; // 设备管理(需已鉴权):注册 / 列表 / 当前 / 吊销。 registerClientDevice: (payload: ClientDeviceRegisterRequest) => Promise; diff --git a/packages/contracts/src/audit.ts b/packages/contracts/src/audit.ts index cee4dbba..2b99224c 100644 --- a/packages/contracts/src/audit.ts +++ b/packages/contracts/src/audit.ts @@ -81,3 +81,31 @@ export const auditTimelineVmSchema = z.object({ manifest_facts: manifestFactsSchema }); export type AuditTimelineVM = z.infer; + +// R20 P2A(R19-21 工作区审计列表 · 仅管理员):跨工作区的审计流(不局限单个工作项),支持按 +// 操作者/动作/时间范围过滤 + 分页,时间倒序。工作区隔离在服务层用 actor.workspaceId 强制, +// 客户端不能传 workspace(避免越租户读)。 +export const WORKSPACE_AUDIT_DEFAULT_LIMIT = 50; +export const WORKSPACE_AUDIT_MAX_LIMIT = 200; + +export const workspaceAuditQuerySchema = z.object({ + actor_user_id: idSchema.optional(), + action: z.string().trim().min(1).max(64).optional(), + from: isoDateTimeSchema.optional(), + to: isoDateTimeSchema.optional(), + limit: z.coerce.number().int().min(1).max(WORKSPACE_AUDIT_MAX_LIMIT).optional(), + offset: z.coerce.number().int().min(0).optional() +}); +export type WorkspaceAuditQuery = z.infer; + +export const workspaceAuditListVmSchema = z.object({ + generated_at: isoDateTimeSchema, + workspace_id: idSchema, + audit_logs: z.array(auditLogFactSchema), + page: z.object({ + limit: z.number().int().min(1), + offset: z.number().int().min(0), + count: z.number().int().min(0) + }) +}); +export type WorkspaceAuditListVM = z.infer; diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index b7b34b9e..9fc171b9 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -128,6 +128,46 @@ export const listWorkspaceMembersResultVmSchema = z .strict(); export type ListWorkspaceMembersResultVM = z.infer; +// R20 P2A(P1-08 修复 · workspace-scoped roster):GET /api/workspace/roster 的分页查询参数。 +// 背景:/api/users 是全局用户目录(跨租户泄露 + 全局昵称排序 + 硬上限 200 截断),却被消费端误当工作区 +// 花名册。本端点按调用者所在工作区 join membership 列成员,limit/offset 分页——无硬 200 截断,任意工作区 +// 成员可读。查询参数来自 URL query string(字符串),故 coerce;越界/非法一律回退默认(.catch),只读列表 +// 不因坏参数 422 阻断(客户端翻页体验优先)。 +export const workspaceRosterQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(50).catch(50), + offset: z.coerce.number().int().min(0).default(0).catch(0) +}); +export type WorkspaceRosterQuery = z.infer; + +// roster 一行成员:昵称/角色/加入时间/是否本人 + 头像占位 + 在线态占位。 +// * avatar_updated_at:非空表示该成员有头像(兼作 GET /api/users/:id/avatar 的缓存键);此处不回二进制。 +// * online:在线态占位——当前恒 null(presence 接线后由后续批次填真值),字段先就位以免届时改契约。 +export const workspaceRosterMemberVmSchema = z + .object({ + user_id: idSchema, + nickname: z.string().min(1).max(96), + role: workspaceMemberRoleSchema, + joined_at: isoDateTimeSchema, + is_self: z.boolean(), + avatar_updated_at: isoDateTimeSchema.nullable(), + online: z.boolean().nullable() + }) + .strict(); +export type WorkspaceRosterMemberVM = z.infer; + +// roster 分页响应:本页成员 + 工作区活跃成员总数 + 回显本次 limit/offset。 +// * total 修「计数错」——/api/users 的全局计数含跨租户且封顶 200;这里是本工作区 active 成员真实总数。 +// * total > offset + members.length 即还有下一页,客户端据此翻页;全量成员皆可经 offset 达到(无 200 截断)。 +export const workspaceRosterResultVmSchema = z + .object({ + members: z.array(workspaceRosterMemberVmSchema), + total: z.number().int().nonnegative(), + limit: z.number().int().min(1).max(100), + offset: z.number().int().nonnegative() + }) + .strict(); +export type WorkspaceRosterResultVM = z.infer; + export const identifyResponseSchema = userSchema.pick({ id: true, nickname: true, diff --git a/packages/contracts/src/domain/project.ts b/packages/contracts/src/domain/project.ts index f0d3e354..f00304a1 100644 --- a/packages/contracts/src/domain/project.ts +++ b/packages/contracts/src/domain/project.ts @@ -47,6 +47,21 @@ export const bootstrapProjectResultSchema = z.object({ }); export type BootstrapProjectResult = z.infer; +// R20 P2A(R19-19 项目归档/删除 · 纯后端):归档=软置 archived=true(从团队列表隐去,可再建同名), +// 删除=软置 deletedAt(墓碑)。两端点均无请求体,管理员/项目所有者门控(canManageProjectDrive)。 +// 响应回操作后的项目 VM 与一个操作标志(archived / deleted)——additive,既有客户端不用改。 +export const archiveProjectResultSchema = z.object({ + project: projectVmSchema, + archived: z.literal(true) +}); +export type ArchiveProjectResult = z.infer; + +export const deleteProjectResultSchema = z.object({ + project: projectVmSchema, + deleted: z.literal(true) +}); +export type DeleteProjectResult = z.infer; + // R13 批 S3(个人空间):创建请求只填名字(可省略——服务端按「我的空间」/「我的空间 2」…自动命名)。 // 不需要 workspace_id/slug/成员邀请这些团队项目才有的字段——个人空间跳过治理/邀请这一整套步骤。 export const createPersonalProjectRequestSchema = z.object({ diff --git a/packages/contracts/src/domain/work-item.ts b/packages/contracts/src/domain/work-item.ts index d7949ca2..6824c067 100644 --- a/packages/contracts/src/domain/work-item.ts +++ b/packages/contracts/src/domain/work-item.ts @@ -195,3 +195,49 @@ export const acceptanceCriteriaSchema = timestampFieldsSchema.extend({ source_plan_id: idSchema.optional() }); export type AcceptanceCriteria = z.infer; + +// R20 P2A(R19-18 指派/认领):把一个工作项显式指派给某工作区成员。role 省略默认 collaborator; +// 角色枚举与 work_item_assignments.role / assignmentSchema 同源(lead/collaborator)。 +export const assignWorkItemRequestSchema = z + .object({ + assignee_user_id: idSchema, + role: z.enum(["lead", "collaborator"]).optional() + }) + .strict(); +export type AssignWorkItemRequest = z.infer; + +export const assignWorkItemResultSchema = z.object({ + assignment: assignmentSchema +}); +export type AssignWorkItemResult = z.infer; + +// R20 P2A:认领端点无请求体(当前登录用户认领这个无主工作项)。响应回落地后的认领人。 +export const claimWorkItemResultSchema = z.object({ + work_item_id: idSchema, + claimed_by_user_id: idSchema +}); +export type ClaimWorkItemResult = z.infer; + +// R20 P2A(R19-22 工作项评论):通用 comments 表(work_item_id + author_nickname + body)的读写视图。 +export const WORK_ITEM_COMMENT_MAX_CHARS = 4000; + +export const workItemCommentSchema = timestampFieldsSchema.extend({ + id: idSchema, + work_item_id: idSchema, + author_nickname: z.string().min(1).max(64), + body: z.string().min(1) +}); +export type WorkItemComment = z.infer; + +export const workItemCommentsResultSchema = z.object({ + work_item_id: idSchema, + comments: z.array(workItemCommentSchema) +}); +export type WorkItemCommentsResult = z.infer; + +export const createWorkItemCommentRequestSchema = z + .object({ + body: z.string().trim().min(1).max(WORK_ITEM_COMMENT_MAX_CHARS) + }) + .strict(); +export type CreateWorkItemCommentRequest = z.infer; diff --git a/packages/contracts/src/workspace-roster.test.ts b/packages/contracts/src/workspace-roster.test.ts new file mode 100644 index 00000000..a6ce2434 --- /dev/null +++ b/packages/contracts/src/workspace-roster.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + workspaceRosterMemberVmSchema, + workspaceRosterQuerySchema, + workspaceRosterResultVmSchema +} from "./auth.js"; + +// R20 P2A(P1-08 修复 · workspace-scoped roster):分页查询参数 + 响应 VM 的契约门。 + +test("roster query defaults to limit 50 / offset 0 when unspecified", () => { + assert.deepEqual(workspaceRosterQuerySchema.parse({}), { limit: 50, offset: 0 }); + assert.deepEqual(workspaceRosterQuerySchema.parse({ limit: undefined, offset: undefined }), { limit: 50, offset: 0 }); +}); + +test("roster query coerces string query params into bounded integers", () => { + assert.deepEqual(workspaceRosterQuerySchema.parse({ limit: "20", offset: "40" }), { limit: 20, offset: 40 }); +}); + +test("roster query falls back to defaults on illegal / out-of-range params instead of throwing", () => { + // 只读列表不因坏参数 422 阻断——越界/非数值一律回退默认(.catch)。 + assert.deepEqual(workspaceRosterQuerySchema.parse({ limit: "999" }), { limit: 50, offset: 0 }); + assert.deepEqual(workspaceRosterQuerySchema.parse({ limit: "0" }), { limit: 50, offset: 0 }); + assert.deepEqual(workspaceRosterQuerySchema.parse({ limit: "abc" }), { limit: 50, offset: 0 }); + assert.deepEqual(workspaceRosterQuerySchema.parse({ offset: "-5" }), { limit: 50, offset: 0 }); +}); + +test("roster member VM accepts avatar/online placeholders (null) and rejects unknown roles", () => { + const base = { + user_id: "20000000-0000-4000-8000-0000000000b1", + nickname: "小赵", + role: "member" as const, + joined_at: "2026-07-02T00:00:00.000Z", + is_self: false, + avatar_updated_at: null, + online: null + }; + assert.equal(workspaceRosterMemberVmSchema.safeParse(base).success, true); + assert.equal( + workspaceRosterMemberVmSchema.safeParse({ ...base, avatar_updated_at: "2026-07-14T00:00:00.000Z" }).success, + true + ); + assert.equal(workspaceRosterMemberVmSchema.safeParse({ ...base, role: "superuser" }).success, false); + // strict:禁未知字段混入。 + assert.equal(workspaceRosterMemberVmSchema.safeParse({ ...base, cookie_token: "leak" }).success, false); +}); + +test("roster result VM carries members plus total/limit/offset paging metadata", () => { + const result = { + members: [], + total: 251, + limit: 100, + offset: 200 + }; + assert.equal(workspaceRosterResultVmSchema.safeParse(result).success, true); + // total 不设上限(工作区可有 >200 成员,正是本次修复要覆盖的规模)。 + assert.equal(workspaceRosterResultVmSchema.safeParse({ ...result, total: 100000 }).success, true); + // 负数拒。 + assert.equal(workspaceRosterResultVmSchema.safeParse({ ...result, offset: -1 }).success, false); +}); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 160ad7dd..3b490f7f 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -29,6 +29,8 @@ export * from "./repositories/notifications.js"; export * from "./repositories/audit.js"; export * from "./repositories/confidence.js"; export * from "./repositories/work-items.js"; +export * from "./repositories/work-item-assignments.js"; +export * from "./repositories/comments.js"; export * from "./repositories/proposals.js"; export * from "./repositories/projects.js"; export * from "./repositories/workbench.js"; diff --git a/packages/db/src/memberships-roster.test.ts b/packages/db/src/memberships-roster.test.ts new file mode 100644 index 00000000..8ce7c073 --- /dev/null +++ b/packages/db/src/memberships-roster.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createWorkspaceMembershipRepository } from "./repositories/memberships.js"; +import { users, workspaceMemberships } from "./schema/index.js"; +import { createQueryRecorder, queryParamValues, queryReferences } from "./test-query-recorder.js"; + +// R20 P2A(P1-08 修复 · workspace-scoped roster):memberships.listActiveRosterPageByWorkspace 的 SQL 形状门。 +// 用假 DB(记录查询形状 + 回放 seeded 行)验证:工作区隔离、双软删排除、分页 limit/offset 而非硬 200 截断、 +// 稳定排序、总数与取页同口径。真实过滤在真 PG(qa smoke)覆盖;此处只钉查询正确构造。 + +const WS = "20000000-0000-4000-8000-0000000000a1"; + +const rowA = { + userId: "20000000-0000-4000-8000-0000000000b1", + nickname: "阿黄", + role: "owner" as const, + joinedAt: new Date("2026-07-01T00:00:00.000Z"), + avatarUpdatedAt: new Date("2026-07-10T00:00:00.000Z") +}; +const rowB = { + userId: "20000000-0000-4000-8000-0000000000b2", + nickname: "小赵", + role: "member" as const, + joinedAt: new Date("2026-07-02T00:00:00.000Z"), + avatarUpdatedAt: null +}; + +test("roster page scopes to the workspace, excludes soft-deleted, joins users and paginates", async () => { + const { db, queries } = createQueryRecorder([[{ total: 3 }], [rowA, rowB]]); + const repo = createWorkspaceMembershipRepository(db); + const listRoster = repo.listActiveRosterPageByWorkspace; + assert.ok(listRoster, "concrete repository implements the optional roster method"); + + const result = await listRoster(WS, { limit: 2, offset: 4 }); + + // 映射正确:总数来自计数查询,成员含头像时间戳占位(含 null)。 + assert.equal(result.total, 3); + assert.deepEqual(result.members, [ + { userId: rowA.userId, nickname: rowA.nickname, role: "owner", joinedAt: rowA.joinedAt, avatarUpdatedAt: rowA.avatarUpdatedAt }, + { userId: rowB.userId, nickname: rowB.nickname, role: "member", joinedAt: rowB.joinedAt, avatarUpdatedAt: null } + ]); + + const [countQuery, pageQuery] = queries; + // 计数查询:join users、按工作区隔离、不带 limit(总数不能被封顶——否则「计数错」重现)。 + assert.ok(countQuery, "count query recorded first"); + assert.ok( + countQuery.joins.some((join) => join.table === users), + "count query inner-joins users" + ); + assert.equal(countQuery.limit, undefined, "count query must not be capped"); + assert.ok(queryParamValues(countQuery.where).includes(WS), "count query binds the workspace id"); + + // 取页查询:join users、WHERE 绑定 workspaceId + 排除 membership/user 双软删、排序、分页 limit/offset。 + assert.ok(pageQuery, "page query recorded second"); + assert.ok( + pageQuery.joins.some((join) => join.table === users), + "page query inner-joins users" + ); + assert.ok( + queryReferences(pageQuery.where, workspaceMemberships.workspaceId), + "page query filters by workspace membership workspace id (tenant isolation)" + ); + assert.ok( + queryReferences(pageQuery.where, workspaceMemberships.deletedAt), + "page query excludes soft-deleted memberships" + ); + assert.ok( + queryReferences(pageQuery.where, users.deletedAt), + "page query excludes soft-deleted users" + ); + assert.ok(queryParamValues(pageQuery.where).includes(WS), "page query binds the workspace id"); + assert.ok(pageQuery.steps.includes("orderBy"), "page query orders for a stable pagination sort"); + assert.equal(pageQuery.limit, 2, "page query applies the requested page size"); + assert.equal(pageQuery.offset, 4, "page query applies the requested offset"); +}); + +test("roster page size is never hard-capped at 200 (reachable past the /api/users truncation)", async () => { + const { db, queries } = createQueryRecorder([[{ total: 250 }], []]); + const repo = createWorkspaceMembershipRepository(db); + const listRoster = repo.listActiveRosterPageByWorkspace; + assert.ok(listRoster, "concrete repository implements the optional roster method"); + + // 请求一个远超 200 的 limit + 深 offset:若仓库像 users.listActiveRefs 那样硬 .limit(200) 就会在这里现形。 + const result = await listRoster(WS, { limit: 500, offset: 400 }); + + assert.equal(result.total, 250, "total reflects the workspace count, not a 200 cap"); + const pageQuery = queries[1]; + assert.ok(pageQuery, "page query recorded"); + assert.equal(pageQuery.limit, 500, "repository does not clamp the page size to 200"); + assert.equal(pageQuery.offset, 400, "deep offsets pass through so every member is reachable by paging"); +}); + +test("roster page defensively floors illegal limit/offset", async () => { + const { db, queries } = createQueryRecorder([[{ total: 0 }], []]); + const repo = createWorkspaceMembershipRepository(db); + const listRoster = repo.listActiveRosterPageByWorkspace; + assert.ok(listRoster, "concrete repository implements the optional roster method"); + + await listRoster(WS, { limit: -3, offset: -10 }); + + const pageQuery = queries[1]; + assert.ok(pageQuery, "page query recorded"); + assert.equal(pageQuery.limit, 1, "non-positive limit floors to 1"); + assert.equal(pageQuery.offset, 0, "negative offset floors to 0"); +}); diff --git a/packages/db/src/r20-project-ops-repos.test.ts b/packages/db/src/r20-project-ops-repos.test.ts new file mode 100644 index 00000000..dbbc7695 --- /dev/null +++ b/packages/db/src/r20-project-ops-repos.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createProjectRepository } from "./repositories/projects.js"; +import { createWorkItemAssignmentRepository } from "./repositories/work-item-assignments.js"; +import { createCommentRepository, COMMENTS_FOR_WORK_ITEM_LIMIT } from "./repositories/comments.js"; +import { + createWorkspaceAuditLogRepository, + WORKSPACE_AUDIT_LOGS_MAX_LIMIT +} from "./repositories/audit.js"; +import { auditLogs, comments, projects, workItemAssignments } from "./schema/index.js"; +import { + createQueryRecorder, + queryParamValues, + queryReferences +} from "./test-query-recorder.js"; + +const at = new Date("2026-07-15T00:00:00.000Z"); + +// R20 P2A(R19-19 项目归档):archiveProject 只写 archived=true 且 CAS 只命中活跃/未删行。 +test("archiveProject soft-sets archived under an active-project CAS guard", async () => { + const row = { id: "proj-1", archived: true } as unknown; + const { db, queries } = createQueryRecorder([[row]]); + const repo = createProjectRepository(db); + + const result = await repo.archiveProject({ projectId: "proj-1", now: at }); + + assert.equal(result, row); + const [query] = queries; + assert.equal(query?.operation, "update"); + assert.equal(query?.targetTable, projects); + assert.equal(query?.returningCalled, true); + // CAS:WHERE 引用 archived + deletedAt(只命中未归档、未软删的行)。 + assert.ok(queryReferences(query?.where, projects.id)); + assert.ok(queryReferences(query?.where, projects.archived)); + assert.ok(queryReferences(query?.where, projects.deletedAt)); + assert.ok(queryParamValues(query?.where).includes("proj-1")); +}); + +test("archiveProject returns null when the CAS guard matches no active row", async () => { + const { db } = createQueryRecorder([[]]); + const repo = createProjectRepository(db); + assert.equal(await repo.archiveProject({ projectId: "proj-x", now: at }), null); +}); + +// R20 P2A(R19-19 软删):softDeleteProject 写 deletedAt + deletedByNickname,CAS 只命中未软删的行。 +test("softDeleteProject tombstones under a not-deleted CAS guard and records the deleter", async () => { + const row = { id: "proj-1", deletedAt: at } as unknown; + const { db, queries } = createQueryRecorder([[row]]); + const repo = createProjectRepository(db); + + const result = await repo.softDeleteProject({ projectId: "proj-1", deletedByNickname: "admin", now: at }); + + assert.equal(result, row); + const [query] = queries; + assert.equal(query?.operation, "update"); + assert.equal(query?.targetTable, projects); + assert.ok(queryReferences(query?.where, projects.deletedAt)); + assert.ok(queryParamValues(query?.where).includes("proj-1")); +}); + +// R20 P2A(R19-18 指派):assignWorkItem upsert,onConflict 目标是 (workItemId, userId) 唯一索引。 +test("assignWorkItem upserts on the (work_item, user) unique index", async () => { + const row = { + id: "assign-1", + workItemId: "wi-1", + userId: "u-2", + role: "collaborator", + assignedByUserId: "u-1" + } as unknown; + const { db, queries } = createQueryRecorder([[row]]); + const repo = createWorkItemAssignmentRepository(db); + + const result = await repo.assignWorkItem({ + workItemId: "wi-1", + userId: "u-2", + role: "collaborator", + assignedByUserId: "u-1", + at + }); + + assert.equal(result, row); + const [query] = queries; + assert.equal(query?.operation, "insert"); + assert.equal(query?.targetTable, workItemAssignments); + assert.equal(query?.returningCalled, true); + const conflict = query?.onConflict as { target?: unknown[]; set?: Record } | undefined; + assert.deepEqual(conflict?.target, [workItemAssignments.workItemId, workItemAssignments.userId]); + assert.ok(conflict?.set && "role" in conflict.set); +}); + +// R20 P2A(R19-22 评论):读按 createdAt 升序 + 上限;写 INSERT ... RETURNING。 +test("listCommentsForWorkItem reads the thread ascending with a bounded default limit", async () => { + const rows = [{ id: "c-1" }, { id: "c-2" }] as unknown[]; + const { db, queries } = createQueryRecorder([rows]); + const repo = createCommentRepository(db); + + const result = await repo.listCommentsForWorkItem("wi-1"); + + assert.deepEqual(result, rows); + const [query] = queries; + assert.equal(query?.fromTable, comments); + assert.ok(queryReferences(query?.where, comments.workItemId)); + assert.ok(queryParamValues(query?.where).includes("wi-1")); + assert.equal(query?.limit, COMMENTS_FOR_WORK_ITEM_LIMIT); + assert.ok((query?.orderBy.length ?? 0) > 0); +}); + +test("insertComment writes a comment row and returns it", async () => { + const row = { id: "c-1", workItemId: "wi-1", authorNickname: "ann", body: "hi" } as unknown; + const { db, queries } = createQueryRecorder([[row]]); + const repo = createCommentRepository(db); + + const result = await repo.insertComment({ workItemId: "wi-1", authorNickname: "ann", body: "hi", at }); + + assert.equal(result, row); + const [query] = queries; + assert.equal(query?.operation, "insert"); + assert.equal(query?.targetTable, comments); + assert.equal(query?.returningCalled, true); +}); + +// R20 P2A(R19-21 工作区审计):按 workspace 过滤 + 可选过滤 + 时间倒序 + 分页(limit/offset)。 +test("listAuditLogsForWorkspace filters by workspace, applies filters, and paginates newest-first", async () => { + const rows = [{ id: "a-1" }] as unknown[]; + const { db, queries } = createQueryRecorder([rows]); + const repo = createWorkspaceAuditLogRepository(db); + + const from = new Date("2026-07-01T00:00:00.000Z"); + const to = new Date("2026-07-15T00:00:00.000Z"); + const result = await repo.listAuditLogsForWorkspace({ + workspaceId: "ws-1", + actorUserId: "u-9", + action: "snapshot.reverted", + from, + to, + limit: 25, + offset: 50 + }); + + assert.deepEqual(result, rows); + const [query] = queries; + assert.equal(query?.fromTable, auditLogs); + assert.ok(queryReferences(query?.where, auditLogs.workspaceId)); + assert.ok(queryReferences(query?.where, auditLogs.actorUserId)); + assert.ok(queryReferences(query?.where, auditLogs.action)); + assert.ok(queryReferences(query?.where, auditLogs.createdAt)); + assert.ok(queryParamValues(query?.where).includes("ws-1")); + assert.ok(queryParamValues(query?.where).includes("u-9")); + assert.ok(queryParamValues(query?.where).includes("snapshot.reverted")); + assert.equal(query?.limit, 25); + assert.equal(query?.offset, 50); + assert.ok((query?.orderBy.length ?? 0) > 0); +}); + +test("listAuditLogsForWorkspace clamps limit to the max and defaults offset to 0", async () => { + const { db, queries } = createQueryRecorder([[]]); + const repo = createWorkspaceAuditLogRepository(db); + + await repo.listAuditLogsForWorkspace({ workspaceId: "ws-1", limit: 999 }); + + const [query] = queries; + assert.equal(query?.limit, WORKSPACE_AUDIT_LOGS_MAX_LIMIT); + assert.equal(query?.offset, 0); + // 只按 workspace 过滤时 WHERE 不应引用 actor/action/createdAt。 + assert.ok(!queryReferences(query?.where, auditLogs.actorUserId)); + assert.ok(!queryReferences(query?.where, auditLogs.action)); +}); diff --git a/packages/db/src/repositories/audit.ts b/packages/db/src/repositories/audit.ts index 17430f19..587015b4 100644 --- a/packages/db/src/repositories/audit.ts +++ b/packages/db/src/repositories/audit.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { and, desc, eq, isNull, or, sql, type SQL } from "drizzle-orm"; +import { and, desc, eq, gte, isNull, lte, or, sql, type SQL } from "drizzle-orm"; import type { ActorKind } from "@workhub/contracts"; @@ -56,6 +56,56 @@ export type AuditLogRepository = { markAuditLogUndone: (id: string, at: Date) => Promise; }; +// R20 P2A(R19-21 工作区审计列表):跨工作项的审计流读口(仅管理员用)——按 workspace 硬隔离 + +// 可选操作者/动作/时间范围过滤 + 分页,时间倒序。单列在自有类型/工厂(不塞进 AuditLogRepository), +// 保持纯 additive:不牵动 middleware/auth、agent-runner 等一堆现有 AuditLogRepository 实现/fake。 +export const WORKSPACE_AUDIT_LOGS_DEFAULT_LIMIT = 50; +export const WORKSPACE_AUDIT_LOGS_MAX_LIMIT = 200; + +export type WorkspaceAuditLogFilter = { + workspaceId: string; + actorUserId?: string | undefined; + action?: string | undefined; + from?: Date | undefined; + to?: Date | undefined; + limit?: number | undefined; + offset?: number | undefined; +}; + +export type WorkspaceAuditLogRepository = { + listAuditLogsForWorkspace: (filter: WorkspaceAuditLogFilter) => Promise; +}; + +export function createWorkspaceAuditLogRepository(db: WorkHubDb): WorkspaceAuditLogRepository { + return { + async listAuditLogsForWorkspace(filter) { + const conditions: SQL[] = [eq(auditLogs.workspaceId, filter.workspaceId)]; + if (filter.actorUserId) { + conditions.push(eq(auditLogs.actorUserId, filter.actorUserId)); + } + if (filter.action) { + conditions.push(eq(auditLogs.action, filter.action)); + } + if (filter.from) { + conditions.push(gte(auditLogs.createdAt, filter.from)); + } + if (filter.to) { + conditions.push(lte(auditLogs.createdAt, filter.to)); + } + const rawLimit = filter.limit ?? WORKSPACE_AUDIT_LOGS_DEFAULT_LIMIT; + const limit = Math.min(Math.max(rawLimit, 1), WORKSPACE_AUDIT_LOGS_MAX_LIMIT); + const offset = Math.max(filter.offset ?? 0, 0); + return db + .select() + .from(auditLogs) + .where(and(...conditions)) + .orderBy(desc(auditLogs.createdAt)) + .limit(limit) + .offset(offset); + } + }; +} + export function createSnapshotRepository(db: WorkHubDb): SnapshotRepository { return { async createSnapshot(input) { diff --git a/packages/db/src/repositories/comments.ts b/packages/db/src/repositories/comments.ts new file mode 100644 index 00000000..bfda9e8c --- /dev/null +++ b/packages/db/src/repositories/comments.ts @@ -0,0 +1,59 @@ +import { randomUUID } from "node:crypto"; + +import { asc, eq } from "drizzle-orm"; + +import type { WorkHubDb } from "../client.js"; +import { comments } from "../schema/index.js"; + +export type CommentRow = typeof comments.$inferSelect; + +export type InsertCommentInput = { + id?: string; + workItemId: string; + authorNickname: string; + body: string; + at?: Date; +}; + +// R20 P2A(R19-22 工作项评论):通用 comments 表(work_item_id + author_nickname + body)全库此前零读写, +// 补上读/写两口。评论流是「一个工作项下的讨论」,按 createdAt 升序(对话顺序)。 +export const COMMENTS_FOR_WORK_ITEM_LIMIT = 200; + +export type CommentRepository = { + listCommentsForWorkItem: (workItemId: string, options?: { limit?: number }) => Promise; + insertComment: (input: InsertCommentInput) => Promise; +}; + +export function createCommentRepository(db: WorkHubDb): CommentRepository { + return { + async listCommentsForWorkItem(workItemId, options = {}) { + const limit = options.limit ?? COMMENTS_FOR_WORK_ITEM_LIMIT; + return db + .select() + .from(comments) + .where(eq(comments.workItemId, workItemId)) + .orderBy(asc(comments.createdAt)) + .limit(limit); + }, + + async insertComment(input) { + const at = input.at ?? new Date(); + const rows = await db + .insert(comments) + .values({ + id: input.id ?? randomUUID(), + workItemId: input.workItemId, + authorNickname: input.authorNickname, + body: input.body, + createdAt: at, + updatedAt: at + }) + .returning(); + const row = rows[0]; + if (!row) { + throw new Error("Failed to insert work item comment"); + } + return row; + } + }; +} diff --git a/packages/db/src/repositories/memberships.ts b/packages/db/src/repositories/memberships.ts index 33b59652..fb5ff344 100644 --- a/packages/db/src/repositories/memberships.ts +++ b/packages/db/src/repositories/memberships.ts @@ -20,6 +20,23 @@ export type WorkspaceMemberWithNickname = { joinedAt: Date; }; +// R20 P2A(P1-08 修复 · workspace-scoped roster):分页花名册的一行。较 WorkspaceMemberWithNickname 多带 +// 头像时间戳占位(avatarUpdatedAt 非空=有头像,兼作头像端点缓存键;只取时间戳、不带 bytea 二进制)。 +export type WorkspaceRosterMember = { + userId: string; + nickname: string; + role: MembershipRole; + joinedAt: Date; + avatarUpdatedAt: Date | null; +}; + +// R20 P2A:一页花名册 + 工作区 active 成员总数(total 供客户端翻页与「成员数」显示,修 /api/users 全局 +// 计数封顶 200 的「计数错」)。 +export type WorkspaceRosterPage = { + members: WorkspaceRosterMember[]; + total: number; +}; + // actor 租户 = 默认成员行的工作区 + 其 org(workspaces.org_id 派生,本 epic 无独立 org_memberships)。 export type ResolvedTenant = { workspaceId: string; @@ -56,6 +73,18 @@ export type WorkspaceMembershipRepository = { /** R18 批 H1(成员清单):某工作区全部 active 成员 + 昵称(join users),按昵称字典序——供 web 成员分区 * roster。成员规模对内部团队远低于任何上限,无需分页。 */ listActiveWithNicknameByWorkspace: (workspaceId: string) => Promise; + /** R20 P2A(P1-08 修复 · workspace-scoped roster):按 workspaceId join membership+users 分页列本工作区 + * active 成员(limit/offset),并回工作区 active 成员总数。取代消费端误用的全局 /api/users: + * - 工作区隔离:WHERE 绑定 workspaceId + 排除 membership/user 双软删,杜绝跨租户泄露; + * - 无硬上限:不再像 users.listActiveRefs 那样硬 .limit(200) 截断,任意 offset 可翻至全量成员; + * - 稳定序:按 lower(nickname), user id 排序,翻页无重复/漏项。 + * 只取头像时间戳(非 bytea),避免把二进制带进列表查询。 + * OPTIONAL:与 UserRepository 的 listActiveRefs 等同范式——标可选以免逼各处内存假仓库都实现(生产 + * createWorkspaceMembershipRepository 恒实现;roster 端点的默认读者会做存在性兜底)。 */ + listActiveRosterPageByWorkspace?: ( + workspaceId: string, + page: { limit: number; offset: number } + ) => Promise; /** R17 批 G1(#15 角色变更):更新一条 active 成员行的角色(幂等:改到同值也照常前进 updated_at)。 */ updateRole: (id: string, role: MembershipRole, at: Date) => Promise; }; @@ -202,6 +231,48 @@ export function createWorkspaceMembershipRepository(db: WorkHubDb): WorkspaceMem })); }, + async listActiveRosterPageByWorkspace(workspaceId, page) { + // 防御性夹取:真实上下限由调用方(契约 limit 1..100)保证,这里只兜非法值。刻意不设 200 上限—— + // offset 可翻至任意深度,全量成员皆可达,这正是对 users.listActiveRefs 硬 .limit(200) 截断的修复。 + const limit = Math.max(1, Math.floor(page.limit)); + const offset = Math.max(0, Math.floor(page.offset)); + // 工作区隔离 + 双软删排除:一条 WHERE 复用于计数与取页,确保两者同一口径(total 与 members 不打架)。 + const scope = and( + eq(workspaceMemberships.workspaceId, workspaceId), + isNull(workspaceMemberships.deletedAt), + isNull(users.deletedAt) + ); + const countRows = await db + .select({ total: sql`count(*)::int` }) + .from(workspaceMemberships) + .innerJoin(users, eq(users.id, workspaceMemberships.userId)) + .where(scope); + const rows = await db + .select({ + userId: workspaceMemberships.userId, + nickname: users.nickname, + role: workspaceMemberships.role, + joinedAt: workspaceMemberships.createdAt, + avatarUpdatedAt: users.avatarUpdatedAt + }) + .from(workspaceMemberships) + .innerJoin(users, eq(users.id, workspaceMemberships.userId)) + .where(scope) + .orderBy(asc(sql`lower(${users.nickname})`), asc(users.id)) + .limit(limit) + .offset(offset); + return { + total: countRows[0]?.total ?? 0, + members: rows.map((row) => ({ + userId: row.userId, + nickname: row.nickname, + role: row.role as MembershipRole, + joinedAt: row.joinedAt, + avatarUpdatedAt: row.avatarUpdatedAt + })) + }; + }, + async updateRole(id, role, at) { const rows = await db .update(workspaceMemberships) diff --git a/packages/db/src/repositories/projects.ts b/packages/db/src/repositories/projects.ts index cfea139d..255ad7cc 100644 --- a/packages/db/src/repositories/projects.ts +++ b/packages/db/src/repositories/projects.ts @@ -75,6 +75,20 @@ export type UpdateProjectInstructionsInput = { now?: Date; }; +// R20 P2A(R19-19 项目归档/删除):软置 archived=true——仅命中「当前活跃」(未归档、未软删)的项目行, +// 已归档/已删返回 null(幂等且不复活墓碑)。列表读路径的 WHERE 已过滤 archived=true,故无需改读。 +export type ArchiveProjectInput = { + projectId: string; + now?: Date; +}; + +// R20 P2A:软删——软置 deletedAt(墓碑),可选记录删除者昵称。仅命中未软删的行,已删返回 null。 +export type SoftDeleteProjectInput = { + projectId: string; + deletedByNickname?: string | null; + now?: Date; +}; + export class ProjectSlugOccupiedError extends Error { constructor(public readonly slug: string) { super("Project slug is occupied by an archived or deleted project in this workspace"); @@ -90,6 +104,9 @@ export type ProjectRepository = { listPersonalForUser: (input: ListPersonalProjectsInput) => Promise; // R16 批 W4a:写路径——返回更新后的完整行(含 instructionsMd),未命中活跃项目返回 null。 updateInstructions: (input: UpdateProjectInstructionsInput) => Promise; + // R20 P2A(R19-19):软归档/软删——CAS 只命中活跃/未删行,未命中返回 null(不复活墓碑)。 + archiveProject: (input: ArchiveProjectInput) => Promise; + softDeleteProject: (input: SoftDeleteProjectInput) => Promise; }; async function ensureActiveMain( @@ -370,6 +387,43 @@ export function createProjectRepository(db: WorkHubDb): ProjectRepository { ) .returning(); return row ?? null; + }, + + async archiveProject(input) { + const at = input.now ?? new Date(); + const [row] = await db + .update(projects) + .set({ archived: true, updatedAt: at }) + // CAS:只归档「当前活跃」的项目——已归档/已软删不再命中(幂等,返回 null)。 + .where( + and( + eq(projects.id, input.projectId), + eq(projects.archived, false), + isNull(projects.deletedAt) + ) + ) + .returning(); + return row ?? null; + }, + + async softDeleteProject(input) { + const at = input.now ?? new Date(); + const [row] = await db + .update(projects) + .set({ + deletedAt: at, + ...(input.deletedByNickname !== undefined ? { deletedByNickname: input.deletedByNickname } : {}), + updatedAt: at + }) + // CAS:只软删未删的行——已软删不再命中(幂等,返回 null)。可对已归档项目再软删。 + .where( + and( + eq(projects.id, input.projectId), + isNull(projects.deletedAt) + ) + ) + .returning(); + return row ?? null; } }; } diff --git a/packages/db/src/repositories/work-item-assignments.ts b/packages/db/src/repositories/work-item-assignments.ts new file mode 100644 index 00000000..29e08945 --- /dev/null +++ b/packages/db/src/repositories/work-item-assignments.ts @@ -0,0 +1,68 @@ +import { randomUUID } from "node:crypto"; + +import { asc, eq } from "drizzle-orm"; + +import type { WorkHubDb } from "../client.js"; +import { workItemAssignments } from "../schema/index.js"; + +export type WorkItemAssignmentRow = typeof workItemAssignments.$inferSelect; + +export type AssignWorkItemInput = { + workItemId: string; + userId: string; + role: "lead" | "collaborator"; + assignedByUserId: string; + at?: Date; +}; + +// R20 P2A(R19-18 指派/认领):把工作项显式指派给某成员,落一行 work_item_assignments。生产此前对 +// 这张表零 INSERT(唯一写口是找人卡的 claimedByUserId,不写 assignments 行)。单列在自有仓库里—— +// 只服务人发起的指派端点,不掺进 WorkItemDataRepository 的大接口(那会牵动一堆 fake 实现)。 +export type WorkItemAssignmentRepository = { + // upsert 语义:同一 (workItemId, userId) 再次指派=改角色(命中唯一索引 work_item_assignments_item_user_uq), + // 不新增重复行;assignedByUserId/updatedAt 一并刷新。返回落地后的整行。 + assignWorkItem: (input: AssignWorkItemInput) => Promise; + listAssignmentsForWorkItem: (workItemId: string) => Promise; +}; + +export function createWorkItemAssignmentRepository(db: WorkHubDb): WorkItemAssignmentRepository { + return { + async assignWorkItem(input) { + const at = input.at ?? new Date(); + const rows = await db + .insert(workItemAssignments) + .values({ + id: randomUUID(), + workItemId: input.workItemId, + userId: input.userId, + role: input.role, + assignedByUserId: input.assignedByUserId, + createdAt: at, + updatedAt: at + }) + // 已有 (workItemId, userId) 指派行时改角色(不新增重复行)。 + .onConflictDoUpdate({ + target: [workItemAssignments.workItemId, workItemAssignments.userId], + set: { + role: input.role, + assignedByUserId: input.assignedByUserId, + updatedAt: at + } + }) + .returning(); + const row = rows[0]; + if (!row) { + throw new Error("Failed to write work item assignment"); + } + return row; + }, + + async listAssignmentsForWorkItem(workItemId) { + return db + .select() + .from(workItemAssignments) + .where(eq(workItemAssignments.workItemId, workItemId)) + .orderBy(asc(workItemAssignments.createdAt)); + } + }; +} diff --git a/packages/db/src/test-query-recorder.ts b/packages/db/src/test-query-recorder.ts index 3b1115bc..5e4363ec 100644 --- a/packages/db/src/test-query-recorder.ts +++ b/packages/db/src/test-query-recorder.ts @@ -14,6 +14,9 @@ export type RecordedQuery = { // 假 DB 之前没有任何调用方用到过 having,补一个和 groupBy/orderBy 同档次的透传记录字段。 having?: unknown; limit?: number; + // R20 P2A(roster 与工作区审计分页):listActiveRosterPageByWorkspace 与审计列表都用 .limit().offset() 翻页。 + // 这个假 DB 之前无调用方用过 offset,补一个与 limit 同档次的透传记录字段(校验分页而非硬 200 截断)。 + offset?: number; lock?: string; alias?: string; setValue?: unknown; @@ -90,6 +93,12 @@ class RecordedQueryBuilder implements PromiseLike { return this; } + offset(count: number): this { + this.query.offset = count; + this.query.steps.push("offset"); + return this; + } + for(mode: string): this { this.query.lock = mode; this.query.steps.push("for");