Skip to content

Commit c929b2e

Browse files
authored
Merge pull request #3504 from SeedCompany/project-member/project-region-defaults-directors
2 parents 61be479 + 46b2e81 commit c929b2e

8 files changed

+494
-60
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ResourceLoader } from '~/core';
2+
import { EventsHandler } from '~/core/events';
3+
import { ProjectUpdatedEvent } from '../../events';
4+
import { ProjectMemberRepository } from '../project-member.repository';
5+
6+
@EventsHandler(ProjectUpdatedEvent)
7+
export class ProjectRegionDefaultsDirectorMembershipHandler {
8+
constructor(
9+
private readonly repo: ProjectMemberRepository,
10+
private readonly resources: ResourceLoader,
11+
) {}
12+
13+
async handle(event: ProjectUpdatedEvent) {
14+
const { fieldRegionId } = event.changes;
15+
if (!fieldRegionId) {
16+
return;
17+
}
18+
19+
const fieldRegion = await this.resources.load('FieldRegion', fieldRegionId);
20+
if (fieldRegion.director.value) {
21+
await this.repo.addDefaultForRole(
22+
'RegionalDirector',
23+
event.updated.id,
24+
fieldRegion.director.value.id,
25+
);
26+
}
27+
28+
if (fieldRegion.fieldZone.value) {
29+
const fieldZone = await this.resources.load(
30+
'FieldZone',
31+
fieldRegion.fieldZone.value.id,
32+
);
33+
if (fieldZone.director.value) {
34+
await this.repo.addDefaultForRole(
35+
'FieldOperationsDirector',
36+
event.updated.id,
37+
fieldZone.director.value.id,
38+
);
39+
}
40+
}
41+
}
42+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { ModuleRef } from '@nestjs/core';
2+
import { node, type Query, relation } from 'cypher-query-builder';
3+
import { type Role } from '~/common';
4+
import { BaseMigration, Migration } from '~/core/database';
5+
import { ACTIVE, variable } from '~/core/database/query';
6+
import { projectFilters } from '../../project-filters.query';
7+
import {
8+
projectMemberFilters,
9+
ProjectMemberRepository,
10+
} from '../project-member.repository';
11+
12+
@Migration('2025-06-18T00:00:05')
13+
export class BackfillMissingDirectorsMigration extends BaseMigration {
14+
constructor(private readonly moduleRef: ModuleRef) {
15+
super();
16+
}
17+
18+
async up() {
19+
const members = this.moduleRef.get(ProjectMemberRepository, {
20+
strict: false,
21+
});
22+
// @ts-expect-error the method is private, but it is fine for this.
23+
const upsertMember = members.upsertMember.bind(members);
24+
25+
const openProjectsMissingRole = (role: Role) => (query: Query) =>
26+
query
27+
// Open projects
28+
.match(node('node', 'Project'))
29+
.apply(
30+
projectFilters({
31+
status: ['Active', 'InDevelopment'],
32+
}),
33+
)
34+
.with('node as project')
35+
// Missing role
36+
.subQuery('project', (sub) =>
37+
sub
38+
.match([
39+
node('project'),
40+
relation('out', '', 'member', ACTIVE),
41+
node('node', 'ProjectMember'),
42+
])
43+
.apply(
44+
projectMemberFilters({
45+
active: true,
46+
roles: [role],
47+
}),
48+
)
49+
.with('count(node) as members')
50+
.raw('WHERE members = 0')
51+
.return('true as filtered'),
52+
)
53+
.with('*');
54+
55+
await this.db
56+
.query()
57+
.apply((q) => {
58+
q.params.addParam(this.version, 'now');
59+
})
60+
.apply(openProjectsMissingRole('RegionalDirector'))
61+
// Find its region director
62+
.match([
63+
node('project'),
64+
relation('out', '', 'fieldRegion', ACTIVE),
65+
node('', 'FieldRegion'),
66+
relation('out', '', 'director', ACTIVE),
67+
node('director', 'User'),
68+
])
69+
.apply(await upsertMember(variable('director'), 'RegionalDirector'))
70+
.return('project.id as id')
71+
.executeAndLogStats();
72+
73+
await this.db
74+
.query()
75+
.apply((q) => {
76+
q.params.addParam(this.version, 'now');
77+
})
78+
.apply(openProjectsMissingRole('FieldOperationsDirector'))
79+
// Find its zone director
80+
.match([
81+
node('project'),
82+
relation('out', '', 'fieldRegion', ACTIVE),
83+
node('', 'FieldRegion'),
84+
relation('out', '', 'zone', ACTIVE),
85+
node('', 'FieldZone'),
86+
relation('out', '', 'director', ACTIVE),
87+
node('director', 'User'),
88+
])
89+
.apply(
90+
await upsertMember(variable('director'), 'FieldOperationsDirector'),
91+
)
92+
.return('project.id as id')
93+
.executeAndLogStats();
94+
}
95+
}

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,54 @@ export class ProjectMemberGelRepository
9898
];
9999
}
100100

