From c1bdfe39203fbbf6655ea80b4515d128cf23985b Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 21:57:53 +0000 Subject: [PATCH] feat(web): persist exclusive reviewer duty on project create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep project_role as member/viewer, send document_duty=reviewer for USER_B, and clear the seed 李主管 reviewer so the assigned reviewer is the one that syncs. Co-authored-by: Cursor --- web/src/api/projectMembers.ts | 3 ++ .../components/chat/deliverableSync.test.ts | 29 ++++++++++++ .../components/project/CreateProjectModal.tsx | 9 +--- .../lib/createProjectMembersPersist.test.ts | 2 + web/src/lib/createProjectMembersPersist.ts | 2 + web/src/lib/currentDocRole.test.ts | 24 ++++++++++ web/src/lib/projectDocumentDuty.test.ts | 33 ++++++++++++++ web/src/lib/projectDocumentDuty.ts | 45 +++++++++++++++++++ web/src/lib/projectMemberAdapter.test.ts | 3 +- web/src/lib/projectMemberAdapter.ts | 6 ++- 10 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/projectDocumentDuty.test.ts create mode 100644 web/src/lib/projectDocumentDuty.ts diff --git a/web/src/api/projectMembers.ts b/web/src/api/projectMembers.ts index ff98daf1..d7fc98c4 100644 --- a/web/src/api/projectMembers.ts +++ b/web/src/api/projectMembers.ts @@ -2,6 +2,7 @@ import { getBearerAuthHeaders } from './authHeaders'; export type ApiProjectMemberRole = 'owner' | 'member' | 'viewer'; export type ApiProjectMemberStatus = 'active' | 'exited'; +export type ApiDocumentDuty = 'author' | 'reviewer' | 'approver' | ''; export interface ApiProjectMemberRecord { id?: number; @@ -10,6 +11,7 @@ export interface ApiProjectMemberRecord { username?: string; name?: string; project_role: ApiProjectMemberRole; + document_duty?: ApiDocumentDuty; status: ApiProjectMemberStatus; joined_at?: string; } @@ -21,6 +23,7 @@ export interface ListProjectMembersResponse { export interface UpsertProjectMemberPayload { user_id: number; project_role: ApiProjectMemberRole; + document_duty?: ApiDocumentDuty; status?: ApiProjectMemberStatus; } diff --git a/web/src/components/chat/deliverableSync.test.ts b/web/src/components/chat/deliverableSync.test.ts index ba40a4c2..e983bdc9 100644 --- a/web/src/components/chat/deliverableSync.test.ts +++ b/web/src/components/chat/deliverableSync.test.ts @@ -102,6 +102,35 @@ describe('mergeWorkflowDocumentsFromServer', () => { expect(asA[0].dutiesAuthoritative).toBe(true); }); + it('maps server reviewer_id for USER_B and does not invent reviewer from the login name', () => { + const userB = { id: 171, name: '审核人B-mu0bvnp8' }; + const merged = mergeWorkflowDocumentsFromServer( + [], + [ + { + doc_id: 'doc_proj6145_intake', + project_id: 6145, + task_key: 'intake', + title: '用户初始需求收集表', + lifecycle_state: 'drafting', + is_template_only: false, + author_id: 170, + author_name: '编制人A', + reviewer_id: 171, + reviewer_name: '审核人B-mu0bvnp8', + approver_id: 0, + approver_name: '', + }, + ], + 6145, + userB, + ); + expect(merged[0].reviewerId).toBe(171); + expect(merged[0].authorId).toBe(170); + expect(merged[0].authorId).not.toBe(userB.id); + expect(merged[0].reviewer).toBe('审核人B-mu0bvnp8'); + }); + it('marks server controlled docs authoritative even when duty ids are 0', () => { const merged = mergeWorkflowDocumentsFromServer( [], diff --git a/web/src/components/project/CreateProjectModal.tsx b/web/src/components/project/CreateProjectModal.tsx index d1934bcc..f04c1428 100644 --- a/web/src/components/project/CreateProjectModal.tsx +++ b/web/src/components/project/CreateProjectModal.tsx @@ -7,6 +7,7 @@ import { shouldBlockCreateProjectClose, type CreateProjectSubmitPhase, } from '../../lib/createProjectSubmitGuard'; +import { exclusiveMemberRoleChange } from '../../lib/projectDocumentDuty'; interface CreateProjectModalProps { isOpen: boolean; @@ -101,13 +102,7 @@ export const CreateProjectModal: React.FC = ({ const handleChangeRole = (userId: number, role: ProjectMemberRole) => { if (submitting) return; - setSelectedMembers((prev) => ({ - ...prev, - [userId]: { - ...prev[userId], - role, - }, - })); + setSelectedMembers((prev) => exclusiveMemberRoleChange(prev, userId, role)); }; const handleConfirm = (e: React.FormEvent) => { diff --git a/web/src/lib/createProjectMembersPersist.test.ts b/web/src/lib/createProjectMembersPersist.test.ts index 4c345e16..78f1b85d 100644 --- a/web/src/lib/createProjectMembersPersist.test.ts +++ b/web/src/lib/createProjectMembersPersist.test.ts @@ -25,10 +25,12 @@ test('buildInitialMemberUpsertPayload maps reviewer UI role to member API role', assert.deepEqual(buildInitialMemberUpsertPayload({ userId: 99, userName: 'B', role: 'reviewer' }, 86), { user_id: 99, project_role: 'member', + document_duty: 'reviewer', }); assert.deepEqual(buildInitialMemberUpsertPayload({ userId: 99, userName: 'B', role: 'viewer' }, 86), { user_id: 99, project_role: 'viewer', + document_duty: '', }); }); diff --git a/web/src/lib/createProjectMembersPersist.ts b/web/src/lib/createProjectMembersPersist.ts index 594b027e..ff918702 100644 --- a/web/src/lib/createProjectMembersPersist.ts +++ b/web/src/lib/createProjectMembersPersist.ts @@ -5,6 +5,7 @@ import type { UpsertProjectMemberPayload } from '../api/projectMembers'; import { toApiProjectRole } from './projectMemberAdapter'; +import { documentDutyFromMemberRole } from './projectDocumentDuty'; import type { ProjectMemberRole } from '../types/workbench'; export type InitialProjectMemberInput = { @@ -29,6 +30,7 @@ export function buildInitialMemberUpsertPayload( return { user_id: member.userId, project_role: toApiProjectRole(member.role, member.userId, creatorUserId), + document_duty: documentDutyFromMemberRole(member.role), }; } diff --git a/web/src/lib/currentDocRole.test.ts b/web/src/lib/currentDocRole.test.ts index 7b53e81e..c8b18cc6 100644 --- a/web/src/lib/currentDocRole.test.ts +++ b/web/src/lib/currentDocRole.test.ts @@ -25,6 +25,30 @@ describe('resolveCurrentDocRole', () => { assert.equal(resolveCurrentDocRole({ currentUser: stranger, ...duties }), 'viewer'); }); + it('resolves real USER_B id as reviewer and never name-promotes placeholder 2', () => { + const userB = { id: 171, name: '审核人B' }; + assert.equal( + resolveCurrentDocRole({ + currentUser: userB, + authorId: 170, + reviewerId: 171, + approverId: 4, + governanceMode: 'controlled', + }), + 'reviewer', + ); + assert.equal( + resolveCurrentDocRole({ + currentUser: userB, + authorId: 170, + reviewerId: 2, + reviewer: '审核人B', + governanceMode: 'controlled', + }), + 'viewer', + ); + }); + it('fail-closes unknown identity to viewer even if projectPermission says author', () => { assert.equal( resolveCurrentDocRole({ diff --git a/web/src/lib/projectDocumentDuty.test.ts b/web/src/lib/projectDocumentDuty.test.ts new file mode 100644 index 00000000..298d1f6d --- /dev/null +++ b/web/src/lib/projectDocumentDuty.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + documentDutyFromMemberRole, + exclusiveMemberRoleChange, + memberRoleFromDocumentDuty, +} from './projectDocumentDuty'; + +test('documentDutyFromMemberRole maps UI reviewer and creator', () => { + assert.equal(documentDutyFromMemberRole('reviewer'), 'reviewer'); + assert.equal(documentDutyFromMemberRole('creator'), 'author'); + assert.equal(documentDutyFromMemberRole('viewer'), ''); + assert.equal(documentDutyFromMemberRole('member'), ''); +}); + +test('exclusiveMemberRoleChange keeps a single reviewer', () => { + const next = exclusiveMemberRoleChange( + { + 3: { selected: true, role: 'reviewer' }, + 171: { selected: true, role: 'viewer' }, + }, + 171, + 'reviewer', + ); + assert.equal(next[171].role, 'reviewer'); + assert.equal(next[3].role, 'viewer'); +}); + +test('memberRoleFromDocumentDuty does not invent reviewer from access role', () => { + assert.equal(memberRoleFromDocumentDuty('reviewer', 'member'), 'reviewer'); + assert.equal(memberRoleFromDocumentDuty('', 'member'), 'member'); + assert.equal(memberRoleFromDocumentDuty(undefined, 'viewer'), 'viewer'); +}); diff --git a/web/src/lib/projectDocumentDuty.ts b/web/src/lib/projectDocumentDuty.ts new file mode 100644 index 00000000..4a52013d --- /dev/null +++ b/web/src/lib/projectDocumentDuty.ts @@ -0,0 +1,45 @@ +import type { ProjectMemberRole } from '../types/workbench'; + +export type DocumentDuty = 'author' | 'reviewer' | 'approver' | ''; + +const DUTY_ROLES: DocumentDuty[] = ['author', 'reviewer', 'approver']; + +export function documentDutyFromMemberRole(role: ProjectMemberRole): DocumentDuty { + if (role === 'author' || role === 'reviewer' || role === 'approver') { + return role; + } + if (role === 'creator' || role === 'owner') { + return 'author'; + } + return ''; +} + +export function memberRoleFromDocumentDuty( + documentDuty: string | undefined, + fallback: ProjectMemberRole, +): ProjectMemberRole { + if (documentDuty === 'author' || documentDuty === 'reviewer' || documentDuty === 'approver') { + return documentDuty; + } + return fallback; +} + +export function exclusiveMemberRoleChange( + selected: Record, + userId: number, + role: ProjectMemberRole, +): Record { + const next: Record = { ...selected, [userId]: { ...selected[userId], role } }; + const duty = documentDutyFromMemberRole(role); + if (!DUTY_ROLES.includes(duty)) { + return next; + } + for (const key of Object.keys(next)) { + const id = Number(key); + if (id === userId) continue; + if (documentDutyFromMemberRole(next[id].role) === duty) { + next[id] = { ...next[id], role: 'viewer' }; + } + } + return next; +} diff --git a/web/src/lib/projectMemberAdapter.test.ts b/web/src/lib/projectMemberAdapter.test.ts index d4e95357..6195e273 100644 --- a/web/src/lib/projectMemberAdapter.test.ts +++ b/web/src/lib/projectMemberAdapter.test.ts @@ -27,6 +27,7 @@ test('mapApiMemberToProjectMember builds UI record', () => { name: '张工', username: 'zhang', project_role: 'member', + document_duty: 'reviewer', status: 'active', joined_at: '2026-09-12 10:00:00', }, @@ -34,6 +35,6 @@ test('mapApiMemberToProjectMember builds UI record', () => { ); assert.equal(mapped.userId, 2); assert.equal(mapped.userName, '张工'); - assert.equal(mapped.role, 'member'); + assert.equal(mapped.role, 'reviewer'); assert.equal(mapped.status, 'active'); }); diff --git a/web/src/lib/projectMemberAdapter.ts b/web/src/lib/projectMemberAdapter.ts index 4944807a..7e5fd1ec 100644 --- a/web/src/lib/projectMemberAdapter.ts +++ b/web/src/lib/projectMemberAdapter.ts @@ -1,5 +1,6 @@ import { ApiProjectMemberRecord, ApiProjectMemberRole } from '../api/projectMembers'; import { ProjectMember, ProjectMemberRole } from '../types/workbench'; +import { memberRoleFromDocumentDuty } from './projectDocumentDuty'; /** UI 角色 → 服务端 project_role(禁止 author/reviewer/approver)。 */ export function toApiProjectRole( @@ -39,7 +40,10 @@ export function mapApiMemberToProjectMember( return { userId: rec.user_id, userName: rec.name?.trim() || rec.username?.trim() || `用户 #${rec.user_id}`, - role: fromApiProjectRole(rec.project_role, rec.user_id, creatorId), + role: memberRoleFromDocumentDuty( + rec.document_duty, + fromApiProjectRole(rec.project_role, rec.user_id, creatorId), + ), status: rec.status === 'exited' ? 'exited' : 'active', joinedAt: rec.joined_at || new Date().toLocaleString(), exitedAt: rec.status === 'exited' ? rec.joined_at : undefined,