Skip to content

Commit 2824e51

Browse files
authored
When swapping directors, keep members active that are filling other roles (#3501)
1 parent 2373dae commit 2824e51

File tree

5 files changed

+90
-38
lines changed

5 files changed

+90
-38
lines changed

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,20 @@ export class ProjectMemberGelRepository
116116
),
117117
),
118118
}));
119-
const inactivated = e.update(members, () => ({
119+
const inactivated = e.update(members, (member) => ({
120+
filter: e.op(e.count(member.roles), '=', 1),
120121
set: {
121122
inactiveAt: e.datetime_of_transaction(),
122123
},
123124
}));
124-
const replacements = e.for(inactivated.project, (project) =>
125+
const fillingOtherRoles = e.update(members, (member) => ({
126+
filter: e.op(e.count(member.roles), '>', 1),
127+
set: {
128+
roles: { '-=': $.role },
129+
},
130+
}));
131+
const projects = e.op(inactivated, 'union', fillingOtherRoles).project;
132+
const replacements = e.for(projects, (project) =>
125133
e
126134
.insert(e.Project.Member, {
127135
project,

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

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
variable,
3535
} from '~/core/database/query';
3636
import { type FilterFn } from '~/core/database/query/filters';
37+
import { conditionalOn } from '~/core/database/query/properties/update-property';
3738
import { userFilters, UserRepository } from '../../user/user.repository';
3839
import { type ProjectFilters } from '../dto';
3940
import { projectFilters } from '../project-filters.query';
@@ -240,15 +241,47 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
240241
},
241242
}),
242243
)
243-
.apply(
244-
updateProperty({
245-
resource: ProjectMember,
246-
key: 'inactiveAt',
247-
value: now,
248-
permanentAfter: 0,
249-
}),
244+
.subQuery('node', (sub) =>
245+
sub
246+
.match([
247+
node('node'),
248+
relation('out', '', 'roles', ACTIVE),
249+
node('roles', 'Property'),
250+
])
251+
.apply(
252+
conditionalOn(
253+
'size(roles.value) > 1',
254+
['node'],
255+
// If there are other roles, remove this role from the membership & keep it active
256+
(q) =>
257+
q
258+
.apply(
259+
updateProperty({
260+
resource: ProjectMember,
261+
key: 'roles',
262+
value: variable(
263+
apoc.coll.disjunction('roles.value', [`"${role}"`]),
264+
),
265+
permanentAfter: 0,
266+
}),
267+
)
268+
.return('stats'),
269+
// Else then mark the membership inactive & maintain the role
270+
(q) =>
271+
q
272+
.apply(
273+
updateProperty({
274+
resource: ProjectMember,
275+
key: 'inactiveAt',
276+
value: now,
277+
permanentAfter: 0,
278+
}),
279+
)
280+
.return('stats'),
281+
),
282+
)
283+
.return('stats as oldMemberStats'),
250284
)
251-
.with('project')
252285
.subQuery('project', (sub) =>
253286
sub
254287
.match([

src/core/database/query/cypher-functions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ export const apoc = {
8282
coll: {
8383
flatten: fn1('apoc.coll.flatten'),
8484
indexOf: fn('apoc.coll.indexOf'),
85+
/**
86+
* Returns the items in the first list that aren't in the second list.
87+
* @see https://neo4j.com/docs/apoc/current/overview/apoc.coll/apoc.coll.disjunction/
88+
*/
89+
disjunction: fn2('apoc.coll.disjunction'),
8590
/**
8691
* Returns the distinct union of the two given LIST<ANY> values.
8792
* @see https://neo4j.com/docs/apoc/current/overview/apoc.coll/apoc.coll.union/

src/core/database/query/properties/update-property.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export const conditionalOn = <R>(
206206
trueQuery: QueryFragment<unknown, R>,
207207
falseQuery: QueryFragment<unknown, R>,
208208
): QueryFragment<unknown, R> => {
209-
const imports = [...new Set([conditionVar, ...scope])];
209+
const imports = [...new Set([varInExp(conditionVar), ...scope])];
210210
return (query) =>
211211
query.subQuery((sub) =>
212212
sub

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

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { entries, mapEntries } from '@seedcompany/common';
12
import { DateTime } from 'luxon';
23
import { type ID, Role } from '~/common';
34
import { graphql } from '~/graphql';
@@ -66,6 +67,15 @@ it('director change replaces memberships on open projects', async () => {
6667
});
6768
return project;
6869
})(),
70+
hasMemberIncludingOtherRoles: await (async () => {
71+
const project = await createProject(app);
72+
await createProjectMember(app, {
73+
projectId: project.id,
74+
userId: directors.old.id,
75+
roles: [Role.RegionalDirector, Role.ProjectManager],
76+
});
77+
return project;
78+
})(),
6979
alreadyHasRoleFilled: await (async () => {
7080
const project = await createProject(app);
7181
await createProjectMember(app, {
@@ -138,33 +148,13 @@ it('director change replaces memberships on open projects', async () => {
138148
};
139149

140150
const getResults = async () => {
141-
const results = {
142-
needsSwapA: await fetchMembers(app, projects.needsSwapA.id),
143-
needsSwapB: await fetchMembers(app, projects.needsSwapB.id),
144-
doesNotHaveMember: await fetchMembers(app, projects.doesNotHaveMember.id),
145-
hasMemberButInactive: await fetchMembers(
146-
app,
147-
projects.hasMemberButInactive.id,
148-
),
149-
alreadyHasRoleFilled: await fetchMembers(
150-
app,
151-
projects.alreadyHasRoleFilled.id,
152-
),
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-
),
165-
closed: await fetchMembers(app, projects.closed.id),
166-
};
167-
151+
const resultList = await Promise.all(
152+
entries(projects).map(async ([key, project]) => {
153+
const results = await fetchMembers(app, project.id);
154+
return [key, results] as const;
155+
}),
156+
);
157+
const results = mapEntries(resultList, (i) => i).asRecord;
168158
return {
169159
get: (project: keyof typeof results, key: keyof typeof directors) => {
170160
const member = results[project].find(
@@ -200,6 +190,14 @@ it('director change replaces memberships on open projects', async () => {
200190
expect(before.get('hasMemberButInactive', 'old')).toEqual(InactiveRD);
201191
expect(before.get('hasMemberButInactive', 'new')).toBeUndefined();
202192
expect(before.get('hasMemberButInactive', 'unrelated')).toBeUndefined();
193+
expect(before.get('hasMemberIncludingOtherRoles', 'old')).toEqual({
194+
active: true,
195+
roles: [Role.RegionalDirector, Role.ProjectManager],
196+
});
197+
expect(before.get('hasMemberIncludingOtherRoles', 'new')).toBeUndefined();
198+
expect(
199+
before.get('hasMemberIncludingOtherRoles', 'unrelated'),
200+
).toBeUndefined();
203201
expect(before.get('alreadyHasRoleFilled', 'old')).toBeUndefined();
204202
expect(before.get('alreadyHasRoleFilled', 'new')).toBeUndefined();
205203
expect(before.get('alreadyHasRoleFilled', 'unrelated')).toEqual(ActiveRD);
@@ -264,6 +262,14 @@ it('director change replaces memberships on open projects', async () => {
264262
expect(after.get('hasMemberButInactive', 'old')).toEqual(InactiveRD);
265263
expect(after.get('hasMemberButInactive', 'new')).toBeUndefined();
266264
expect(after.get('hasMemberButInactive', 'unrelated')).toBeUndefined();
265+
expect(after.get('hasMemberIncludingOtherRoles', 'old')).toEqual({
266+
active: true, // still active
267+
roles: [Role.ProjectManager], // but without RD
268+
});
269+
expect(after.get('hasMemberIncludingOtherRoles', 'new')).toEqual(ActiveRD);
270+
expect(
271+
after.get('hasMemberIncludingOtherRoles', 'unrelated'),
272+
).toBeUndefined();
267273
expect(after.get('alreadyHasRoleFilled', 'old')).toBeUndefined();
268274
expect(after.get('alreadyHasRoleFilled', 'new')).toBeUndefined();
269275
expect(after.get('alreadyHasRoleFilled', 'unrelated')).toEqual(ActiveRD);

0 commit comments

Comments
 (0)