101+
async addDefaultForRole(
102+
role: Role,
103+
project: ID<'Project'>,
104+
user: ID<'User'>,
105+
) {
106+
await this.db.run(this.addDefaultForRoleQuery, {
107+
role,
108+
project,
109+
user,
110+
});
111+
}
112+
private readonly addDefaultForRoleQuery = e.params(
113+
{
114+
role: e.Role,
115+
project: e.uuid,
116+
user: e.uuid,
117+
},
118+
($) => {
119+
const project = e.cast(e.Project, $.project);
120+
const user = e.cast(e.User, $.user);
121+
122+
const membersWithRole = e.select(project.members, (member) => ({
123+
filter: e.all(
124+
e.set(
125+
e.op(member.active, '=', true),
126+
e.op($.role, 'in', member.roles),
127+
),
128+
),
129+
}));
130+
const hasMemberWithRole = e.op('exists', membersWithRole);
131+
const createNew = e.insert(e.Project.Member, {
132+
project,
133+
projectContext: project.projectContext,
134+
user,
135+
roles: $.role,
136+
});
137+
const exp = e.op(
138+
'if',
139+
hasMemberWithRole,
140+
'then',
141+
membersWithRole,
142+
'else',
143+
createNew,
144+
);
145+
return e.select(exp);
146+
},
147+
);
148+
101149
async replaceMembershipsOnOpenProjects(
102150
oldDirector: ID<'User'>,
103151
newDirector: ID<'User'>,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { UserModule } from '../../user/user.module';
55
import { ProjectModule } from '../project.module';
66
import { AvailableRolesToProjectResolver } from './available-roles-to-project.resolver';
77
import { DirectorChangeApplyToProjectMembersHandler } from './handlers/director-change-apply-to-project-members.handler';
8+
import { ProjectRegionDefaultsDirectorMembershipHandler } from './handlers/project-region-defaults-director-membership.handler';
89
import { RegionsZoneChangesAppliesDirectorChangeToProjectMembersHandler } from './handlers/regions-zone-changes-applies-director-change-to-project-members.handler';
910
import { MemberProjectConnectionResolver } from './member-project-connection.resolver';
1011
import { AddInactiveAtMigration } from './migrations/add-inactive-at.migration';
12+
import { BackfillMissingDirectorsMigration } from './migrations/backfill-missing-directors.migration';
1113
import { ProjectMemberGelRepository } from './project-member.gel.repository';
1214
import { ProjectMemberLoader } from './project-member.loader';
1315
import { ProjectMemberRepository } from './project-member.repository';
@@ -30,6 +32,8 @@ import { ProjectMemberService } from './project-member.service';
3032
AddInactiveAtMigration,
3133
DirectorChangeApplyToProjectMembersHandler,
3234
RegionsZoneChangesAppliesDirectorChangeToProjectMembersHandler,
35+
ProjectRegionDefaultsDirectorMembershipHandler,
36+
BackfillMissingDirectorsMigration,
3337
],
3438
exports: [ProjectMemberService],
3539
})

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

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ import {
3232
sorting,
3333
updateProperty,
3434
variable,
35+
Variable,
3536
} from '~/core/database/query';
37+
import { varInExp } from '~/core/database/query-augmentation/subquery';
3638
import { type FilterFn } from '~/core/database/query/filters';
3739
import { conditionalOn } from '~/core/database/query/properties/update-property';
3840
import { userFilters, UserRepository } from '../../user/user.repository';
@@ -224,6 +226,41 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
224226
.run();
225227
}
226228

