feat(web): persist exclusive reviewer duty on project create

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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 21:57:53 +00:00
co-authored by Cursor
parent 6a68e04f1f
commit c1bdfe3920
10 changed files with 147 additions and 9 deletions
+3
View File
@@ -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;
}
@@ -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(
[],
@@ -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<CreateProjectModalProps> = ({
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) => {
@@ -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: '',
});
});
@@ -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),
};
}
+24
View File
@@ -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({
+33
View File
@@ -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');
});
+45
View File
@@ -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<T extends { role: ProjectMemberRole }>(
selected: Record<number, T>,
userId: number,
role: ProjectMemberRole,
): Record<number, T> {
const next: Record<number, T> = { ...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;
}
+2 -1
View File
@@ -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');
});
+5 -1
View File
@@ -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,