Skip to content

Commit 996453f

Browse files
authored
Merge pull request #3508 from SeedCompany/project-member/project-membership-by-user
2 parents d956a15 + b3895f3 commit 996453f

8 files changed

+124
-2
lines changed

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
type UnsecuredDto,
1212
} from '~/common';
1313
import { e } from '~/core/gel';
14-
import { RegisterResource } from '~/core/resources';
14+
import { type LinkTo, RegisterResource } from '~/core/resources';
1515
import { SecuredUser, type User } from '../../../user/dto';
1616

1717
@RegisterResource({ db: e.Project.Member })
@@ -21,6 +21,8 @@ import { SecuredUser, type User } from '../../../user/dto';
2121
export class ProjectMember extends Resource {
2222
static readonly Parent = () => import('../../dto').then((m) => m.IProject);
2323

24+
readonly project: LinkTo<'Project'>;
25+
2426
@Field(() => SecuredUser)
2527
readonly user: SecuredUser & SetUnsecuredType<UnsecuredDto<User>>;
2628

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
2+
import { type ID, IdArg } from '~/common';
3+
import { Loader, type LoaderOf } from '~/core/data-loader';
4+
import { IProject, type Project } from '../dto';
5+
import { ProjectMember } from './dto';
6+
import { MembershipByProjectAndUserLoader } from './membership-by-project-and-user.loader';
7+
8+
@Resolver(IProject)
9+
export class MemberProjectConnectionResolver {
10+
@ResolveField(() => ProjectMember)
11+
async membership(
12+
@Parent() project: Project,
13+
@IdArg({ name: 'user' }) userId: ID<'User'>,
14+
@Loader(() => MembershipByProjectAndUserLoader)
15+
loader: LoaderOf<MembershipByProjectAndUserLoader>,
16+
): Promise<ProjectMember> {
17+
const { membership } = await loader.load({
18+
project: project.id,
19+
user: userId,
20+
});
21+
return membership;
22+
}
23+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { type ID } from '~/common';
2+
import {
3+
type DataLoaderStrategy,
4+
LoaderFactory,
5+
type LoaderOptionsOf,
6+
} from '~/core/data-loader';
7+
import { type ProjectMember } from './dto';
8+
import { ProjectMemberService } from './project-member.service';
9+
10+
export interface MembershipByProjectAndUserInput {
11+
project: ID<'Project'>;
12+
user: ID<'User'>;
13+
}
14+
15+
@LoaderFactory()
16+
export class MembershipByProjectAndUserLoader
17+
implements
18+
DataLoaderStrategy<
19+
{ id: MembershipByProjectAndUserInput; membership: ProjectMember },
20+
MembershipByProjectAndUserInput,
21+
string
22+
>
23+
{
24+
constructor(private readonly service: ProjectMemberService) {}
25+
26+
getOptions() {
27+
return {
28+
cacheKeyFn: (input) => `${input.project}:${input.user}`,
29+
} satisfies LoaderOptionsOf<MembershipByProjectAndUserLoader>;
30+
}
31+
32+
async loadMany(input: readonly MembershipByProjectAndUserInput[]) {
33+
return await this.service.readManyByProjectAndUser(input);
34+
}
35+
}

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import {
77
ProjectMember,
88
type ProjectMemberListInput,
99
} from './dto';
10+
import type { MembershipByProjectAndUserInput } from './membership-by-project-and-user.loader';
1011
import { type ProjectMemberRepository as Neo4jRepository } from './project-member.repository';
1112

1213
@Injectable()
1314
export class ProjectMemberGelRepository
1415
extends RepoFor(ProjectMember, {
1516
hydrate: (member) => ({
1617
...member['*'],
18+
project: true,
1719
user: hydrateUser(member.user),
1820
}),
1921
omit: ['create'],
@@ -38,6 +40,29 @@ export class ProjectMemberGelRepository
3840
return await this.db.run(query);
3941
}
4042

43+
async readManyByProjectAndUser(
44+
input: readonly MembershipByProjectAndUserInput[],
45+
) {
46+
return await this.db.run(this.readManyByProjectAndUserQuery, { input });
47+
}
48+
private readonly readManyByProjectAndUserQuery = e.params(
49+
{
50+
input: e.array(e.tuple({ project: e.uuid, user: e.uuid })),
51+
},
52+
({ input }) =>
53+
e.select(e.Project.Member, (member) => ({
54+
...this.hydrate(member),
55+
filter: e.op(
56+
e.tuple({
57+
project: member.project.id,
58+
user: member.user.id,
59+
}),
60+
'in',
61+
e.array_unpack(input),
62+
),
63+
})),
64+
);
65+
4166
async listAsNotifiers(projectId: ID, roles?: Role[]) {
4267
const project = e.cast(e.Project, e.uuid(projectId));
4368
const members = e.select(project.members, (member) => ({

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ 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';
88
import { RegionsZoneChangesAppliesDirectorChangeToProjectMembersHandler } from './handlers/regions-zone-changes-applies-director-change-to-project-members.handler';
9+
import { MemberProjectConnectionResolver } from './member-project-connection.resolver';
910
import { AddInactiveAtMigration } from './migrations/add-inactive-at.migration';
1011
import { ProjectMemberGelRepository } from './project-member.gel.repository';
1112
import { ProjectMemberLoader } from './project-member.loader';
@@ -22,6 +23,7 @@ import { ProjectMemberService } from './project-member.service';
2223
providers: [
2324
ProjectMemberResolver,
2425
AvailableRolesToProjectResolver,
26+
MemberProjectConnectionResolver,
2527
ProjectMemberService,
2628
splitDb(ProjectMemberRepository, ProjectMemberGelRepository),
2729
ProjectMemberLoader,

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
type ProjectMemberListInput,
4646
type UpdateProjectMember,
4747
} from './dto';
48+
import { type MembershipByProjectAndUserInput } from './membership-by-project-and-user.loader';
4849

4950
@Injectable()
5051
export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
@@ -149,10 +150,31 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) {
149150
sub.with('user as node').apply(this.users.hydrateAsNeo4j()),
150151
)
151152
.return<{ dto: UnsecuredDto<ProjectMember> }>(
152-
merge('props', { user: 'dto' }).as('dto'),
153+
merge('props', {
154+
project: 'project { .id }',
155+
user: 'dto',
156+
}).as('dto'),
153157
);
154158
}
155159

160+
async readManyByProjectAndUser(
161+
input: readonly MembershipByProjectAndUserInput[],
162+
) {
163+
return await this.db
164+
.query()
165+
.unwind([...input], 'input')
166+
.match([
167+
node('project', 'Project', { id: variable('input.project') }),
168+
relation('out', '', 'member', ACTIVE),
169+
node('node', 'ProjectMember'),
170+
relation('out', '', 'user', ACTIVE),
171+
node('user', 'User', { id: variable('input.user') }),
172+
])
173+
.apply(this.hydrate())
174+
.map('dto')
175+
.run();
176+
}
177+
156178
async list({ filter, ...input }: ProjectMemberListInput) {
157179
const result = await this.db
158180
.query()

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type ProjectMemberListOutput,
2222
type UpdateProjectMember,
2323
} from './dto';
24+
import { type MembershipByProjectAndUserInput } from './membership-by-project-and-user.loader';
2425
import { ProjectMemberRepository } from './project-member.repository';
2526

2627
@Injectable()
@@ -75,6 +76,16 @@ export class ProjectMemberService {
7576
return projectMembers.map((dto) => this.secure(dto));
7677
}
7778

79+
async readManyByProjectAndUser(
80+
input: readonly MembershipByProjectAndUserInput[],
81+
) {
82+
const dtos = await this.repo.readManyByProjectAndUser(input);
83+
return dtos.map((dto) => ({
84+
id: { project: dto.project.id, user: dto.user.id },
85+
membership: this.secure(dto),
86+
}));
87+
}
88+
7889
private secure(dto: UnsecuredDto<ProjectMember>): ProjectMember {
7990
const { user, ...secured } = this.privileges.for(ProjectMember).secure(dto);
8091
return {

src/components/user/dto/list-users.dto.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { InputType, ObjectType } from '@nestjs/graphql';
22
import {
33
FilterField,
44
type ID,
5+
IdField,
56
ListField,
67
OptionalField,
78
PaginatedList,
@@ -12,6 +13,7 @@ import { User } from './user.dto';
1213

1314
@InputType()
1415
export abstract class UserFilters {
16+
@IdField({ optional: true })
1517
readonly id?: ID<'User'>;
1618

1719
@OptionalField()

0 commit comments

Comments
 (0)