Skip to content

Commit 2093388

Browse files
authored
Merge pull request #3447 from SeedCompany/director-change-members
2 parents 7e19101 + 36b8d39 commit 2093388

File tree

11 files changed

+597
-13
lines changed

11 files changed

+597
-13
lines changed

src/components/field-region/events/field-region-updated.event.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { type FieldRegion, type UpdateFieldRegion } from '../dto';
33

44
export class FieldRegionUpdatedEvent {
55
constructor(
6-
readonly updated: UnsecuredDto<FieldRegion>,
76
readonly previous: UnsecuredDto<FieldRegion>,
7+
readonly updated: UnsecuredDto<FieldRegion>,
88
readonly input: UpdateFieldRegion,
99
) {}
1010
}

src/components/field-zone/events/field-zone-updated.event.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { type FieldZone, type UpdateFieldZone } from '../dto';
33

44
export class FieldZoneUpdatedEvent {
55
constructor(
6-
readonly updated: UnsecuredDto<FieldZone>,
76
readonly previous: UnsecuredDto<FieldZone>,
7+
readonly updated: UnsecuredDto<FieldZone>,
88
readonly input: UpdateFieldZone,
99
) {}
1010
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { type Role } from '~/common';
2+
import { EventsHandler } from '~/core/events';
3+
import { ILogger, Logger } from '~/core/logger';
4+
import { FieldRegionUpdatedEvent } from '../../../field-region/events/field-region-updated.event';
5+
import { FieldZoneUpdatedEvent } from '../../../field-zone/events/field-zone-updated.event';
6+
import { ProjectMemberRepository } from '../project-member.repository';
7+
8+
@EventsHandler(FieldZoneUpdatedEvent, FieldRegionUpdatedEvent)
9+
export class DirectorChangeApplyToProjectMembersHandler {
10+
constructor(
11+
private readonly repo: ProjectMemberRepository,
12+
@Logger('project:members') private readonly logger: ILogger,
13+
) {}
14+
15+
async handle(event: FieldZoneUpdatedEvent | FieldRegionUpdatedEvent) {
16+
const oldDirector = event.previous.director.id;
17+
const newDirector = event.input.directorId;
18+
if (!newDirector) {
19+
return;
20+
}
21+
const role: Role =
22+
event instanceof FieldZoneUpdatedEvent
23+
? 'FieldOperationsDirector'
24+
: 'RegionalDirector';
25+
26+
const stats = await this.repo.replaceMembershipsOnOpenProjects(
27+
oldDirector,
28+
newDirector,
29+
role,
30+
);
31+
32+
this.logger.notice('Replaced director memberships on open projects', {
33+
location: event.updated.id,
34+
oldDirector,
35+
newDirector,
36+
role,
37+
...stats,
38+
});
39+
}
40+
}

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,65 @@ export class ProjectMemberGelRepository
7272
),
7373
];
7474
}
75+
76+
async replaceMembershipsOnOpenProjects(
77+
oldDirector: ID<'User'>,
78+
newDirector: ID<'User'>,
79+
role: Role,
80+
) {
81+
return await this.db.run(this.replaceMembershipsOnOpenProjectsQuery, {
82+
oldDirector,
83+
newDirector,
84+
role,
85+
});
86+
}
87+
private readonly replaceMembershipsOnOpenProjectsQuery = e.params(
88+
{
89+
oldDirector: e.uuid,
90+
newDirector: e.uuid,
91+
role: e.Role,
92+
},
93+
($) => {
94+
const oldDirector = e.cast(e.User, $.oldDirector);
95+
const newDirector = e.cast(e.User, $.newDirector);
96+
97+
const members = e.select(e.Project.members, (member) => ({
98+
filter: e.all(
99+
e.set(
100+
e.op(member.user, '=', oldDirector),
101+
e.op(member.active, '=', true),
102+
e.op($.role, 'in', member.roles),
103+
e.op(member.project.status, 'in', e.set('Active', 'InDevelopment')),
104+
),
105+
),
106+
}));
107+
const inactivated = e.update(members, () => ({
108+
set: {
109+
inactiveAt: e.datetime_of_transaction(),
110+
},
111+
}));
112+
const replacements = e.for(inactivated.project, (project) =>
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+
})),
129+
);
130+
return e.select({
131+
timestampId: e.datetime_of_transaction(),
132+
projects: replacements.project.id,
133+
});
134+
},
135+
);
75136
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AuthorizationModule } from '../../authorization/authorization.module';
44
import { UserModule } from '../../user/user.module';
55
import { ProjectModule } from '../project.module';
66
import { AvailableRolesToProjectResolver } from './available-roles-to-project.resolver';
7+
import { DirectorChangeApplyToProjectMembersHandler } from './handlers/director-change-apply-to-project-members.handler';
78
import { AddInactiveAtMigration } from './migrations/add-inactive-at.migration';
89
import { ProjectMemberGelRepository } from './project-member.gel.repository';
910
import { ProjectMemberLoader } from './project-member.loader';
@@ -24,6 +25,7 @@ import { ProjectMemberService } from './project-member.service';
2425
splitDb(ProjectMemberRepository, ProjectMemberGelRepository),
2526
ProjectMemberLoader,
2627
AddInactiveAtMigration,
28+
DirectorChangeApplyToProjectMembersHandler,
2729
],
2830
exports: [ProjectMemberService],
2931
})

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

