Skip to content

Commit 36b8d39

Browse files
committed
Handle new director on swap already existing in projects
1 parent 6775657 commit 36b8d39

File tree

3 files changed

+238
-43
lines changed

3 files changed

+238
-43
lines changed

src/components/project/project-member/project-member.gel.repository.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,22 @@ export class ProjectMemberGelRepository
110110
},
111111
}));
112112
const replacements = e.for(inactivated.project, (project) =>
113-
e.insert(e.Project.Member, {
114-
project,
115-
projectContext: project.projectContext,
116-
user: newDirector,
117-
roles: $.role,
118-
}),
113+
e
114+
.insert(e.Project.Member, {
115+
project,
116+
projectContext: project.projectContext,
117+
user: newDirector,
118+
roles: $.role,
119+
})
120+
.unlessConflict((member) => ({
121+
on: member.user,
122+
else: e.update(member, () => ({
123+
set: {
124+
roles: { '+=': $.role },
125+
inactiveAt: null,
126+
},
127+
})),
128+
})),
119129
);
120130
return e.select({
121131
timestampId: e.datetime_of_transaction(),

src/components/project/project-member/project-member.repository.ts

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { Injectable } from '@nestjs/common';
2-
import { type Node, node, type Query, relation } from 'cypher-query-builder';
2+
import {
3+
type Node,
4+
node,
5+
not,
6+
type Query,
7+
relation,
8+
} from 'cypher-query-builder';
39
import { DateTime } from 'luxon';
410
import {
511
CreationFailed,
@@ -13,13 +19,15 @@ import {
1319
import { DtoRepository } from '~/core/database';
1420
import {
1521
ACTIVE,
22+
apoc,
1623
createNode,
1724
createRelationships,
1825
filter,
1926
matchPropsAndProjectSensAndScopedRoles,
2027
merge,
2128
oncePerProject,
2229
paginate,
30+
path,
2331
randomUUID,
2432
sorting,
2533
updateProperty,
@@ -192,9 +200,22 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
192200
newDirector: ID<'User'>,
193201
role: Role,
194202
) {
195-
const now = DateTime.now();
203+
const nowVal = DateTime.now();
204+
const now = variable('$now');
205+
const createMember = await createNode(ProjectMember, {
206+
baseNodeProps: {
207+
id: variable(randomUUID()),
208+
createdAt: now,
209+
},
210+
initialProps: {
211+
roles: [role],
212+
inactiveAt: null,
213+
modifiedAt: now,
214+
},
215+
});
196216
const result = await this.db
197217
.query()
218+
.raw('', { now: nowVal })
198219
.match([
199220
node('project', 'Project'),
200221
relation('out', '', 'member', ACTIVE),
@@ -217,30 +238,81 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
217238
}),
218239
)
219240
.with('project')
220-
.apply(
221-
await createNode(ProjectMember, {
222-
baseNodeProps: {
223-
id: variable(randomUUID()),
224-
createdAt: now,
225-
},
226-
initialProps: {
227-
roles: [role],
228-
inactiveAt: null,
229-
modifiedAt: now,
230-
},
231-
}),
232-
)
233-
.apply(
234-
createRelationships(ProjectMember, {
235-
in: { member: variable('project') },
236-
out: { user: ['User', newDirector] },
237-
}),
241+
.subQuery('project', (sub) =>
242+
sub
243+
.match([
244+
[
245+
node('project'),
246+
relation('out', '', 'member', ACTIVE),
247+
node('node', 'ProjectMember'),
248+
relation('out', '', 'user', ACTIVE),
249+
node('', 'User', { id: newDirector }),
250+
],
251+
[
252+
node('node', 'ProjectMember'),
253+
relation('out', '', 'roles', ACTIVE),
254+
node('roles', 'Property'),
255+
],
256+
])
257+
.apply(
258+
updateProperty({
259+
resource: ProjectMember,
260+
key: 'roles',
261+
value: variable(apoc.coll.union('roles.value', [`"${role}"`])),
262+
now,
263+
permanentAfter: 0,
264+
outputStatsVar: 'inactiveStats',
265+
}),
266+
)
267+
.apply(
268+
updateProperty({
269+
resource: ProjectMember,
270+
key: 'inactiveAt',
271+
value: null,
272+
now,
273+
permanentAfter: 0,
274+
outputStatsVar: 'rolesStats',
275+
}),
276+
)
277+
.apply(
278+
updateProperty({
279+
resource: ProjectMember,
280+
key: 'modifiedAt',
281+
value: now,
282+
now,
283+
permanentAfter: 0,
284+
outputStatsVar: 'modifiedAtStats',
285+
}),
286+
)
287+
.return('node as member')
288+
.union()
289+
.with('project')
290+
.with('project')
291+
.where(
292+
not(
293+
path([
294+
node('project'),
295+
relation('out', '', 'member', ACTIVE),
296+
node('', 'ProjectMember'),
297+
relation('out', '', 'user', ACTIVE),
298+
node('', 'User', { id: newDirector }),
299+
]),
300+
),
301+
)
302+
.apply(createMember)
303+
.apply(
304+
createRelationships(ProjectMember, {
305+
in: { member: variable('project') },
306+
out: { user: ['User', newDirector] },
307+
}),
308+
)
309+
.return('node as member'),
238310
)
239311
.return<{ id: ID }>('project.id as id')
240312
.run();
241313
return {
242314
projects: result.map(({ id }) => id) as readonly ID[],
243-
timestampId: now,
315+
timestampId: nowVal,
244316
};
245317
}
246318
}

test/features/director-change-replaces-memberships-on-open-projects.e2e-spec.ts

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,49 @@ it('director change replaces memberships on open projects', async () => {
7575
});
7676
return project;
7777
})(),
78+
alreadyHasNewDirectorActive: await (async () => {
79+
const project = await createProject(app);
80+
await createProjectMember(app, {
81+
projectId: project.id,
82+
userId: directors.old.id,
83+
roles: [Role.RegionalDirector],
84+
});
85+
await createProjectMember(app, {
86+
projectId: project.id,
87+
userId: directors.new.id,
88+
roles: [Role.RegionalDirector],
89+
});
90+
return project;
91+
})(),
92+
alreadyHasNewDirectorInactive: await (async () => {
93+
const project = await createProject(app);
94+
await createProjectMember(app, {
95+
projectId: project.id,
96+
userId: directors.old.id,
97+
roles: [Role.RegionalDirector],
98+
});
99+
await createProjectMember(app, {
100+
projectId: project.id,
101+
userId: directors.new.id,
102+
roles: [Role.RegionalDirector],
103+
inactiveAt: DateTime.now().plus({ minute: 1 }).toISO(),
104+
});
105+
return project;
106+
})(),
107+
alreadyHasNewDirectorWithoutRole: await (async () => {
108+
const project = await createProject(app);
109+
await createProjectMember(app, {
110+
projectId: project.id,
111+
userId: directors.old.id,
112+
roles: [Role.RegionalDirector],
113+
});
114+
await createProjectMember(app, {
115+
projectId: project.id,
116+
userId: directors.new.id,
117+
roles: [Role.ProjectManager],
118+
});
119+
return project;
120+
})(),
78121
closed: await (async () => {
79122
const project = await createProject(app, {
80123
mouStart: '2025-06-05',
@@ -107,37 +150,82 @@ it('director change replaces memberships on open projects', async () => {
107150
app,
108151
projects.alreadyHasRoleFilled.id,
109152
),
153+
alreadyHasNewDirectorActive: await fetchMembers(
154+
app,
155+
projects.alreadyHasNewDirectorActive.id,
156+
),
157+
alreadyHasNewDirectorInactive: await fetchMembers(
158+
app,
159+
projects.alreadyHasNewDirectorInactive.id,
160+
),
161+
alreadyHasNewDirectorWithoutRole: await fetchMembers(
162+
app,
163+
projects.alreadyHasNewDirectorWithoutRole.id,
164+
),
110165
closed: await fetchMembers(app, projects.closed.id),
111166
};
167+
112168
return {
113-
get: (project: keyof typeof results, key: keyof typeof directors) =>
114-
results[project].find(
169+
get: (project: keyof typeof results, key: keyof typeof directors) => {
170+
const member = results[project].find(
115171
(member) => member.user.value!.id === directors[key].id,
116-
)?.active,
172+
);
173+
return member
174+
? {
175+
active: member.active,
176+
roles: member.roles.value,
177+
}
178+
: undefined;
179+
},
117180
};
118181
};
119182
// endregion
120183

121184
// region validate setup
122185
const before = await getResults();
123186

124-
expect(before.get('needsSwapA', 'old')).toBe(true);
187+
const ActiveRD = { active: true, roles: ['RegionalDirector'] };
188+
const InactiveRD = { active: false, roles: ['RegionalDirector'] };
189+
190+
expect(before.get('needsSwapA', 'old')).toEqual(ActiveRD);
125191
expect(before.get('needsSwapA', 'new')).toBeUndefined();
126-
expect(before.get('needsSwapB', 'old')).toBe(true);
192+
expect(before.get('needsSwapB', 'old')).toEqual(ActiveRD);
127193
expect(before.get('needsSwapB', 'new')).toBeUndefined();
128194

129195
expect(before.get('needsSwapA', 'unrelated')).toBeUndefined();
130196
expect(before.get('needsSwapB', 'unrelated')).toBeUndefined();
131197
expect(before.get('doesNotHaveMember', 'old')).toBeUndefined();
132198
expect(before.get('doesNotHaveMember', 'new')).toBeUndefined();
133199
expect(before.get('doesNotHaveMember', 'unrelated')).toBeUndefined();
134-
expect(before.get('hasMemberButInactive', 'old')).toBe(false);
200+
expect(before.get('hasMemberButInactive', 'old')).toEqual(InactiveRD);
135201
expect(before.get('hasMemberButInactive', 'new')).toBeUndefined();
136202
expect(before.get('hasMemberButInactive', 'unrelated')).toBeUndefined();
137203
expect(before.get('alreadyHasRoleFilled', 'old')).toBeUndefined();
138204
expect(before.get('alreadyHasRoleFilled', 'new')).toBeUndefined();
139-
expect(before.get('alreadyHasRoleFilled', 'unrelated')).toBe(true);
140-
expect(before.get('closed', 'old')).toBe(true);
205+
expect(before.get('alreadyHasRoleFilled', 'unrelated')).toEqual(ActiveRD);
206+
expect(before.get('alreadyHasNewDirectorActive', 'old')).toEqual(ActiveRD);
207+
expect(before.get('alreadyHasNewDirectorActive', 'new')).toEqual(ActiveRD);
208+
expect(
209+
before.get('alreadyHasNewDirectorActive', 'unrelated'),
210+
).toBeUndefined();
211+
expect(before.get('alreadyHasNewDirectorInactive', 'old')).toEqual(ActiveRD);
212+
expect(before.get('alreadyHasNewDirectorInactive', 'new')).toEqual(
213+
InactiveRD,
214+
);
215+
expect(
216+
before.get('alreadyHasNewDirectorInactive', 'unrelated'),
217+
).toBeUndefined();
218+
expect(before.get('alreadyHasNewDirectorWithoutRole', 'old')).toEqual(
219+
ActiveRD,
220+
);
221+
expect(before.get('alreadyHasNewDirectorWithoutRole', 'new')).toEqual({
222+
active: true,
223+
roles: [Role.ProjectManager],
224+
});
225+
expect(
226+
before.get('alreadyHasNewDirectorWithoutRole', 'unrelated'),
227+
).toBeUndefined();
228+
expect(before.get('closed', 'old')).toEqual(ActiveRD);
141229
expect(before.get('closed', 'new')).toBeUndefined();
142230
expect(before.get('closed', 'unrelated')).toBeUndefined();
143231
// endregion
@@ -163,23 +251,41 @@ it('director change replaces memberships on open projects', async () => {
163251
// region assertions
164252
const after = await getResults();
165253

166-
expect(after.get('needsSwapA', 'old')).toBe(false);
167-
expect(after.get('needsSwapA', 'new')).toBe(true);
168-
expect(after.get('needsSwapB', 'old')).toBe(false);
169-
expect(after.get('needsSwapB', 'new')).toBe(true);
254+
expect(after.get('needsSwapA', 'old')).toEqual(InactiveRD);
255+
expect(after.get('needsSwapA', 'new')).toEqual(ActiveRD);
256+
expect(after.get('needsSwapB', 'old')).toEqual(InactiveRD);
257+
expect(after.get('needsSwapB', 'new')).toEqual(ActiveRD);
170258

171259
expect(after.get('needsSwapA', 'unrelated')).toBeUndefined();
172260
expect(after.get('needsSwapB', 'unrelated')).toBeUndefined();
173261
expect(after.get('doesNotHaveMember', 'old')).toBeUndefined();
174262
expect(after.get('doesNotHaveMember', 'new')).toBeUndefined();
175263
expect(after.get('doesNotHaveMember', 'unrelated')).toBeUndefined();
176-
expect(after.get('hasMemberButInactive', 'old')).toBe(false);
264+
expect(after.get('hasMemberButInactive', 'old')).toEqual(InactiveRD);
177265
expect(after.get('hasMemberButInactive', 'new')).toBeUndefined();
178266
expect(after.get('hasMemberButInactive', 'unrelated')).toBeUndefined();
179267
expect(after.get('alreadyHasRoleFilled', 'old')).toBeUndefined();
180268
expect(after.get('alreadyHasRoleFilled', 'new')).toBeUndefined();
181-
expect(after.get('alreadyHasRoleFilled', 'unrelated')).toBe(true);
182-
expect(after.get('closed', 'old')).toBe(true);
269+
expect(after.get('alreadyHasRoleFilled', 'unrelated')).toEqual(ActiveRD);
270+
expect(after.get('alreadyHasNewDirectorActive', 'old')).toEqual(InactiveRD);
271+
expect(after.get('alreadyHasNewDirectorActive', 'new')).toEqual(ActiveRD);
272+
expect(after.get('alreadyHasNewDirectorActive', 'unrelated')).toBeUndefined();
273+
expect(after.get('alreadyHasNewDirectorInactive', 'old')).toEqual(InactiveRD);
274+
expect(after.get('alreadyHasNewDirectorInactive', 'new')).toEqual(ActiveRD);
275+
expect(
276+
after.get('alreadyHasNewDirectorInactive', 'unrelated'),
277+
).toBeUndefined();
278+
expect(after.get('alreadyHasNewDirectorWithoutRole', 'old')).toEqual(
279+
InactiveRD,
280+
);
281+
expect(after.get('alreadyHasNewDirectorWithoutRole', 'new')).toEqual({
282+
active: true,
283+
roles: [Role.ProjectManager, Role.RegionalDirector],
284+
});
285+
expect(
286+
after.get('alreadyHasNewDirectorWithoutRole', 'unrelated'),
287+
).toBeUndefined();
288+
expect(after.get('closed', 'old')).toEqual(ActiveRD);
183289
expect(after.get('closed', 'new')).toBeUndefined();
184290
expect(after.get('closed', 'unrelated')).toBeUndefined();
185291
// endregion
@@ -203,6 +309,9 @@ async function fetchMembers(app: TestApp, projectId: ID) {
203309
inactiveAt {
204310
value
205311
}
312+
roles {
313+
value
314+
}
206315
}
207316
}
208317
}
@@ -211,5 +320,9 @@ async function fetchMembers(app: TestApp, projectId: ID) {
211320
),
212321
{ projectId },
213322
);
214-
return res.project.team.items;
323+
const members = res.project.team.items;
324+
if (members.length !== new Set(members.map((m) => m.user.value!.id)).size) {
325+
throw new Error('Duplicate members detected');
326+
}
327+
return members;
215328
}

0 commit comments

Comments
 (0)