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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user