fix(web): send document_duty on member add, update, and restore

Post-create role changes were silently dropping reviewer/approver
because only project_role was persisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 22:16:44 +00:00
co-authored by Cursor
parent 4512132b20
commit 7ef34a685d
3 changed files with 73 additions and 26 deletions
+27 -22
View File
@@ -34,17 +34,17 @@ import {
removeProjectMember,
updateProjectMember,
} from './api/projectMembers';
import { ensureCreatedProjectWithMembers } from './lib/createProjectMembersPersist';
import {
buildManagedMemberUpsertPayload,
ensureCreatedProjectWithMembers,
} from './lib/createProjectMembersPersist';
import {
beginCreateProjectSubmit,
endCreateProjectSubmit,
resolveCreateProjectSubmitPhase,
type CreateProjectSubmitPhase,
} from './lib/createProjectSubmitGuard';
import {
mapApiMembersToProjectMembers,
toApiProjectRole,
} from './lib/projectMemberAdapter';
import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter';
import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync';
import { resolveCurrentDocRole } from './lib/currentDocRole';
import { canAddBlockComment } from './lib/commentPolicy';
@@ -645,6 +645,7 @@ export default function App() {
const [sessionSyncNotice, setSessionSyncNotice] = useState<string | null>(null);
const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false);
const [projectMembersError, setProjectMembersError] = useState<string | null>(null);
const [workflowDocsEpoch, setWorkflowDocsEpoch] = useState(0);
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
const [workflowGovernanceEnabled, setWorkflowGovernanceEnabled] = useState(false);
@@ -1736,7 +1737,7 @@ export default function App() {
return () => {
active = false;
};
}, [session, sessionValidated, currentProjectId, currentUser.id, currentUser.name]);
}, [session, sessionValidated, currentProjectId, currentUser.id, currentUser.name, workflowDocsEpoch]);
// 文档占用是多人共享运行态:定期从服务端同步,避免其他标签页/成员开始编辑后本页无感知。
useEffect(() => {
@@ -3772,11 +3773,15 @@ export default function App() {
const handleAddProjectMember = async (newMember: Omit<ProjectMember, 'joinedAt' | 'status'>) => {
if (!currentProject) return;
try {
await addProjectMember(currentProject.id, {
user_id: newMember.userId,
project_role: toApiProjectRole(newMember.role, newMember.userId, currentProject.creatorId),
});
await addProjectMember(
currentProject.id,
buildManagedMemberUpsertPayload(
{ userId: newMember.userId, role: newMember.role },
currentProject.creatorId,
),
);
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
setWorkflowDocsEpoch((n) => n + 1);
const newSessId = buildPrivateSessionId(`p${currentProject.id}`, newMember.userId);
setSessions((prev) => {
@@ -3819,11 +3824,12 @@ export default function App() {
const handleUpdateProjectMemberRole = async (userId: number, newRole: ProjectMemberRole) => {
if (!currentProject) return;
try {
await updateProjectMember(currentProject.id, {
user_id: userId,
project_role: toApiProjectRole(newRole, userId, currentProject.creatorId),
});
await updateProjectMember(
currentProject.id,
buildManagedMemberUpsertPayload({ userId, role: newRole }, currentProject.creatorId),
);
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
setWorkflowDocsEpoch((n) => n + 1);
} catch (err) {
setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败');
}
@@ -3843,16 +3849,15 @@ export default function App() {
if (!currentProject) return;
try {
const member = currentProject.members.find((m) => m.userId === userId);
await updateProjectMember(currentProject.id, {
user_id: userId,
project_role: toApiProjectRole(
member?.role ?? 'member',
userId,
currentProject.creatorId
await updateProjectMember(
currentProject.id,
buildManagedMemberUpsertPayload(
{ userId, role: member?.role ?? 'member', status: 'active' },
currentProject.creatorId,
),
status: 'active',
});
);
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
setWorkflowDocsEpoch((n) => n + 1);
} catch (err) {
setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败');
}
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildInitialMemberUpsertPayload,
buildManagedMemberUpsertPayload,
ensureCreatedProjectWithMembers,
persistInitialProjectMembers,
selectNonCreatorInitialMembers,
@@ -21,6 +22,33 @@ test('selectNonCreatorInitialMembers skips creator only', () => {
);
});
test('buildManagedMemberUpsertPayload sends document_duty on add/update/restore', () => {
assert.deepEqual(buildManagedMemberUpsertPayload({ userId: 171, role: 'reviewer' }, 170), {
user_id: 171,
project_role: 'member',
document_duty: 'reviewer',
});
assert.deepEqual(buildManagedMemberUpsertPayload({ userId: 4, role: 'approver' }, 170), {
user_id: 4,
project_role: 'member',
document_duty: 'approver',
});
assert.deepEqual(
buildManagedMemberUpsertPayload({ userId: 171, role: 'reviewer', status: 'active' }, 170),
{
user_id: 171,
project_role: 'member',
document_duty: 'reviewer',
status: 'active',
},
);
assert.deepEqual(buildManagedMemberUpsertPayload({ userId: 170, role: 'owner' }, 170), {
user_id: 170,
project_role: 'owner',
document_duty: 'author',
});
});
test('buildInitialMemberUpsertPayload maps reviewer UI role to member API role', () => {
assert.deepEqual(buildInitialMemberUpsertPayload({ userId: 99, userName: 'B', role: 'reviewer' }, 86), {
user_id: 99,
+18 -4
View File
@@ -27,11 +27,25 @@ export function buildInitialMemberUpsertPayload(
member: InitialProjectMemberInput,
creatorUserId: number,
): UpsertProjectMemberPayload {
return {
user_id: member.userId,
project_role: toApiProjectRole(member.role, member.userId, creatorUserId),
document_duty: documentDutyFromMemberRole(member.role),
return buildManagedMemberUpsertPayload(
{ userId: member.userId, role: member.role },
creatorUserId,
);
}
export function buildManagedMemberUpsertPayload(
input: { userId: number; role: ProjectMemberRole; status?: 'active' | 'exited' },
creatorUserId: number,
): UpsertProjectMemberPayload {
const payload: UpsertProjectMemberPayload = {
user_id: input.userId,
project_role: toApiProjectRole(input.role, input.userId, creatorUserId),
document_duty: documentDutyFromMemberRole(input.role),
};
if (input.status) {
payload.status = input.status;
}
return payload;
}
export type AddProjectMemberFn = (