fix(web): persist initial project members after createProject
Create Project no longer treats local member lists as success: non-creator initialMembers are upserted via the authorized members API, with bootstrap project id retained for idempotent retry when persistence partially fails. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+35
-8
@@ -36,6 +36,7 @@ import {
|
||||
removeProjectMember,
|
||||
updateProjectMember,
|
||||
} from './api/projectMembers';
|
||||
import { ensureCreatedProjectWithMembers } from './lib/createProjectMembersPersist';
|
||||
import {
|
||||
mapApiMembersToProjectMembers,
|
||||
toApiProjectRole,
|
||||
@@ -603,6 +604,8 @@ export default function App() {
|
||||
|
||||
const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false);
|
||||
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
|
||||
/** createProject 已成功但成员持久化失败时保留,避免再次确认重复建项 */
|
||||
const [createProjectBootstrapId, setCreateProjectBootstrapId] = useState<number | null>(null);
|
||||
const [projectSyncNotice, setProjectSyncNotice] = useState<string | null>(null);
|
||||
const [sessionSyncNotice, setSessionSyncNotice] = useState<string | null>(null);
|
||||
const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false);
|
||||
@@ -3417,21 +3420,44 @@ export default function App() {
|
||||
|
||||
let newProjId: number;
|
||||
try {
|
||||
const created = await createProject({
|
||||
name: data.title,
|
||||
device_type: data.deviceType,
|
||||
intended_use: data.description,
|
||||
const ensured = await ensureCreatedProjectWithMembers({
|
||||
bootstrapProjectId: createProjectBootstrapId,
|
||||
createProject: () =>
|
||||
createProject({
|
||||
name: data.title,
|
||||
device_type: data.deviceType,
|
||||
intended_use: data.description,
|
||||
}),
|
||||
creatorUserId: currentUser.id,
|
||||
initialMembers: data.initialMembers,
|
||||
addMember: addProjectMember,
|
||||
});
|
||||
newProjId = created.id;
|
||||
newProjId = ensured.projectId;
|
||||
setCreateProjectBootstrapId(ensured.projectId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '创建项目失败,请稍后重试';
|
||||
setCreateProjectError(message);
|
||||
const withId = err as Error & { projectId?: number };
|
||||
const bootstrapId =
|
||||
typeof withId.projectId === 'number' && withId.projectId > 0
|
||||
? withId.projectId
|
||||
: createProjectBootstrapId;
|
||||
if (bootstrapId) {
|
||||
setCreateProjectBootstrapId(bootstrapId);
|
||||
}
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: '创建项目或同步初始成员失败,请稍后重试(不会因本地状态假成功)';
|
||||
setCreateProjectError(
|
||||
bootstrapId
|
||||
? `项目已创建(#${bootstrapId}),但初始成员未全部落库:${message}。请再次确认以幂等补齐,勿关闭后当作成功。`
|
||||
: message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nowStr = new Date().toLocaleString();
|
||||
|
||||
// 1. 构建项目与成员元数据
|
||||
// 1. 构建项目与成员元数据(服务端成员已持久化)
|
||||
const members: ProjectMember[] = data.initialMembers.map((m) => ({
|
||||
userId: m.userId,
|
||||
userName: m.userName,
|
||||
@@ -3520,6 +3546,7 @@ export default function App() {
|
||||
}
|
||||
setActiveDocId(clonedDocs[0].docId);
|
||||
setActiveCategory('workflow');
|
||||
setCreateProjectBootstrapId(null);
|
||||
setCreateProjectModalOpen(false);
|
||||
setCreateProjectError(null);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildInitialMemberUpsertPayload,
|
||||
ensureCreatedProjectWithMembers,
|
||||
persistInitialProjectMembers,
|
||||
selectNonCreatorInitialMembers,
|
||||
} from './createProjectMembersPersist';
|
||||
|
||||
test('selectNonCreatorInitialMembers skips creator only', () => {
|
||||
const members = selectNonCreatorInitialMembers(
|
||||
[
|
||||
{ userId: 86, userName: 'A', role: 'creator' },
|
||||
{ userId: 99, userName: 'B', role: 'reviewer' },
|
||||
],
|
||||
86,
|
||||
);
|
||||
assert.deepEqual(
|
||||
members.map((m) => m.userId),
|
||||
[99],
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
assert.deepEqual(buildInitialMemberUpsertPayload({ userId: 99, userName: 'B', role: 'viewer' }, 86), {
|
||||
user_id: 99,
|
||||
project_role: 'viewer',
|
||||
});
|
||||
});
|
||||
|
||||
test('persistInitialProjectMembers awaits each add and stops on failure', async () => {
|
||||
const calls: number[] = [];
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
persistInitialProjectMembers({
|
||||
projectId: 6071,
|
||||
creatorUserId: 86,
|
||||
initialMembers: [
|
||||
{ userId: 86, userName: 'A', role: 'creator' },
|
||||
{ userId: 2, userName: 'B', role: 'reviewer' },
|
||||
{ userId: 3, userName: 'C', role: 'approver' },
|
||||
],
|
||||
addMember: async (_projectId, payload) => {
|
||||
calls.push(payload.user_id);
|
||||
if (payload.user_id === 3) {
|
||||
throw new Error('add failed');
|
||||
}
|
||||
},
|
||||
}),
|
||||
/add failed/,
|
||||
);
|
||||
assert.deepEqual(calls, [2, 3]);
|
||||
});
|
||||
|
||||
test('ensureCreatedProjectWithMembers skips recreate on bootstrap id and retries members', async () => {
|
||||
let createCalls = 0;
|
||||
const added: number[] = [];
|
||||
const first = await ensureCreatedProjectWithMembers({
|
||||
bootstrapProjectId: null,
|
||||
createProject: async () => {
|
||||
createCalls += 1;
|
||||
return { id: 6071 };
|
||||
},
|
||||
creatorUserId: 86,
|
||||
initialMembers: [
|
||||
{ userId: 86, userName: 'A', role: 'creator' },
|
||||
{ userId: 99, userName: 'B', role: 'reviewer' },
|
||||
],
|
||||
addMember: async (_pid, payload) => {
|
||||
added.push(payload.user_id);
|
||||
},
|
||||
});
|
||||
assert.equal(first.projectId, 6071);
|
||||
assert.equal(first.createdNow, true);
|
||||
assert.deepEqual(first.persistedUserIds, [99]);
|
||||
|
||||
let failOnce = true;
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
ensureCreatedProjectWithMembers({
|
||||
bootstrapProjectId: 6071,
|
||||
createProject: async () => {
|
||||
createCalls += 1;
|
||||
return { id: 9999 };
|
||||
},
|
||||
creatorUserId: 86,
|
||||
initialMembers: [
|
||||
{ userId: 86, userName: 'A', role: 'creator' },
|
||||
{ userId: 99, userName: 'B', role: 'reviewer' },
|
||||
],
|
||||
addMember: async () => {
|
||||
if (failOnce) {
|
||||
failOnce = false;
|
||||
throw new Error('transient');
|
||||
}
|
||||
},
|
||||
}),
|
||||
/transient/,
|
||||
);
|
||||
assert.equal(createCalls, 1, 'must not recreate project on member retry');
|
||||
|
||||
const retry = await ensureCreatedProjectWithMembers({
|
||||
bootstrapProjectId: 6071,
|
||||
createProject: async () => {
|
||||
createCalls += 1;
|
||||
return { id: 9999 };
|
||||
},
|
||||
creatorUserId: 86,
|
||||
initialMembers: [
|
||||
{ userId: 86, userName: 'A', role: 'creator' },
|
||||
{ userId: 99, userName: 'B', role: 'reviewer' },
|
||||
],
|
||||
addMember: async (_pid, payload) => {
|
||||
added.push(payload.user_id);
|
||||
},
|
||||
});
|
||||
assert.equal(retry.createdNow, false);
|
||||
assert.equal(retry.projectId, 6071);
|
||||
assert.equal(createCalls, 1);
|
||||
assert.ok(added.filter((id) => id === 99).length >= 2);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 立项成功后将非创建者初始成员持久化到受权 project_members API。
|
||||
* 禁止仅写本地 allUsers/sessions 造成「页面勾选成功、库表无成员」假成功。
|
||||
*/
|
||||
|
||||
import type { UpsertProjectMemberPayload } from '../api/projectMembers';
|
||||
import { toApiProjectRole } from './projectMemberAdapter';
|
||||
import type { ProjectMemberRole } from '../types/workbench';
|
||||
|
||||
export type InitialProjectMemberInput = {
|
||||
userId: number;
|
||||
userName: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
role: ProjectMemberRole;
|
||||
};
|
||||
|
||||
export function selectNonCreatorInitialMembers(
|
||||
initialMembers: InitialProjectMemberInput[],
|
||||
creatorUserId: number,
|
||||
): InitialProjectMemberInput[] {
|
||||
return initialMembers.filter((m) => m.userId !== creatorUserId);
|
||||
}
|
||||
|
||||
export function buildInitialMemberUpsertPayload(
|
||||
member: InitialProjectMemberInput,
|
||||
creatorUserId: number,
|
||||
): UpsertProjectMemberPayload {
|
||||
return {
|
||||
user_id: member.userId,
|
||||
project_role: toApiProjectRole(member.role, member.userId, creatorUserId),
|
||||
};
|
||||
}
|
||||
|
||||
export type AddProjectMemberFn = (
|
||||
projectId: number,
|
||||
payload: UpsertProjectMemberPayload,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 逐一 upsert 非创建者;任一失败抛错(调用方保留已创建 projectId 以便重试幂等)。
|
||||
* Upsert 语义下重复确认不会因已存在成员而假失败。
|
||||
*/
|
||||
export async function persistInitialProjectMembers(params: {
|
||||
projectId: number;
|
||||
creatorUserId: number;
|
||||
initialMembers: InitialProjectMemberInput[];
|
||||
addMember: AddProjectMemberFn;
|
||||
}): Promise<{ persistedUserIds: number[] }> {
|
||||
const targets = selectNonCreatorInitialMembers(params.initialMembers, params.creatorUserId);
|
||||
const persistedUserIds: number[] = [];
|
||||
for (const member of targets) {
|
||||
const payload = buildInitialMemberUpsertPayload(member, params.creatorUserId);
|
||||
await params.addMember(params.projectId, payload);
|
||||
persistedUserIds.push(member.userId);
|
||||
}
|
||||
return { persistedUserIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* 若已有 bootstrapProjectId(上次 create 成功但成员失败),跳过再次 createProject,仅补成员。
|
||||
*/
|
||||
export async function ensureCreatedProjectWithMembers(params: {
|
||||
bootstrapProjectId: number | null;
|
||||
createProject: () => Promise<{ id: number }>;
|
||||
creatorUserId: number;
|
||||
initialMembers: InitialProjectMemberInput[];
|
||||
addMember: AddProjectMemberFn;
|
||||
}): Promise<{ projectId: number; createdNow: boolean; persistedUserIds: number[] }> {
|
||||
let projectId = params.bootstrapProjectId;
|
||||
let createdNow = false;
|
||||
if (projectId == null || projectId <= 0) {
|
||||
const created = await params.createProject();
|
||||
projectId = created.id;
|
||||
createdNow = true;
|
||||
}
|
||||
try {
|
||||
const { persistedUserIds } = await persistInitialProjectMembers({
|
||||
projectId,
|
||||
creatorUserId: params.creatorUserId,
|
||||
initialMembers: params.initialMembers,
|
||||
addMember: params.addMember,
|
||||
});
|
||||
return { projectId, createdNow, persistedUserIds };
|
||||
} catch (err) {
|
||||
const wrapped = err instanceof Error ? err : new Error(String(err));
|
||||
(wrapped as Error & { projectId?: number }).projectId = projectId;
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user