Lines changed: 133 additions & 1 deletion
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,14 +19,19 @@ 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,
31+
randomUUID,
2332
sorting,
33+
updateProperty,
34+
variable,
2435
} from '~/core/database/query';
2536
import { type FilterFn } from '~/core/database/query/filters';
2637
import { userFilters, UserRepository } from '../../user/user.repository';
@@ -183,6 +194,127 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
183194
])
184195
.run();
185196
}
197+
198+
async replaceMembershipsOnOpenProjects(
199+
oldDirector: ID<'User'>,
200+
newDirector: ID<'User'>,
201+
role: Role,
202+
) {
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+
});
216+
const result = await this.db
217+
.query()
218+
.raw('', { now: nowVal })
219+
.match([
220+
node('project', 'Project'),
221+
relation('out', '', 'member', ACTIVE),
222+
node('node', 'ProjectMember'),
223+
])
224+
.apply(
225+
projectMemberFilters({
226+
user: { id: oldDirector },
227+
active: true,
228+
roles: [role],
229+
project: { status: ['Active', 'InDevelopment'] },
230+
}),
231+
)
232+
.apply(
233+
updateProperty({
234+
resource: ProjectMember,
235+
key: 'inactiveAt',
236+
value: now,
237+
permanentAfter: 0,
238+
}),
239+
)
240+
.with('project')
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'),
310+
)
311+
.return<{ id: ID }>('project.id as id')
312+
.run();
313+
return {
314+
projects: result.map(({ id }) => id) as readonly ID[],
315+
timestampId: nowVal,
316+
};
317+
}
186318
}
187319

188320
export const projectMemberFilters = filter.define(() => ProjectMemberFilters, {

src/core/database/query-augmentation/subquery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,4 @@ class SubQueryClause extends SubClauseCollection {
6969
export const varInExp = (variable: string | Variable) =>
7070
variable.toString().startsWith('$')
7171
? ''
72-
: /(?:.+\()?([^.]+)\.?.*/.exec(variable.toString())?.[1] ?? '';
72+
: /(?:.+\()?\)?([^.]*)\.?.*/.exec(variable.toString())?.[1] ?? '';

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const fn =
1616
/** Create a function with a name that takes a single argument */
1717
const fn1 = (name: string) => (arg: ExpressionInput) => fn(name)(arg);
1818

19+
/** Create a function with a name that takes two arguments */
20+
const fn2 = (name: string) => (arg1: ExpressionInput, arg2: ExpressionInput) =>
21+
fn(name)(arg1, arg2);
22+
1923
const fn0 = (name: string) => () => fn(name)();
2024

2125
/**
@@ -78,6 +82,11 @@ export const apoc = {
7882
coll: {
7983
flatten: fn1('apoc.coll.flatten'),
8084
indexOf: fn('apoc.coll.indexOf'),
85+
/**
86+
* Returns the distinct union of the two given LIST<ANY> values.
87+
* @see https://neo4j.com/docs/apoc/current/overview/apoc.coll/apoc.coll.union/
88+
*/
89+
union: fn2('apoc.coll.union'),
8190
},
8291
convert: {
8392
/** Converts Neo4j node to object/map of the node's properties */

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type ResourceShape,
88
} from '~/common';
99
import { type DbChanges } from '../../changes';
10-
import { apoc, collect, merge, type Variable, variable } from '../index';
10+
import { apoc, collect, merge, Variable, variable } from '../index';
1111
import { type PropUpdateStat, updateProperty } from './update-property';
1212

1313
export interface UpdatePropertiesOptions<
@@ -21,6 +21,7 @@ export interface UpdatePropertiesOptions<
2121
changeset?: Variable;
2222
nodeName?: string;
2323
outputStatsVar?: string;
24+
now?: DateTime | Variable;
2425
}
2526

2627
export const updateProperties =
@@ -35,6 +36,7 @@ export const updateProperties =
3536
changeset,
3637
nodeName = 'node',
3738
outputStatsVar = 'stats',
39+
now,
3840
}: UpdatePropertiesOptions<TResourceStatic, TObject>) =>
3941
<R>(query: Query<R>) => {
4042
const resource = EnhancedResource.of(resourceIn);
@@ -61,7 +63,10 @@ export const updateProperties =
6163
labels: variable('prop.labels'),
6264
changeset,
6365
nodeName,
64-
now: query.params.addParam(DateTime.local(), 'now'),
66+
now:
67+
now instanceof Variable
68+
? now
69+
: query.params.addParam(now ?? DateTime.local(), 'now'),
6570
}),
6671
)
6772
.return<{

0 commit comments

Comments
 (0)