Skip to content

Default directors memberships when the project's region is assigned #3504

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ResourceLoader } from '~/core';
import { EventsHandler } from '~/core/events';
import { ProjectUpdatedEvent } from '../../events';
import { ProjectMemberRepository } from '../project-member.repository';

@EventsHandler(ProjectUpdatedEvent)
export class ProjectRegionDefaultsDirectorMembershipHandler {
constructor(
private readonly repo: ProjectMemberRepository,
private readonly resources: ResourceLoader,
) {}

async handle(event: ProjectUpdatedEvent) {
const { fieldRegionId } = event.changes;
if (!fieldRegionId) {
return;
}

const fieldRegion = await this.resources.load('FieldRegion', fieldRegionId);
if (fieldRegion.director.value) {
await this.repo.addDefaultForRole(
'RegionalDirector',
event.updated.id,
fieldRegion.director.value.id,
);
}

if (fieldRegion.fieldZone.value) {
const fieldZone = await this.resources.load(
'FieldZone',
fieldRegion.fieldZone.value.id,
);
if (fieldZone.director.value) {
await this.repo.addDefaultForRole(
'FieldOperationsDirector',
event.updated.id,
fieldZone.director.value.id,
);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { ModuleRef } from '@nestjs/core';
import { node, type Query, relation } from 'cypher-query-builder';
import { type Role } from '~/common';
import { BaseMigration, Migration } from '~/core/database';
import { ACTIVE, variable } from '~/core/database/query';
import { projectFilters } from '../../project-filters.query';
import {
projectMemberFilters,
ProjectMemberRepository,
} from '../project-member.repository';

@Migration('2025-06-18T00:00:05')
export class BackfillMissingDirectorsMigration extends BaseMigration {
constructor(private readonly moduleRef: ModuleRef) {
super();
}

async up() {
const members = this.moduleRef.get(ProjectMemberRepository, {
strict: false,
});
// @ts-expect-error the method is private, but it is fine for this.
const upsertMember = members.upsertMember.bind(members);

const openProjectsMissingRole = (role: Role) => (query: Query) =>
query
// Open projects
.match(node('node', 'Project'))
.apply(
projectFilters({
status: ['Active', 'InDevelopment'],
}),
)
.with('node as project')
// Missing role
.subQuery('project', (sub) =>
sub
.match([
node('project'),
relation('out', '', 'member', ACTIVE),
node('node', 'ProjectMember'),
])
.apply(
projectMemberFilters({
active: true,
roles: [role],
}),
)
.with('count(node) as members')
.raw('WHERE members = 0')
.return('true as filtered'),
)
.with('*');

await this.db
.query()
.apply((q) => {
q.params.addParam(this.version, 'now');
})
.apply(openProjectsMissingRole('RegionalDirector'))
// Find its region director
.match([
node('project'),
relation('out', '', 'fieldRegion', ACTIVE),
node('', 'FieldRegion'),
relation('out', '', 'director', ACTIVE),
node('director', 'User'),
])
.apply(await upsertMember(variable('director'), 'RegionalDirector'))
.return('project.id as id')
.executeAndLogStats();

await this.db
.query()
.apply((q) => {
q.params.addParam(this.version, 'now');
})
.apply(openProjectsMissingRole('FieldOperationsDirector'))
// Find its zone director
.match([
node('project'),
relation('out', '', 'fieldRegion', ACTIVE),
node('', 'FieldRegion'),
relation('out', '', 'zone', ACTIVE),
node('', 'FieldZone'),
relation('out', '', 'director', ACTIVE),
node('director', 'User'),
])
.apply(
await upsertMember(variable('director'), 'FieldOperationsDirector'),
)
.return('project.id as id')
.executeAndLogStats();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,54 @@ export class ProjectMemberGelRepository
];
}

async addDefaultForRole(
role: Role,
project: ID<'Project'>,
user: ID<'User'>,
) {
await this.db.run(this.addDefaultForRoleQuery, {
role,
project,
user,
});
}
private readonly addDefaultForRoleQuery = e.params(
{
role: e.Role,
project: e.uuid,
user: e.uuid,
},
($) => {
const project = e.cast(e.Project, $.project);
const user = e.cast(e.User, $.user);

const membersWithRole = e.select(project.members, (member) => ({
filter: e.all(
e.set(
e.op(member.active, '=', true),
e.op($.role, 'in', member.roles),
),
),
}));
const hasMemberWithRole = e.op('exists', membersWithRole);
const createNew = e.insert(e.Project.Member, {
project,
projectContext: project.projectContext,
user,
roles: $.role,
});
const exp = e.op(
'if',
hasMemberWithRole,
'then',
membersWithRole,
'else',
createNew,
);
return e.select(exp);
},
);

async replaceMembershipsOnOpenProjects(
oldDirector: ID<'User'>,
newDirector: ID<'User'>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { UserModule } from '../../user/user.module';
import { ProjectModule } from '../project.module';
import { AvailableRolesToProjectResolver } from './available-roles-to-project.resolver';
import { DirectorChangeApplyToProjectMembersHandler } from './handlers/director-change-apply-to-project-members.handler';
import { ProjectRegionDefaultsDirectorMembershipHandler } from './handlers/project-region-defaults-director-membership.handler';
import { RegionsZoneChangesAppliesDirectorChangeToProjectMembersHandler } from './handlers/regions-zone-changes-applies-director-change-to-project-members.handler';
import { AddInactiveAtMigration } from './migrations/add-inactive-at.migration';
import { BackfillMissingDirectorsMigration } from './migrations/backfill-missing-directors.migration';
import { ProjectMemberGelRepository } from './project-member.gel.repository';
import { ProjectMemberLoader } from './project-member.loader';
import { ProjectMemberRepository } from './project-member.repository';
Expand All @@ -28,6 +30,8 @@ import { ProjectMemberService } from './project-member.service';
AddInactiveAtMigration,
DirectorChangeApplyToProjectMembersHandler,
RegionsZoneChangesAppliesDirectorChangeToProjectMembersHandler,
ProjectRegionDefaultsDirectorMembershipHandler,
BackfillMissingDirectorsMigration,
],
exports: [ProjectMemberService],
})
Expand Down
104 changes: 77 additions & 27 deletions src/components/project/project-member/project-member.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ import {
sorting,
updateProperty,
variable,
Variable,
} from '~/core/database/query';
import { varInExp } from '~/core/database/query-augmentation/subquery';
import { type FilterFn } from '~/core/database/query/filters';
import { userFilters, UserRepository } from '../../user/user.repository';
import { type ProjectFilters } from '../dto';
Expand Down Expand Up @@ -201,27 +203,53 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
.run();
}

async addDefaultForRole(
role: Role,
project: ID<'Project'>,
user: ID<'User'>,
) {
const now = DateTime.now();
await this.db
.query()
.apply((q) => {
q.params.addParam(now, 'now');
})
.match(node('project', 'Project', { id: project }))
.subQuery('project', (sub) =>
sub
.match([
node('project'),
relation('out', '', 'member', ACTIVE),
node('node', 'ProjectMember'),
])
.apply(
projectMemberFilters({
active: true,
roles: [role],
}),
)
.with('count(node) as members')
.raw('WHERE members = 0')
.return('true as filtered'),
)
.with('*')
.apply(await this.upsertMember(user, role))
.return<{ id: ID<'ProjectMember'> }>('project.id as id')
.executeAndLogStats();
}

async replaceMembershipsOnOpenProjects(
oldDirector: ID<'User'>,
newDirector: ID<'User'>,
role: Role,
) {
const nowVal = DateTime.now();
const now = variable('$now');
const createMember = await createNode(ProjectMember, {
baseNodeProps: {
id: variable(randomUUID()),
createdAt: now,
},
initialProps: {
roles: [role],
inactiveAt: null,
modifiedAt: now,
},
});
const result = await this.db
.query()
.raw('', { now: nowVal })
.apply((q) => {
q.params.addParam(nowVal, 'now');
})
.match([
node('project', 'Project'),
relation('out', '', 'member', ACTIVE),
Expand All @@ -244,15 +272,43 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
}),
)
.with('project')
.subQuery('project', (sub) =>
.apply(await this.upsertMember(newDirector, role))
.return<{ id: ID }>('project.id as id')
.run();
return {
projects: result.map(({ id }) => id) as readonly ID[],
timestampId: nowVal,
};
}

protected async upsertMember(user: ID<'User'> | Variable, role: Role) {
const now = variable('$now');
const createMember = await createNode(ProjectMember, {
baseNodeProps: {
id: variable(randomUUID()),
createdAt: now,
},
initialProps: {
roles: [role],
inactiveAt: null,
modifiedAt: now,
},
});
const scope = ['project', user instanceof Variable ? varInExp(user) : ''];
const userNode =
user instanceof Variable
? node(String(user))
: node('', 'User', { id: user });
return (query: Query) =>
query.subQuery(scope, (sub) =>
sub
.match([
[
node('project'),
relation('out', '', 'member', ACTIVE),
node('node', 'ProjectMember'),
relation('out', '', 'user', ACTIVE),
node('', 'User', { id: newDirector }),
userNode,
],
[
node('node', 'ProjectMember'),
Expand All @@ -267,7 +323,7 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
value: variable(apoc.coll.union('roles.value', [`"${role}"`])),
now,
permanentAfter: 0,
outputStatsVar: 'inactiveStats',
outputStatsVar: 'rolesStats',
}),
)
.apply(
Expand All @@ -277,7 +333,7 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
value: null,
now,
permanentAfter: 0,
outputStatsVar: 'rolesStats',
outputStatsVar: 'inactiveStats',
}),
)
.apply(
Expand All @@ -292,34 +348,28 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
)
.return('node as member')
.union()
.with('project')
.with('project')
.with(scope)
.with(scope)
.where(
not(
path([
node('project'),
relation('out', '', 'member', ACTIVE),
node('', 'ProjectMember'),
relation('out', '', 'user', ACTIVE),
node('', 'User', { id: newDirector }),
userNode,
]),
),
)
.apply(createMember)
.apply(
createRelationships(ProjectMember, {
in: { member: variable('project') },
out: { user: ['User', newDirector] },
out: { user: user instanceof Variable ? user : ['User', user] },
}),
)
.return('node as member'),
)
.return<{ id: ID }>('project.id as id')
.run();
return {
projects: result.map(({ id }) => id) as readonly ID[],
timestampId: nowVal,
};
);
}
}

Expand Down
Loading