diff --git a/src/components/project/project-member/project-member.edgedb.repository.ts b/src/components/project/project-member/project-member.edgedb.repository.ts new file mode 100644 index 0000000000..2aeda691fb --- /dev/null +++ b/src/components/project/project-member/project-member.edgedb.repository.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { isIdLike, PublicOf } from '~/common'; +import { e, RepoFor, ScopeOf } from '~/core/edgedb'; +import { + CreateProjectMember, + ProjectMember, + ProjectMemberListInput, +} from './dto'; +import { ProjectMemberRepository as Neo4jRepository } from './project-member.repository'; + +@Injectable() +export class ProjectMemberEdgeDBRepository + extends RepoFor(ProjectMember, { + hydrate: (member) => ({ + ...member['*'], + user: member.user['*'], + }), + omit: ['create'], + }) + implements PublicOf +{ + async create({ projectId: projectOrId, userId, roles }: CreateProjectMember) { + const projectId = isIdLike(projectOrId) ? projectOrId : projectOrId.id; + const project = e.cast(e.Project, e.uuid(projectId)); + + const created = e.insert(this.resource.db, { + user: e.cast(e.User, e.uuid(userId)), + project, + projectContext: project.projectContext, + roles, + }); + const query = e.select(created, this.hydrate); + return await this.db.run(query); + } + + protected listFilters( + member: ScopeOf, + { filter: input }: ProjectMemberListInput, + ) { + return [ + (input.roles?.length ?? 0) > 0 && + e.op( + 'exists', + e.op( + member.roles, + 'intersect', + e.cast(e.Role, e.set(...input.roles!)), + ), + ), + ]; + } +} diff --git a/src/components/project/project-member/project-member.module.ts b/src/components/project/project-member/project-member.module.ts index 9689ceff8e..827ac2c33a 100644 --- a/src/components/project/project-member/project-member.module.ts +++ b/src/components/project/project-member/project-member.module.ts @@ -1,7 +1,9 @@ import { forwardRef, Module } from '@nestjs/common'; +import { splitDb } from '~/core'; import { AuthorizationModule } from '../../authorization/authorization.module'; import { UserModule } from '../../user/user.module'; import { ProjectModule } from '../project.module'; +import { ProjectMemberEdgeDBRepository } from './project-member.edgedb.repository'; import { ProjectMemberLoader } from './project-member.loader'; import { ProjectMemberRepository } from './project-member.repository'; import { ProjectMemberResolver } from './project-member.resolver'; @@ -16,7 +18,7 @@ import { ProjectMemberService } from './project-member.service'; providers: [ ProjectMemberResolver, ProjectMemberService, - ProjectMemberRepository, + splitDb(ProjectMemberRepository, ProjectMemberEdgeDBRepository), ProjectMemberLoader, ], 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 cb28149ab3..0a863b6c2e 100644 --- a/src/components/project/project-member/project-member.repository.ts +++ b/src/components/project/project-member/project-member.repository.ts @@ -1,19 +1,27 @@ import { Injectable } from '@nestjs/common'; import { Node, node, Query, relation } from 'cypher-query-builder'; import { DateTime } from 'luxon'; -import { ChangesOf } from '~/core/database/changes'; -import { ID, Session, UnsecuredDto } from '../../../common'; -import { DtoRepository } from '../../../core'; +import { + DuplicateException, + ID, + isIdLike, + NotFoundException, + ServerException, + Session, + UnsecuredDto, +} from '~/common'; +import { DtoRepository } from '~/core/database'; import { ACTIVE, + createNode, + createRelationships, matchPropsAndProjectSensAndScopedRoles, merge, oncePerProject, paginate, - property, requestingUser, sorting, -} from '../../../core/database/query'; +} from '~/core/database/query'; import { UserRepository } from '../../user/user.repository'; import { CreateProjectMember, @@ -31,8 +39,8 @@ export class ProjectMemberRepository extends DtoRepository< super(); } - async verifyRelationshipEligibility(projectId: ID, userId: ID) { - return await this.db + private async verifyRelationshipEligibility(projectId: ID, userId: ID) { + const result = await this.db .query() .optionalMatch(node('user', 'User', { id: userId })) .optionalMatch(node('project', 'Project', { id: projectId })) @@ -43,63 +51,69 @@ export class ProjectMemberRepository extends DtoRepository< relation('out', '', 'user', ACTIVE), node('user'), ]) - .return(['user', 'project', 'member']) - .asResult<{ user?: Node; project?: Node; member?: Node }>() + .return<{ user?: Node; project?: Node; member?: Node }>([ + 'user', + 'project', + 'member', + ]) .first(); + + if (!result?.project) { + throw new NotFoundException( + 'Could not find project', + 'projectMember.projectId', + ); + } + if (!result?.user) { + throw new NotFoundException( + 'Could not find person', + 'projectMember.userId', + ); + } + if (result.member) { + throw new DuplicateException( + 'projectMember.userId', + 'Person is already a member of this project', + ); + } } async create( - { userId, projectId, ...input }: CreateProjectMember, - id: ID, + { userId, projectId: projectOrId, ...input }: CreateProjectMember, session: Session, - createdAt: DateTime, ) { - const createProjectMember = this.db - .query() - .create([ - [ - node('newProjectMember', 'ProjectMember:BaseNode', { - createdAt, - id, - }), - ], - ...property('roles', input.roles, 'newProjectMember'), - ...property('modifiedAt', createdAt, 'newProjectMember'), - ]) - .return<{ id: ID }>('newProjectMember.id as id'); - await createProjectMember.first(); + const projectId = isIdLike(projectOrId) ? projectOrId : projectOrId.id; + + await this.verifyRelationshipEligibility(projectId, userId); - // connect the Project to the ProjectMember - // and connect ProjectMember to User - return await this.db + const created = await this.db .query() - .match([ - [node('user', 'User', { id: userId })], - [node('project', 'Project', { id: projectId })], - [node('projectMember', 'ProjectMember', { id })], - ]) - .create([ - node('project'), - relation('out', '', 'member', { - active: true, - createdAt: DateTime.local(), + .apply( + await createNode(ProjectMember, { + initialProps: { + roles: input.roles ?? [], + modifiedAt: DateTime.local(), + }, }), - node('projectMember'), - relation('out', '', 'user', { - active: true, - createdAt: DateTime.local(), + ) + .apply( + createRelationships(ProjectMember, { + in: { member: ['Project', projectId] }, + out: { user: ['User', userId] }, }), - node('user'), - ]) - .return<{ id: ID }>('projectMember.id as id') + ) + .apply(this.hydrate(session)) + .map('dto') .first(); + if (!created) { + throw new ServerException('Failed to create project member'); + } + return created; } - async update( - existing: ProjectMember, - changes: ChangesOf, - ) { - await this.updateProperties(existing, changes); + async update({ id, ...changes }: UpdateProjectMember, session: Session) { + await this.updateProperties({ id }, changes); + return await this.readOne(id, session); } protected hydrate(session: Session) { diff --git a/src/components/project/project-member/project-member.service.ts b/src/components/project/project-member/project-member.service.ts index ea182e7759..1157b61e86 100644 --- a/src/components/project/project-member/project-member.service.ts +++ b/src/components/project/project-member/project-member.service.ts @@ -1,37 +1,19 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { MaybeAsync } from '@seedcompany/common'; -import { node, Query, relation } from 'cypher-query-builder'; -import { RelationDirection } from 'cypher-query-builder/dist/typings/clauses/relation-pattern'; import { difference } from 'lodash'; -import { DateTime } from 'luxon'; import { - DuplicateException, - generateId, ID, InputException, - isIdLike, - mapSecuredValue, NotFoundException, ObjectView, ServerException, Session, UnauthorizedException, UnsecuredDto, -} from '../../../common'; -import { - ConfigService, - DatabaseService, - HandleIdLookup, - IEventBus, - ILogger, - Logger, -} from '../../../core'; -import { ACTIVE } from '../../../core/database/query'; -import { mapListResults } from '../../../core/database/results'; +} from '~/common'; +import { HandleIdLookup, ResourceLoader } from '~/core'; import { Privileges, Role } from '../../authorization'; import { User, UserService } from '../../user'; -import { IProject } from '../dto'; -import { ProjectService } from '../project.service'; import { CreateProjectMember, ProjectMember, @@ -44,88 +26,29 @@ import { ProjectMemberRepository } from './project-member.repository'; @Injectable() export class ProjectMemberService { constructor( - private readonly db: DatabaseService, - private readonly config: ConfigService, @Inject(forwardRef(() => UserService)) private readonly userService: UserService & {}, - private readonly eventBus: IEventBus, - @Inject(forwardRef(() => ProjectService)) - private readonly projectService: ProjectService & {}, - @Logger('project:member:service') private readonly logger: ILogger, + private readonly resources: ResourceLoader, private readonly privileges: Privileges, private readonly repo: ProjectMemberRepository, ) {} - protected async verifyRelationshipEligibility( - projectId: ID, - userId: ID, - ): Promise { - const result = await this.repo.verifyRelationshipEligibility( - projectId, - userId, - ); - - if (!result?.project) { - throw new NotFoundException( - 'Could not find project', - 'projectMember.projectId', - ); - } - - if (!result?.user) { - throw new NotFoundException( - 'Could not find person', - 'projectMember.userId', - ); - } - - if (result.member) { - throw new DuplicateException( - 'projectMember.userId', - 'Person is already a member of this project', - ); - } - } - async create( - { userId, projectId: projectOrId, ...input }: CreateProjectMember, + input: CreateProjectMember, session: Session, enforcePerms = true, ): Promise { - const projectId = isIdLike(projectOrId) ? projectOrId : projectOrId.id; - const project = isIdLike(projectOrId) - ? await this.projectService.readOneUnsecured(projectOrId, session) - : projectOrId; - - enforcePerms && - this.privileges - .for(session, IProject, project) - .verifyCan('create', 'member'); - - const id = await generateId(); - const createdAt = DateTime.local(); - await this.repo.verifyRelationshipEligibility(projectId, userId); - enforcePerms && (await this.assertValidRoles(input.roles, () => - this.userService.readOne(userId, session), + this.resources.load('User', input.userId), )); - try { - const memberQuery = await this.repo.create( - { userId, projectId, ...input }, - id, - session, - createdAt, - ); - if (!memberQuery) { - throw new ServerException('Failed to create project member'); - } + const created = await this.repo.create(input, session); - return await this.readOne(id, session); - } catch (exception) { - throw new ServerException('Could not create project member', exception); - } + enforcePerms && + this.privileges.for(session, ProjectMember, created).verifyCan('create'); + + return this.secure(created, session); } @HandleIdLookup(ProjectMember) @@ -134,10 +57,6 @@ export class ProjectMemberService { session: Session, _view?: ObjectView, ): Promise { - this.logger.debug(`read one`, { - id, - userId: session.userId, - }); if (!id) { throw new NotFoundException( 'No project member id to search for', @@ -146,29 +65,33 @@ export class ProjectMemberService { } const dto = await this.repo.readOne(id, session); - return await this.secure(dto, session); + return this.secure(dto, session); } async readMany(ids: readonly ID[], session: Session) { const projectMembers = await this.repo.readMany(ids, session); - return await Promise.all( - projectMembers.map((dto) => this.secure(dto, session)), - ); + return projectMembers.map((dto) => this.secure(dto, session)); } - private async secure( + private secure( dto: UnsecuredDto, session: Session, - ): Promise { - const secured = this.privileges.for(session, ProjectMember).secure({ - ...dto, - roles: dto.roles ?? [], - }); + ): ProjectMember { + const { user, ...secured } = this.privileges + .for(session, ProjectMember) + .secure(dto); return { ...secured, - user: await mapSecuredValue(secured.user, (user) => - this.userService.secure(user, session), - ), + user: { + ...user, + value: + user.value && user.canRead + ? this.userService.secure( + user.value as unknown as UnsecuredDto, + session, + ) + : undefined, + }, }; } @@ -190,8 +113,12 @@ export class ProjectMemberService { const changes = this.repo.getActualChanges(object, input); this.privileges.for(session, ProjectMember, object).verifyChanges(changes); - await this.repo.update(object, changes); - return await this.readOne(input.id, session); + + const updated = await this.repo.update( + { id: object.id, ...changes }, + session, + ); + return this.secure(updated, session); } private async assertValidRoles( @@ -216,20 +143,11 @@ export class ProjectMemberService { async delete(id: ID, session: Session): Promise { const object = await this.readOne(id, session); - if (!object) { - throw new NotFoundException( - 'Could not find project member', - 'projectMember.id', - ); - } + this.privileges.for(session, ProjectMember, object).verifyCan('delete'); try { await this.repo.deleteNode(object); } catch (exception) { - this.logger.warning('Failed to delete project member', { - exception, - }); - throw new ServerException('Failed to delete project member', exception); } } @@ -239,20 +157,9 @@ export class ProjectMemberService { session: Session, ): Promise { const results = await this.repo.list(input, session); - return await mapListResults(results, (dto) => this.secure(dto, session)); - } - - protected filterByProject( - query: Query, - projectId: ID, - relationshipType: string, - relationshipDirection: RelationDirection, - label: string, - ) { - query.match([ - node('project', 'Project', { id: projectId }), - relation(relationshipDirection, '', relationshipType, ACTIVE), - node('node', label), - ]); + return { + ...results, + items: results.items.map((dto) => this.secure(dto, session)), + }; } } diff --git a/src/components/user/user.service.ts b/src/components/user/user.service.ts index 36d9ef8bed..7066b92f58 100644 --- a/src/components/user/user.service.ts +++ b/src/components/user/user.service.ts @@ -11,7 +11,6 @@ import { } from '../../common'; import { HandleIdLookup, ILogger, Logger, Transactional } from '../../core'; import { property } from '../../core/database/query'; -import { mapListResults } from '../../core/database/results'; import { Privileges, Role } from '../authorization'; import { AssignableRoles } from '../authorization/dto/assignable-roles'; import { @@ -86,15 +85,15 @@ export class UserService { @HandleIdLookup(User) async readOne(id: ID, session: Session, _view?: ObjectView): Promise { const user = await this.userRepo.readOne(id, session); - return await this.secure(user, session); + return this.secure(user, session); } async readMany(ids: readonly ID[], session: Session) { const users = await this.userRepo.readMany(ids, session); - return await Promise.all(users.map((dto) => this.secure(dto, session))); + return users.map((dto) => this.secure(dto, session)); } - async secure(user: UnsecuredDto, session: Session): Promise { + secure(user: UnsecuredDto, session: Session): User { return this.privileges.for(session, User).secure(user); } @@ -115,7 +114,7 @@ export class UserService { id: user.id, ...changes, }); - return await this.secure(updated, session); + return this.secure(updated, session); } async delete(id: ID, session: Session): Promise { @@ -125,7 +124,10 @@ export class UserService { async list(input: UserListInput, session: Session): Promise { const results = await this.userRepo.list(input, session); - return await mapListResults(results, (dto) => this.secure(dto, session)); + return { + ...results, + items: results.items.map((dto) => this.secure(dto, session)), + }; } @CachedByArg({ weak: true })