229+
async addDefaultForRole(
230+
role: Role,
231+
project: ID<'Project'>,
232+
user: ID<'User'>,
233+
) {
234+
const now = DateTime.now();
235+
await this.db
236+
.query()
237+
.apply((q) => {
238+
q.params.addParam(now, 'now');
239+
})
240+
.match(node('project', 'Project', { id: project }))
241+
.subQuery('project', (sub) =>
242+
sub
243+
.match([
244+
node('project'),
245+
relation('out', '', 'member', ACTIVE),
246+
node('node', 'ProjectMember'),
247+
])
248+
.apply(
249+
projectMemberFilters({
250+
active: true,
251+
roles: [role],
252+
}),
253+
)
254+
.with('count(node) as members')
255+
.raw('WHERE members = 0')
256+
.return('true as filtered'),
257+
)
258+
.with('*')
259+
.apply(await this.upsertMember(user, role))
260+
.return<{ id: ID<'ProjectMember'> }>('project.id as id')
261+
.executeAndLogStats();
262+
}
263+
227264
async replaceMembershipsOnOpenProjects(
228265
oldDirector: ID<'User'>,
229266
newDirector: ID<'User'>,
@@ -233,20 +270,11 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
233270
) {
234271
const nowVal = DateTime.now();
235272
const now = variable('$now');
236-
const createMember = await createNode(ProjectMember, {
237-
baseNodeProps: {
238-
id: variable(randomUUID()),
239-
createdAt: now,
240-
},
241-
initialProps: {
242-
roles: [role],
243-
inactiveAt: null,
244-
modifiedAt: now,
245-
},
246-
});
247273
const result = await this.db
248274
.query()
249-
.raw('', { now: nowVal })
275+
.apply((q) => {
276+
q.params.addParam(nowVal, 'now');
277+
})
250278
.match([
251279
node('project', 'Project'),
252280
relation('out', '', 'member', ACTIVE),
@@ -304,15 +332,44 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
304332
)
305333
.return('stats as oldMemberStats'),
306334
)
307-
.subQuery('project', (sub) =>
335+
.with('project')
336+
.apply(await this.upsertMember(newDirector, role))
337+
.return<{ id: ID }>('project.id as id')
338+
.run();
339+
return {
340+
projects: result.map(({ id }) => id) as readonly ID[],
341+
timestampId: nowVal,
342+
};
343+
}
344+
345+
protected async upsertMember(user: ID<'User'> | Variable, role: Role) {
346+
const now = variable('$now');
347+
const createMember = await createNode(ProjectMember, {
348+
baseNodeProps: {
349+
id: variable(randomUUID()),
350+
createdAt: now,
351+
},
352+
initialProps: {
353+
roles: [role],
354+
inactiveAt: null,
355+
modifiedAt: now,
356+
},
357+
});
358+
const scope = ['project', user instanceof Variable ? varInExp(user) : ''];
359+
const userNode =
360+
user instanceof Variable
361+
? node(String(user))
362+
: node('', 'User', { id: user });
363+
return (query: Query) =>
364+
query.subQuery(scope, (sub) =>
308365
sub
309366
.match([
310367
[
311368
node('project'),
312369
relation('out', '', 'member', ACTIVE),
313370
node('node', 'ProjectMember'),
314371
relation('out', '', 'user', ACTIVE),
315-
node('', 'User', { id: newDirector }),
372+
userNode,
316373
],
317374
[
318375
node('node', 'ProjectMember'),
@@ -327,7 +384,7 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
327384
value: variable(apoc.coll.union('roles.value', [`"${role}"`])),
328385
now,
329386
permanentAfter: 0,
330-
outputStatsVar: 'inactiveStats',
387+
outputStatsVar: 'rolesStats',
331388
}),
332389
)
333390
.apply(
@@ -337,7 +394,7 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
337394
value: null,
338395
now,
339396
permanentAfter: 0,
340-
outputStatsVar: 'rolesStats',
397+
outputStatsVar: 'inactiveStats',
341398
}),
342399
)
343400
.apply(
@@ -352,34 +409,28 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
352409
)
353410
.return('node as member')
354411
.union()
355-
.with('project')
356-
.with('project')
412+
.with(scope)
413+
.with(scope)
357414
.where(
358415
not(
359416
path([
360417
node('project'),
361418
relation('out', '', 'member', ACTIVE),
362419
node('', 'ProjectMember'),
363420
relation('out', '', 'user', ACTIVE),
364-
node('', 'User', { id: newDirector }),
421+
userNode,
365422
]),
366423
),
367424
)
368425
.apply(createMember)
369426
.apply(
370427
createRelationships(ProjectMember, {
371428
in: { member: variable('project') },
372-
out: { user: ['User', newDirector] },
429+
out: { user: user instanceof Variable ? user : ['User', user] },
373430
}),
374431
)
375432
.return('node as member'),
376-
)
377-
.return<{ id: ID }>('project.id as id')
378-
.run();
379-
return {
380-
projects: result.map(({ id }) => id) as readonly ID[],
381-
timestampId: nowVal,
382-
};
433+
);
383434
}
384435
}
385436

0 commit comments

Comments
 (0)