diff --git a/src/components/project/project-member/dto/project-member.dto.ts b/src/components/project/project-member/dto/project-member.dto.ts index 0772e0909d..91f319e3fe 100644 --- a/src/components/project/project-member/dto/project-member.dto.ts +++ b/src/components/project/project-member/dto/project-member.dto.ts @@ -11,7 +11,7 @@ import { type UnsecuredDto, } from '~/common'; import { e } from '~/core/gel'; -import { RegisterResource } from '~/core/resources'; +import { type LinkTo, RegisterResource } from '~/core/resources'; import { SecuredUser, type User } from '../../../user/dto'; @RegisterResource({ db: e.Project.Member }) @@ -21,6 +21,8 @@ import { SecuredUser, type User } from '../../../user/dto'; export class ProjectMember extends Resource { static readonly Parent = () => import('../../dto').then((m) => m.IProject); + readonly project: LinkTo<'Project'>; + @Field(() => SecuredUser) readonly user: SecuredUser & SetUnsecuredType>; diff --git a/src/components/project/project-member/member-project-connection.resolver.ts b/src/components/project/project-member/member-project-connection.resolver.ts new file mode 100644 index 0000000000..15a5dd9c05 --- /dev/null +++ b/src/components/project/project-member/member-project-connection.resolver.ts @@ -0,0 +1,23 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { type ID, IdArg } from '~/common'; +import { Loader, type LoaderOf } from '~/core/data-loader'; +import { IProject, type Project } from '../dto'; +import { ProjectMember } from './dto'; +import { MembershipByProjectAndUserLoader } from './membership-by-project-and-user.loader'; + +@Resolver(IProject) +export class MemberProjectConnectionResolver { + @ResolveField(() => ProjectMember) + async membership( + @Parent() project: Project, + @IdArg({ name: 'user' }) userId: ID<'User'>, + @Loader(() => MembershipByProjectAndUserLoader) + loader: LoaderOf, + ): Promise { + const { membership } = await loader.load({ + project: project.id, + user: userId, + }); + return membership; + } +} diff --git a/src/components/project/project-member/membership-by-project-and-user.loader.ts b/src/components/project/project-member/membership-by-project-and-user.loader.ts new file mode 100644 index 0000000000..8c28240763 --- /dev/null +++ b/src/components/project/project-member/membership-by-project-and-user.loader.ts @@ -0,0 +1,35 @@ +import { type ID } from '~/common'; +import { + type DataLoaderStrategy, + LoaderFactory, + type LoaderOptionsOf, +} from '~/core/data-loader'; +import { type ProjectMember } from './dto'; +import { ProjectMemberService } from './project-member.service'; + +export interface MembershipByProjectAndUserInput { + project: ID<'Project'>; + user: ID<'User'>; +} + +@LoaderFactory() +export class MembershipByProjectAndUserLoader + implements + DataLoaderStrategy< + { id: MembershipByProjectAndUserInput; membership: ProjectMember }, + MembershipByProjectAndUserInput, + string + > +{ + constructor(private readonly service: ProjectMemberService) {} + + getOptions() { + return { + cacheKeyFn: (input) => `${input.project}:${input.user}`, + } satisfies LoaderOptionsOf; + } + + async loadMany(input: readonly MembershipByProjectAndUserInput[]) { + return await this.service.readManyByProjectAndUser(input); + } +} 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..f4dffb6777 100644 --- a/src/components/project/project-member/project-member.gel.repository.ts +++ b/src/components/project/project-member/project-member.gel.repository.ts @@ -7,6 +7,7 @@ import { ProjectMember, type ProjectMemberListInput, } from './dto'; +import type { MembershipByProjectAndUserInput } from './membership-by-project-and-user.loader'; import { type ProjectMemberRepository as Neo4jRepository } from './project-member.repository'; @Injectable() @@ -14,6 +15,7 @@ export class ProjectMemberGelRepository extends RepoFor(ProjectMember, { hydrate: (member) => ({ ...member['*'], + project: true, user: hydrateUser(member.user), }), omit: ['create'], @@ -38,6 +40,29 @@ export class ProjectMemberGelRepository return await this.db.run(query); } + async readManyByProjectAndUser( + input: readonly MembershipByProjectAndUserInput[], + ) { + return await this.db.run(this.readManyByProjectAndUserQuery, { input }); + } + private readonly readManyByProjectAndUserQuery = e.params( + { + input: e.array(e.tuple({ project: e.uuid, user: e.uuid })), + }, + ({ input }) => + e.select(e.Project.Member, (member) => ({ + ...this.hydrate(member), + filter: e.op( + e.tuple({ + project: member.project.id, + user: member.user.id, + }), + 'in', + e.array_unpack(input), + ), + })), + ); + async listAsNotifiers(projectId: ID, roles?: Role[]) { const project = e.cast(e.Project, e.uuid(projectId)); const members = e.select(project.members, (member) => ({ diff --git a/src/components/project/project-member/project-member.module.ts b/src/components/project/project-member/project-member.module.ts index 0856380ca5..c6f6e4e2ec 100644 --- a/src/components/project/project-member/project-member.module.ts +++ b/src/components/project/project-member/project-member.module.ts @@ -6,6 +6,7 @@ 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 { RegionsZoneChangesAppliesDirectorChangeToProjectMembersHandler } from './handlers/regions-zone-changes-applies-director-change-to-project-members.handler'; +import { MemberProjectConnectionResolver } from './member-project-connection.resolver'; import { AddInactiveAtMigration } from './migrations/add-inactive-at.migration'; import { ProjectMemberGelRepository } from './project-member.gel.repository'; import { ProjectMemberLoader } from './project-member.loader'; @@ -22,6 +23,7 @@ import { ProjectMemberService } from './project-member.service'; providers: [ ProjectMemberResolver, AvailableRolesToProjectResolver, + MemberProjectConnectionResolver, ProjectMemberService, splitDb(ProjectMemberRepository, ProjectMemberGelRepository), ProjectMemberLoader, diff --git a/src/components/project/project-member/project-member.repository.ts b/src/components/project/project-member/project-member.repository.ts index 5bc4df66d1..72e06b1b93 100644 --- a/src/components/project/project-member/project-member.repository.ts +++ b/src/components/project/project-member/project-member.repository.ts @@ -44,6 +44,7 @@ import { type ProjectMemberListInput, type UpdateProjectMember, } from './dto'; +import { type MembershipByProjectAndUserInput } from './membership-by-project-and-user.loader'; @Injectable() export class ProjectMemberRepository extends DtoRepository(ProjectMember) { @@ -148,10 +149,31 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) { sub.with('user as node').apply(this.users.hydrateAsNeo4j()), ) .return<{ dto: UnsecuredDto }>( - merge('props', { user: 'dto' }).as('dto'), + merge('props', { + project: 'project { .id }', + user: 'dto', + }).as('dto'), ); } + async readManyByProjectAndUser( + input: readonly MembershipByProjectAndUserInput[], + ) { + return await this.db + .query() + .unwind([...input], 'input') + .match([ + node('project', 'Project', { id: variable('input.project') }), + relation('out', '', 'member', ACTIVE), + node('node', 'ProjectMember'), + relation('out', '', 'user', ACTIVE), + node('user', 'User', { id: variable('input.user') }), + ]) + .apply(this.hydrate()) + .map('dto') + .run(); + } + async list({ filter, ...input }: ProjectMemberListInput) { const result = await this.db .query() diff --git a/src/components/project/project-member/project-member.service.ts b/src/components/project/project-member/project-member.service.ts index 5a1668e587..a12524dded 100644 --- a/src/components/project/project-member/project-member.service.ts +++ b/src/components/project/project-member/project-member.service.ts @@ -21,6 +21,7 @@ import { type ProjectMemberListOutput, type UpdateProjectMember, } from './dto'; +import { type MembershipByProjectAndUserInput } from './membership-by-project-and-user.loader'; import { ProjectMemberRepository } from './project-member.repository'; @Injectable() @@ -75,6 +76,16 @@ export class ProjectMemberService { return projectMembers.map((dto) => this.secure(dto)); } + async readManyByProjectAndUser( + input: readonly MembershipByProjectAndUserInput[], + ) { + const dtos = await this.repo.readManyByProjectAndUser(input); + return dtos.map((dto) => ({ + id: { project: dto.project.id, user: dto.user.id }, + membership: this.secure(dto), + })); + } + private secure(dto: UnsecuredDto): ProjectMember { const { user, ...secured } = this.privileges.for(ProjectMember).secure(dto); return { diff --git a/src/components/user/dto/list-users.dto.ts b/src/components/user/dto/list-users.dto.ts index 82d8b7b889..c22f6ebf0c 100644 --- a/src/components/user/dto/list-users.dto.ts +++ b/src/components/user/dto/list-users.dto.ts @@ -2,6 +2,7 @@ import { InputType, ObjectType } from '@nestjs/graphql'; import { FilterField, type ID, + IdField, ListField, OptionalField, PaginatedList, @@ -12,6 +13,7 @@ import { User } from './user.dto'; @InputType() export abstract class UserFilters { + @IdField({ optional: true }) readonly id?: ID<'User'>; @OptionalField()