diff --git a/src/components/progress-report/workflow/progress-report-workflow.repository.ts b/src/components/progress-report/workflow/progress-report-workflow.repository.ts index 2fb2e7e3e7..df28df5768 100644 --- a/src/components/progress-report/workflow/progress-report-workflow.repository.ts +++ b/src/components/progress-report/workflow/progress-report-workflow.repository.ts @@ -16,6 +16,7 @@ import { createRelationships, currentUser, merge, + path, sorting, } from '~/core/database/query'; import { ProgressReport, type ProgressReportStatus as Status } from '../dto'; @@ -133,16 +134,23 @@ export class ProgressReportWorkflowRepository extends DtoRepository( const query = this.db .query() .match([ - node('report', 'ProgressReport', { id: reportId }), + node('', 'ProgressReport', { id: reportId }), relation('in', '', ACTIVE), - node('engagement', 'Engagement'), + node('', 'Engagement'), relation('in', '', 'engagement', ACTIVE), - node('project', 'Project'), + node('', 'Project'), relation('out', '', 'member', ACTIVE), node('member', 'ProjectMember'), relation('out', '', 'user', ACTIVE), node('user', 'User'), ]) + .where( + path([ + node('member'), + relation('out', '', 'inactiveAt', ACTIVE), + node('', 'Property', { value: null }), + ]), + ) .match([ node('user'), relation('out', '', 'email', ACTIVE), diff --git a/src/components/project/dto/list-projects.dto.ts b/src/components/project/dto/list-projects.dto.ts index c8f9bd9354..7df7b76ed9 100644 --- a/src/components/project/dto/list-projects.dto.ts +++ b/src/components/project/dto/list-projects.dto.ts @@ -1,6 +1,7 @@ import { InputType, ObjectType } from '@nestjs/graphql'; import { Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; +import { set } from 'lodash'; import { DateFilter, DateTimeFilter, @@ -14,6 +15,7 @@ import { type Sensitivity, SortablePaginationInput, } from '~/common'; +import { Transform } from '~/common/transform.decorator'; import { LocationFilters } from '../../location/dto'; import { PartnershipFilters } from '../../partnership/dto'; import { ProjectMemberFilters } from '../project-member/dto'; @@ -88,21 +90,17 @@ export abstract class ProjectFilters { @ValidateNested() readonly mouEnd?: DateFilter; - @OptionalField({ - description: 'only mine', - deprecationReason: 'Use `isMember` instead.', - }) - readonly mine?: boolean; - - @OptionalField({ - description: 'Only projects that the requesting user is a member of', - }) - readonly isMember?: boolean; - @FilterField(() => ProjectMemberFilters, { description: "Only projects with the requesting user's membership that matches these filters", }) + @Transform(({ value, obj }) => { + // Only ran when GQL specifies membership + if (value.active == null && (obj.mine || obj.isMember)) { + value.active = true; + } + return value; + }) readonly membership?: ProjectMemberFilters & {}; @FilterField(() => ProjectMemberFilters, { @@ -138,6 +136,28 @@ export abstract class ProjectFilters { readonly primaryLocation?: LocationFilters & {}; } +Object.defineProperty(ProjectFilters.prototype, 'mine', { + set(value: boolean) { + // Ensure this is set when membership has not been declared + value && !this.membership && set(this, 'membership.active', true); + }, +}); +OptionalField(() => Boolean, { + description: 'only mine', + deprecationReason: 'Use `isMember` instead.', +})(ProjectFilters.prototype, 'mine'); + +Object.defineProperty(ProjectFilters.prototype, 'isMember', { + set(value: boolean) { + // Ensure this is set when membership has not been declared + value && !this.membership && set(this, 'membership.active', true); + }, +}); +OptionalField(() => Boolean, { + description: + 'Only projects that the requesting user is an active member of. false does nothing.', +})(ProjectFilters.prototype, 'isMember'); + @InputType() export class ProjectListInput extends SortablePaginationInput({ defaultSort: 'name', diff --git a/src/components/project/dto/project.dto.ts b/src/components/project/dto/project.dto.ts index 94aaa3eec0..1d96d3adf6 100644 --- a/src/components/project/dto/project.dto.ts +++ b/src/components/project/dto/project.dto.ts @@ -177,10 +177,11 @@ class Project extends Interfaces { readonly rootDirectory: Secured | null>; - @Field({ - description: 'Is the requesting user a member of this project?', - }) - readonly isMember: boolean; + /** The current user's membership, if any. */ + readonly membership: + | (LinkTo<'ProjectMember'> & + Pick, 'roles' | 'inactiveAt'>) + | null; @Field({ description: stripIndent` diff --git a/src/components/project/project-filters.query.ts b/src/components/project/project-filters.query.ts index d975e8dece..268c940430 100644 --- a/src/components/project/project-filters.query.ts +++ b/src/components/project/project-filters.query.ts @@ -30,13 +30,6 @@ export const projectFilters = filter.define(() => ProjectFilters, { modifiedAt: filter.dateTimeProp(), mouStart: filter.dateTimeProp(), mouEnd: filter.dateTimeProp(), - mine: filter.pathExistsWhenTrue([ - currentUser, - relation('in', '', 'user'), - node('', 'ProjectMember'), - relation('in', '', 'member', ACTIVE), - node('node'), - ]), languageId: filter.pathExists((id) => [ node('node'), relation('out', '', 'engagement', ACTIVE), @@ -51,13 +44,6 @@ export const projectFilters = filter.define(() => ProjectFilters, { relation('out', '', 'partner', ACTIVE), node('', 'Partner', { id }), ]), - isMember: filter.pathExistsWhenTrue([ - currentUser, - relation('in', '', 'user'), - node('', 'ProjectMember'), - relation('in', '', 'member', ACTIVE), - node('node'), - ]), membership: filter.sub(() => projectMemberFilters)((sub) => sub.match([ currentUser, diff --git a/src/components/project/project-member/project-member.repository.ts b/src/components/project/project-member/project-member.repository.ts index fb2ec16e69..5bc4df66d1 100644 --- a/src/components/project/project-member/project-member.repository.ts +++ b/src/components/project/project-member/project-member.repository.ts @@ -181,7 +181,13 @@ export class ProjectMemberRepository extends DtoRepository(ProjectMember) { relation('out', '', 'user', ACTIVE), node('user', 'User'), ]) - .apply(projectMemberFilters({ project: { id: project }, roles })) + .apply( + projectMemberFilters({ + project: { id: project }, + roles, + active: true, + }), + ) .with('user') .optionalMatch([ node('user'), diff --git a/src/components/project/project.gel.repository.ts b/src/components/project/project.gel.repository.ts index b559ba5207..1a665d0a3f 100644 --- a/src/components/project/project.gel.repository.ts +++ b/src/components/project/project.gel.repository.ts @@ -38,6 +38,11 @@ const hydrate = e.shape(e.Project, (project) => ({ __typename: project.__type__.name.slice(9, null), rootDirectory: true, + membership: { + id: true, + roles: true, + inactiveAt: true, + }, primaryPartnership: e .select(project.partnerships, (p) => ({ filter: e.op(p.primary, '=', true), @@ -172,7 +177,8 @@ export class ProjectGelRepository e.op(project.modifiedAt, '<=', input.modifiedAt.beforeInclusive), ] : []), - input.isMember != null && e.op(project.isMember, '=', input.isMember), + input.membership != null && e.op('exists', project.membership), + input.membership?.active && e.op(project.membership.active, '?=', true), input.pinned != null && e.op(project.pinned, '=', input.pinned), input.languageId && e.op( diff --git a/src/components/project/project.repository.ts b/src/components/project/project.repository.ts index 6a2f3579d9..893405a9de 100644 --- a/src/components/project/project.repository.ts +++ b/src/components/project/project.repository.ts @@ -15,12 +15,15 @@ import { CommonRepository, OnIndex, UniquenessError } from '~/core/database'; import { type ChangesOf, getChanges } from '~/core/database/changes'; import { ACTIVE, + collect, createNode, createRelationships, + currentUser, defineSorters, FullTextIndex, matchChangesetAndChangedProps, matchProjectSens, + matchProps, matchPropsAndProjectSensAndScopedRoles, merge, paginate, @@ -89,6 +92,19 @@ export class ProjectRepository extends CommonRepository { relation('out', '', 'rootDirectory', ACTIVE), node('rootDirectory', 'Directory'), ]) + .subQuery('node', (sub) => + sub + .match([ + node('node'), + relation('out', '', 'member', ACTIVE), + node('membership'), + relation('out', '', 'user'), + currentUser, + ]) + .apply(matchProps({ nodeName: 'membership', outputVar: 'dto' })) + .with(collect('dto').as('dtos')) + .return('dtos[0] as membership'), + ) .optionalMatch([ node('node'), relation('out', '', 'partnership', ACTIVE), @@ -134,7 +150,7 @@ export class ProjectRepository extends CommonRepository { merge('props', 'changedProps', { type: 'node.type', pinned, - isMember: '"member:true" in props.scope', + membership: 'membership', rootDirectory: 'rootDirectory { .id }', primaryPartnership: 'primaryPartnership { .id }', primaryLocation: 'primaryLocation { .id }', diff --git a/src/components/project/project.resolver.ts b/src/components/project/project.resolver.ts index 78d08842bc..053360048b 100644 --- a/src/components/project/project.resolver.ts +++ b/src/components/project/project.resolver.ts @@ -18,6 +18,7 @@ import { ListArg, mapSecuredValue, NotFoundException, + OptionalField, SecuredDateRange, } from '~/common'; import { Loader, type LoaderOf } from '~/core'; @@ -83,6 +84,14 @@ class ModifyOtherLocationArgs { locationId: ID; } +@ArgsType() +class IsMemberArgs { + @OptionalField({ + description: 'Consider inactive memberships as well', + }) + includeInactive?: boolean; +} + @Resolver(IProject) export class ProjectResolver { constructor(private readonly projectService: ProjectService) {} @@ -148,6 +157,19 @@ export class ProjectResolver { return list; } + @ResolveField(() => Boolean, { + description: 'Is the requesting user a member of this project?', + }) + isMember( + @Parent() project: Project, + @Args() { includeInactive }: IsMemberArgs, + ): boolean { + return ( + !!project.membership && + (includeInactive ? true : !project.membership.inactiveAt) + ); + } + @ResolveField(() => String, { nullable: true }) avatarLetters(@Parent() project: Project): string | undefined { return project.name.canRead && project.name.value diff --git a/src/core/database/query/match-project-based-props.ts b/src/core/database/query/match-project-based-props.ts index 857060714b..07a05180c8 100644 --- a/src/core/database/query/match-project-based-props.ts +++ b/src/core/database/query/match-project-based-props.ts @@ -58,7 +58,7 @@ export const matchProjectScopedRoles = .match([ [ node(projectVar), - relation('out', '', 'member'), + relation('out', '', 'member', ACTIVE), node('projectMember'), relation('out', '', 'user'), currentUser,