diff --git a/src/components/project/project-member/handlers/project-region-defaults-director-membership.handler.ts b/src/components/project/project-member/handlers/project-region-defaults-director-membership.handler.ts new file mode 100644 index 0000000000..a73f39da99 --- /dev/null +++ b/src/components/project/project-member/handlers/project-region-defaults-director-membership.handler.ts @@ -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, + ); + } + } + } +} diff --git a/src/components/project/project-member/migrations/backfill-missing-directors.migration.ts b/src/components/project/project-member/migrations/backfill-missing-directors.migration.ts new file mode 100644 index 0000000000..054d4e8794 --- /dev/null +++ b/src/components/project/project-member/migrations/backfill-missing-directors.migration.ts @@ -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(); + } +} diff --git a/src/components/project/project-member/project-member.gel.repository.ts b/src/components/project/project-member/project-member.gel.repository.ts index 96f17bd596..6f6a59c7b2 100644 --- a/src/components/project/project-member/project-member.gel.repository.ts +++ b/src/components/project/project-member/project-member.gel.repository.ts @@ -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'>, diff --git a/src/components/project/project-member/project-member.module.ts b/src/components/project/project-member/project-member.module.ts index 0856380ca5..b5ad873536 100644 --- a/src/components/project/project-member/project-member.module.ts +++ b/src/components/project/project-member/project-member.module.ts @@ -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'; @@ -28,6 +30,8 @@ import { ProjectMemberService } from './project-member.service'; AddInactiveAtMigration, DirectorChangeApplyToProjectMembersHandler, RegionsZoneChangesAppliesDirectorChangeToProjectMembersHandler, + ProjectRegionDefaultsDirectorMembershipHandler, + BackfillMissingDirectorsMigration, ], exports: [ProjectMemberService], }) diff --git a/src/components/project/project-member/project-member.repository.ts b/src/components/project/project-member/project-member.repository.ts index 5bc4df66d1..7dd7ffcf88 100644 --- a/src/components/project/project-member/project-member.repository.ts +++ b/src/components/project/project-member/project-member.repository.ts @@ -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'; @@ -201,6 +203,41 @@ 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'>, @@ -208,20 +245,11 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) { ) { 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), @@ -244,7 +272,35 @@ 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([ [ @@ -252,7 +308,7 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) { relation('out', '', 'member', ACTIVE), node('node', 'ProjectMember'), relation('out', '', 'user', ACTIVE), - node('', 'User', { id: newDirector }), + userNode, ], [ node('node', 'ProjectMember'), @@ -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( @@ -277,7 +333,7 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) { value: null, now, permanentAfter: 0, - outputStatsVar: 'rolesStats', + outputStatsVar: 'inactiveStats', }), ) .apply( @@ -292,8 +348,8 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) { ) .return('node as member') .union() - .with('project') - .with('project') + .with(scope) + .with(scope) .where( not( path([ @@ -301,7 +357,7 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) { relation('out', '', 'member', ACTIVE), node('', 'ProjectMember'), relation('out', '', 'user', ACTIVE), - node('', 'User', { id: newDirector }), + userNode, ]), ), ) @@ -309,17 +365,11 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) { .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, - }; + ); } } diff --git a/test/features/project-region-defaults-director-membership.e2e-spec.ts b/test/features/project-region-defaults-director-membership.e2e-spec.ts new file mode 100644 index 0000000000..1c9179e4b8 --- /dev/null +++ b/test/features/project-region-defaults-director-membership.e2e-spec.ts @@ -0,0 +1,196 @@ +import { mapEntries } from '@seedcompany/common'; +import { DateTime } from 'luxon'; +import { Role } from '~/common'; +import { graphql, type VariablesOf } from '~/graphql'; +import { + createProject, + createProjectMember, + createRegion, + createSession, + createTestApp, + loginAsAdmin, + type TestApp, +} from '../utility'; + +let app: TestApp; + +beforeAll(async () => { + app = await createTestApp(); + await createSession(app); + await loginAsAdmin(app); +}); +afterAll(async () => { + await app.close(); +}); + +it('add directors if role is needed on project', async () => { + const region = await createRegion(app); + const project = await createProject(app); + + const members = await assignRegionAndFetchMembers(app, { + project: project.id, + region: region.id, + }); + + expect(members.get(region.director.value!.id)).toEqual( + expect.objectContaining({ + active: true, + roles: [Role.RegionalDirector], + }), + ); + expect(members.get(region.fieldZone.value!.director.value!.id)).toEqual( + expect.objectContaining({ + active: true, + roles: [Role.FieldOperationsDirector], + }), + ); +}); + +it('add directors if role is inactive on project', async () => { + // region setup + const newRegion = await createRegion(app); + const oldRegion = await createRegion(app); + const project = await createProject(app); + await Promise.all([ + createProjectMember(app, { + projectId: project.id, + userId: oldRegion.director.value!.id, + roles: [Role.RegionalDirector], + inactiveAt: DateTime.now().plus({ minute: 1 }).toISO(), + }), + createProjectMember(app, { + projectId: project.id, + userId: oldRegion.fieldZone.value!.director.value!.id, + roles: [Role.FieldOperationsDirector], + inactiveAt: DateTime.now().plus({ minute: 1 }).toISO(), + }), + ]); + // endregion + + const members = await assignRegionAndFetchMembers(app, { + project: project.id, + region: newRegion.id, + }); + + expect(members.get(oldRegion.director.value!.id)).toEqual( + expect.objectContaining({ + active: false, + roles: [Role.RegionalDirector], + }), + ); + expect(members.get(newRegion.director.value!.id)).toEqual( + expect.objectContaining({ + active: true, + roles: [Role.RegionalDirector], + }), + ); + expect(members.get(oldRegion.fieldZone.value!.director.value!.id)).toEqual( + expect.objectContaining({ + active: false, + roles: [Role.FieldOperationsDirector], + }), + ); + expect(members.get(newRegion.fieldZone.value!.director.value!.id)).toEqual( + expect.objectContaining({ + active: true, + roles: [Role.FieldOperationsDirector], + }), + ); +}); + +it('update existing member on project', async () => { + // region setup + const region = await createRegion(app); + const project = await createProject(app); + await createProjectMember(app, { + projectId: project.id, + userId: region.director.value!.id, + roles: [Role.ProjectManager], + }); + // endregion + + const members = await assignRegionAndFetchMembers(app, { + project: project.id, + region: region.id, + }); + + expect(members.get(region.director.value!.id)).toEqual( + expect.objectContaining({ + active: true, + roles: expect.arrayContaining([ + Role.RegionalDirector, + Role.ProjectManager, + ]), + }), + ); +}); + +it('ignore directors if role is not needed on project', async () => { + // region setup + const region = await createRegion(app); + const unrelatedRegion = await createRegion(app); + const project = await createProject(app); + await Promise.all([ + createProjectMember(app, { + projectId: project.id, + userId: unrelatedRegion.director.value!.id, + roles: [Role.RegionalDirector], + }), + createProjectMember(app, { + projectId: project.id, + userId: unrelatedRegion.fieldZone.value!.director.value!.id, + roles: [Role.FieldOperationsDirector], + }), + ]); + // endregion + + const members = await assignRegionAndFetchMembers(app, { + project: project.id, + region: region.id, + }); + + expect(members.get(region.director.value!.id)).toBeUndefined(); + expect( + members.get(region.fieldZone.value!.director.value!.id), + ).toBeUndefined(); +}); + +async function assignRegionAndFetchMembers( + app: TestApp, + input: VariablesOf, +) { + const res = await app.graphql.query(AssignRegionDoc, input); + const members = res.updateProject.project.team.items.map((m) => ({ + user: m.user.value!.id, + active: m.active, + roles: m.roles.value, + })); + if (members.length !== new Set(members.map((m) => m.user)).size) { + throw new Error('Duplicate members detected'); + } + return mapEntries(members, (m) => [m.user, m]).asMap; +} + +const AssignRegionDoc = graphql(` + mutation AssignRegion($project: ID!, $region: ID!) { + updateProject( + input: { project: { id: $project, fieldRegionId: $region } } + ) { + project { + team { + items { + user { + value { + id + } + } + roles { + value + } + active + } + } + } + } + } +`); diff --git a/test/utility/create-region.ts b/test/utility/create-region.ts index ecdee96b73..5e1991296a 100644 --- a/test/utility/create-region.ts +++ b/test/utility/create-region.ts @@ -47,23 +47,9 @@ const CreateFieldRegionDoc = graphql( createFieldRegion(input: { fieldRegion: $input }) { fieldRegion { ...fieldRegion - fieldZone { - value { - ...fieldZone - } - canRead - canEdit - } - director { - value { - ...user - } - canRead - canEdit - } } } } `, - [fragments.fieldRegion, fragments.fieldZone, fragments.user], + [fragments.fieldRegion], ); diff --git a/test/utility/fragments.ts b/test/utility/fragments.ts index e004f4c470..c12650d3c7 100644 --- a/test/utility/fragments.ts +++ b/test/utility/fragments.ts @@ -903,6 +903,9 @@ export const fieldZone = graphql(` director { canRead canEdit + value { + id + } } name { value @@ -913,25 +916,34 @@ export const fieldZone = graphql(` `); export type fieldZone = FragmentOf; -export const fieldRegion = graphql(` - fragment fieldRegion on FieldRegion { - id - createdAt - name { - value - canEdit - canRead - } - fieldZone { - canRead - canEdit - } - director { - canRead - canEdit +export const fieldRegion = graphql( + ` + fragment fieldRegion on FieldRegion { + id + createdAt + name { + value + canEdit + canRead + } + fieldZone { + canRead + canEdit + value { + ...fieldZone + } + } + director { + canRead + canEdit + value { + id + } + } } - } -`); + `, + [fieldZone], +); export type fieldRegion = FragmentOf; export const budgetRecord = graphql(