diff --git a/dbschema/migrations/00023-m1zewki.edgeql b/dbschema/migrations/00023-m1zewki.edgeql new file mode 100644 index 0000000000..df2d331ebd --- /dev/null +++ b/dbschema/migrations/00023-m1zewki.edgeql @@ -0,0 +1,15 @@ +CREATE MIGRATION m1zewkih6hvqg45awyqrglwi56htph4iipv3adh4mx46dtddp4a5ka + ONTO m1xp5mbrgbcsdkvhimirvlj2hueu7m7aeyowjcp3wl4itrzityln4q +{ + CREATE TYPE Organization::AllianceMembership { + CREATE REQUIRED LINK alliance: default::Organization; + CREATE REQUIRED LINK member: default::Organization; + CREATE CONSTRAINT std::exclusive ON ((.member, .alliance)); + CREATE REQUIRED PROPERTY joinedAt: std::cal::local_date; + }; + ALTER TYPE default::Organization { + CREATE MULTI LINK allianceMembers: Organization::AllianceMembership; + CREATE MULTI LINK joinedAlliances: Organization::AllianceMembership; + CREATE LINK parent: default::Organization; + }; +}; diff --git a/dbschema/organization.gel b/dbschema/organization.gel index 73250c4be0..00b60965af 100644 --- a/dbschema/organization.gel +++ b/dbschema/organization.gel @@ -9,8 +9,11 @@ module default { address: str; multi types: Organization::Type; multi reach: Organization::Reach; + multi joinedAlliances: Organization::AllianceMembership; + multi allianceMembers: Organization::AllianceMembership; multi locations: Location; + parent: Organization; overloaded link projectContext: Project::Context { default := (insert Project::Context); @@ -34,4 +37,11 @@ module Organization { National, `Global` >; + + type AllianceMembership { + required link member: default::Organization; + required link alliance: default::Organization; + required joinedAt: cal::local_date; + constraint exclusive on ((.member, .alliance)); + } } diff --git a/src/app.module.ts b/src/app.module.ts index 7830b7b832..6ffc8014a5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import process from 'node:process'; import { AdminModule } from './components/admin/admin.module'; +import { AllianceMembershipModule } from './components/alliance-membership/alliance-membership.module'; import { AuthorizationModule } from './components/authorization/authorization.module'; import { BudgetModule } from './components/budget/budget.module'; import { CeremonyModule } from './components/ceremony/ceremony.module'; @@ -88,6 +89,7 @@ if (process.env.NODE_ENV !== 'production') { NotificationModule, SystemNotificationModule, FinanceDepartmentModule, + AllianceMembershipModule, ], }) export class AppModule {} diff --git a/src/components/alliance-membership/alliance-membership.gel.repository.ts b/src/components/alliance-membership/alliance-membership.gel.repository.ts new file mode 100644 index 0000000000..aaba383e90 --- /dev/null +++ b/src/components/alliance-membership/alliance-membership.gel.repository.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { CalendarDate, type PublicOf, type UnsecuredDto } from '~/common'; +import { e, RepoFor } from '~/core/gel'; +import { type AllianceMembershipRepository } from './alliance-membership.repository'; +import { AllianceMembership, type CreateAllianceMembership } from './dto'; + +@Injectable() +export class AllianceMembershipGelRepository + extends RepoFor(AllianceMembership, { + hydrate: (allianceMembership) => ({ + __typename: e.str('AllianceMembership'), + ...allianceMembership['*'], + member: true, + alliance: true, + }), + omit: ['create'], + }) + implements PublicOf +{ + async create( + input: CreateAllianceMembership, + ): Promise> { + const joinedAt = input.joinedAt ?? CalendarDate.local(); + + const query = e.params( + { + memberId: e.uuid, + allianceId: e.uuid, + joinedAt: e.cal.local_date, + }, + ($) => { + const member = e.cast(e.Organization, $.memberId); + const alliance = e.cast(e.Organization, $.allianceId); + + const created = e.insert(this.resource.db, { + member, + alliance, + joinedAt: $.joinedAt, + }); + + return e.select(created, this.hydrate); + }, + ); + + return await this.db.run(query, { + memberId: input.memberId, + allianceId: input.allianceId, + joinedAt, + }); + } +} diff --git a/src/components/alliance-membership/alliance-membership.loader.ts b/src/components/alliance-membership/alliance-membership.loader.ts new file mode 100644 index 0000000000..367baafe6c --- /dev/null +++ b/src/components/alliance-membership/alliance-membership.loader.ts @@ -0,0 +1,17 @@ +import { type ID } from '~/common'; +import { type DataLoaderStrategy, LoaderFactory } from '~/core/data-loader'; +import { AllianceMembershipService } from './alliance-membership.service'; +import { AllianceMembership } from './dto'; + +@LoaderFactory(() => AllianceMembership) +export class AllianceMembershipLoader + implements DataLoaderStrategy> +{ + constructor( + private readonly allianceMemberships: AllianceMembershipService, + ) {} + + async loadMany(ids: ReadonlyArray>) { + return await this.allianceMemberships.readMany(ids); + } +} diff --git a/src/components/alliance-membership/alliance-membership.module.ts b/src/components/alliance-membership/alliance-membership.module.ts new file mode 100644 index 0000000000..9de294260d --- /dev/null +++ b/src/components/alliance-membership/alliance-membership.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { AuthorizationModule } from '../authorization/authorization.module'; +import { AllianceMembershipLoader } from './alliance-membership.loader'; +import { AllianceMembershipRepository } from './alliance-membership.repository'; +import { AllianceMembershipResolver } from './alliance-membership.resolver'; +import { AllianceMembershipService } from './alliance-membership.service'; + +@Module({ + imports: [AuthorizationModule], + providers: [ + AllianceMembershipResolver, + AllianceMembershipService, + AllianceMembershipRepository, + AllianceMembershipLoader, + ], + exports: [AllianceMembershipService], +}) +export class AllianceMembershipModule {} diff --git a/src/components/alliance-membership/alliance-membership.repository.ts b/src/components/alliance-membership/alliance-membership.repository.ts new file mode 100644 index 0000000000..c9dc66bb86 --- /dev/null +++ b/src/components/alliance-membership/alliance-membership.repository.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; +import { node, type Query, relation } from 'cypher-query-builder'; +import { + CreationFailed, + type ID, + NotFoundException, + ReadAfterCreationFailed, + type UnsecuredDto, +} from '~/common'; +import { DtoRepository } from '~/core/database'; +import { + ACTIVE, + createNode, + createRelationships, + matchProps, + merge, +} from '~/core/database/query'; +import { AllianceMembership, type CreateAllianceMembership } from './dto'; + +@Injectable() +export class AllianceMembershipRepository extends DtoRepository( + AllianceMembership, +) { + async create(input: CreateAllianceMembership) { + const initialProps = { + joinedAt: input.joinedAt, + canDelete: true, + }; + + const result = await this.db + .query() + .apply( + await createNode(AllianceMembership, { + initialProps, + }), + ) + .apply( + createRelationships(AllianceMembership, 'out', { + member: ['Organization', input.memberId], + alliance: ['Organization', input.allianceId], + }), + ) + .return<{ id: ID }>('node.id as id') + .first(); + + if (!result) { + throw new CreationFailed(AllianceMembership); + } + + return await this.readOne(result.id).catch((e) => { + throw e instanceof NotFoundException + ? new ReadAfterCreationFailed(AllianceMembership) + : e; + }); + } + + protected hydrate() { + return (query: Query) => + query + .apply(matchProps()) + .optionalMatch([ + node('node'), + relation('out', '', 'member', ACTIVE), + node('member', 'Organization'), + ]) + .optionalMatch([ + node('node'), + relation('out', '', 'alliance', ACTIVE), + node('alliance', 'Organization'), + ]) + .return<{ dto: UnsecuredDto }>( + merge('props', { + member: 'member { .id}', + alliance: 'alliance { .id}', + }).as('dto'), + ); + } +} diff --git a/src/components/alliance-membership/alliance-membership.resolver.ts b/src/components/alliance-membership/alliance-membership.resolver.ts new file mode 100644 index 0000000000..254ddd626d --- /dev/null +++ b/src/components/alliance-membership/alliance-membership.resolver.ts @@ -0,0 +1,76 @@ +import { + Args, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import { ID, IdArg, mapSecuredValue } from '~/common'; +import { Loader, LoaderOf } from '~/core'; +import { OrganizationLoader } from '../organization'; +import { SecuredOrganization } from '../organization/dto'; +import { AllianceMembershipLoader } from './alliance-membership.loader'; +import { AllianceMembershipService } from './alliance-membership.service'; +import { AllianceMembership } from './dto/alliance-membership.dto'; +import { + CreateAllianceMembershipInput, + CreateAllianceMembershipOutput, +} from './dto/create-alliance-membership.dto'; +import { DeleteAllianceMembershipOutput } from './dto/delete-alliance-membership.dto'; + +@Resolver(AllianceMembership) +export class AllianceMembershipResolver { + constructor(private readonly service: AllianceMembershipService) {} + + @Query(() => AllianceMembership, { + description: 'Read one field zone by id', + }) + async allianceMembership( + @Loader(AllianceMembershipLoader) + allianceMemberships: LoaderOf, + @IdArg() id: ID, + ): Promise { + return await allianceMemberships.load(id); + } + + @Mutation(() => CreateAllianceMembershipOutput, { + description: 'Create an alliance membership', + }) + async createAllianceMembership( + @Args('input') { allianceMembership: input }: CreateAllianceMembershipInput, + ): Promise { + const allianceMembership = await this.service.create(input); + return { allianceMembership }; + } + + @Mutation(() => DeleteAllianceMembershipOutput, { + description: 'Delete an alliance membership', + }) + async deleteAllianceMembership( + @IdArg() id: ID, + ): Promise { + await this.service.delete(id); + return { success: true }; + } + + @ResolveField(() => SecuredOrganization) + async member( + @Parent() allianceMembership: AllianceMembership, + @Loader(OrganizationLoader) organizations: LoaderOf, + ): Promise { + return await mapSecuredValue(allianceMembership.member, ({ id }) => + organizations.load(id), + ); + } + + @ResolveField(() => SecuredOrganization) + async alliance( + @Parent() allianceMembership: AllianceMembership, + @Loader(OrganizationLoader) organizations: LoaderOf, + ): Promise { + return await mapSecuredValue(allianceMembership.alliance, ({ id }) => + organizations.load(id), + ); + } +} diff --git a/src/components/alliance-membership/alliance-membership.service.ts b/src/components/alliance-membership/alliance-membership.service.ts new file mode 100644 index 0000000000..f3618b7919 --- /dev/null +++ b/src/components/alliance-membership/alliance-membership.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { + CalendarDate, + type ID, + InputException, + ObjectView, + ServerException, + type UnsecuredDto, +} from '~/common'; +import { HandleIdLookup, ResourceLoader } from '~/core'; +import { Privileges } from '../authorization'; +import { AllianceMembershipRepository } from './alliance-membership.repository'; +import { type CreateAllianceMembership } from './dto'; +import { AllianceMembership } from './dto/alliance-membership.dto'; + +@Injectable() +export class AllianceMembershipService { + constructor( + private readonly privileges: Privileges, + private readonly resources: ResourceLoader, + private readonly repo: AllianceMembershipRepository, + ) {} + + async create(input: CreateAllianceMembership): Promise { + if (input.memberId === input.allianceId) { + throw new InputException( + 'An organization cannot be its own alliance member', + 'allianceMembership.member', + ); + } + + // Set default joinedAt to today if not provided + const inputWithDefaults = { + ...input, + joinedAt: input.joinedAt ?? CalendarDate.local(), + }; + + const created = await this.repo.create(inputWithDefaults); + + this.privileges.for(AllianceMembership, created).verifyCan('create'); + + return this.secure(created); + } + + @HandleIdLookup(AllianceMembership) + async readOne( + allianceId: ID, + _view?: ObjectView, + ): Promise { + const result = await this.repo.readOne(allianceId); + return this.secure(result); + } + + async readMany(ids: readonly ID[]) { + const allianceMemberships = await this.repo.readMany(ids); + return allianceMemberships.map((dto) => this.secure(dto)); + } + + private secure(dto: UnsecuredDto): AllianceMembership { + return this.privileges.for(AllianceMembership).secure(dto); + } + + // async listAllianceMembers( + // organizationId: ID, + // ): Promise { + // const input = AllianceMembershipListInput.defaultValue( + // AllianceMembershipListInput, + // { + // filter: { allianceId: organizationId }, + // }, + // ); + // const result = await this.repo.list(input); + // return { + // ...result, + // items: result.items.map((dto) => this.secure(dto)), + // }; + // } + + // async listJoinedAlliances( + // organizationId: ID, + // ): Promise { + // const input = AllianceMembershipListInput.defaultValue( + // AllianceMembershipListInput, + // { + // filter: { memberId: organizationId }, + // }, + // ); + // const result = await this.repo.list(input); + // return { + // ...result, + // items: result.items.map((dto) => this.secure(dto)), + // }; + // } + + async delete(id: ID): Promise { + const membership = await this.readOne(id); + this.privileges.for(AllianceMembership, membership).verifyCan('delete'); + + try { + await this.repo.deleteNode(id); + } catch (exception) { + throw new ServerException('Failed to delete', exception); + } + } +} diff --git a/src/components/alliance-membership/dto/alliance-membership.dto.ts b/src/components/alliance-membership/dto/alliance-membership.dto.ts new file mode 100644 index 0000000000..da4b538491 --- /dev/null +++ b/src/components/alliance-membership/dto/alliance-membership.dto.ts @@ -0,0 +1,27 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { type Secured } from '~/common'; +import { CalendarDate } from '~/common/temporal'; +import { type LinkTo, RegisterResource } from '~/core'; +import { e } from '~/core/gel'; + +@RegisterResource({ + db: e.Organization.AllianceMembership, +}) +@ObjectType('AllianceMembership') +export class AllianceMembership { + readonly alliance: Secured>; + + readonly member: Secured>; + + @Field(() => CalendarDate) + readonly joinedAt: CalendarDate; +} + +declare module '~/core/resources/map' { + interface ResourceMap { + AllianceMembership: typeof AllianceMembership; + } + interface ResourceDBMap { + AllianceMembership: typeof e.Organization.AllianceMembership; + } +} diff --git a/src/components/alliance-membership/dto/create-alliance-membership.dto.ts b/src/components/alliance-membership/dto/create-alliance-membership.dto.ts new file mode 100644 index 0000000000..df4c5a6abd --- /dev/null +++ b/src/components/alliance-membership/dto/create-alliance-membership.dto.ts @@ -0,0 +1,33 @@ +import { Field, InputType, ObjectType } from '@nestjs/graphql'; +import { Type } from 'class-transformer'; +import { ValidateNested } from 'class-validator'; +import { CalendarDate, ID, IdField } from '~/common'; +import { AllianceMembership } from './alliance-membership.dto'; + +@InputType() +export abstract class CreateAllianceMembership { + @IdField() + readonly memberId: ID<'Organization'>; + + @IdField() + readonly allianceId: ID<'Organization'>; + + @Field(() => CalendarDate, { nullable: true }) + @Type(() => CalendarDate) + @ValidateNested() + readonly joinedAt?: CalendarDate; +} + +@InputType() +export abstract class CreateAllianceMembershipInput { + @Field() + @Type(() => CreateAllianceMembership) + @ValidateNested() + readonly allianceMembership: CreateAllianceMembership; +} + +@ObjectType() +export abstract class CreateAllianceMembershipOutput { + @Field() + readonly allianceMembership: AllianceMembership; +} diff --git a/src/components/alliance-membership/dto/delete-alliance-membership.dto.ts b/src/components/alliance-membership/dto/delete-alliance-membership.dto.ts new file mode 100644 index 0000000000..669841e8c9 --- /dev/null +++ b/src/components/alliance-membership/dto/delete-alliance-membership.dto.ts @@ -0,0 +1,5 @@ +import { ObjectType } from '@nestjs/graphql'; +import { MutationPlaceholderOutput } from '~/common'; + +@ObjectType() +export abstract class DeleteAllianceMembershipOutput extends MutationPlaceholderOutput {} diff --git a/src/components/alliance-membership/dto/index.ts b/src/components/alliance-membership/dto/index.ts new file mode 100644 index 0000000000..79ab724447 --- /dev/null +++ b/src/components/alliance-membership/dto/index.ts @@ -0,0 +1,4 @@ +export * from './alliance-membership.dto'; +export * from './create-alliance-membership.dto'; +export * from './delete-alliance-membership.dto'; +export * from './list-alliance-membership.dto'; diff --git a/src/components/alliance-membership/dto/list-alliance-membership.dto.ts b/src/components/alliance-membership/dto/list-alliance-membership.dto.ts new file mode 100644 index 0000000000..3184c55a59 --- /dev/null +++ b/src/components/alliance-membership/dto/list-alliance-membership.dto.ts @@ -0,0 +1,45 @@ +import { InputType, ObjectType } from '@nestjs/graphql'; +import { + FilterField, + type ID, + PaginatedList, + SecuredList, + SecuredPropertyList, + SortablePaginationInput, +} from '~/common'; +import { AllianceMembership } from './alliance-membership.dto'; + +@InputType() +export abstract class AllianceMembershipFilters { + readonly allianceId?: ID; + readonly memberId?: ID; +} + +@InputType() +export class AllianceMembershipListInput extends SortablePaginationInput< + keyof AllianceMembership +>({ + defaultSort: 'joinedAt', +}) { + @FilterField(() => AllianceMembershipFilters, { internal: true }) + readonly filter?: AllianceMembershipFilters; +} + +@ObjectType() +export class AllianceMembershipListOutput extends PaginatedList( + AllianceMembership, +) {} + +@ObjectType({ + description: SecuredList.descriptionFor('alliance memberships'), +}) +export class SecuredAllianceMembershipList extends SecuredList( + AllianceMembership, +) {} + +@ObjectType({ + description: SecuredPropertyList.descriptionFor('alliance memberships'), +}) +export class SecuredAllianceMemberships extends SecuredPropertyList( + AllianceMembership, +) {} diff --git a/src/components/organization/dto/create-organization.dto.ts b/src/components/organization/dto/create-organization.dto.ts index 811c53a9ca..4e72b15661 100644 --- a/src/components/organization/dto/create-organization.dto.ts +++ b/src/components/organization/dto/create-organization.dto.ts @@ -1,7 +1,8 @@ -import { Field, InputType, ObjectType } from '@nestjs/graphql'; -import { Type } from 'class-transformer'; +import { Field, ID as IDType, InputType, ObjectType } from '@nestjs/graphql'; +import { Transform, Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; -import { NameField } from '~/common'; +import { uniq } from 'lodash'; +import { type ID, IdField, IsId, NameField } from '~/common'; import { OrganizationReach } from './organization-reach.dto'; import { OrganizationType } from './organization-type.dto'; import { Organization } from './organization.dto'; @@ -22,6 +23,19 @@ export abstract class CreateOrganization { @Field(() => [OrganizationReach], { nullable: true }) readonly reach?: readonly OrganizationReach[]; + + @Field(() => [IDType], { nullable: true }) + @IsId({ each: true }) + @Transform(({ value }) => uniq(value)) + readonly joinedAlliances?: ReadonlyArray> = []; + + @Field(() => [IDType], { nullable: true }) + @IsId({ each: true }) + @Transform(({ value }) => uniq(value)) + readonly allianceMembers?: ReadonlyArray> = []; + + @IdField({ nullable: true }) + readonly parentId?: ID<'Organization'> | null; } @InputType() diff --git a/src/components/organization/dto/organization.dto.ts b/src/components/organization/dto/organization.dto.ts index 79a9ebc4a8..d28355a631 100644 --- a/src/components/organization/dto/organization.dto.ts +++ b/src/components/organization/dto/organization.dto.ts @@ -4,14 +4,16 @@ import { NameField, Resource, type ResourceRelationsShape, + type Secured, SecuredProperty, + SecuredPropertyList, SecuredString, SecuredStringNullable, Sensitivity, SensitivityField, } from '~/common'; import { e } from '~/core/gel'; -import { RegisterResource } from '~/core/resources'; +import { type LinkTo, RegisterResource } from '~/core/resources'; import { Location } from '../../location/dto'; import { SecuredOrganizationReach } from './organization-reach.dto'; import { SecuredOrganizationTypes } from './organization-type.dto'; @@ -46,6 +48,16 @@ export class Organization extends Resource { @Field() readonly reach: SecuredOrganizationReach; + + readonly joinedAlliances: Required< + Secured>> + >; + + readonly allianceMembers: Required< + Secured>> + >; + + readonly parent: Secured | null>; } @ObjectType({ @@ -53,6 +65,11 @@ export class Organization extends Resource { }) export class SecuredOrganization extends SecuredProperty(Organization) {} +@ObjectType({ + description: SecuredPropertyList.descriptionFor('a list of organizations'), +}) +export class SecuredOrganizations extends SecuredPropertyList(Organization) {} + declare module '~/core/resources/map' { interface ResourceMap { Organization: typeof Organization; diff --git a/src/components/organization/dto/update-organization.dto.ts b/src/components/organization/dto/update-organization.dto.ts index 14c0d40113..a1fe4ca2af 100644 --- a/src/components/organization/dto/update-organization.dto.ts +++ b/src/components/organization/dto/update-organization.dto.ts @@ -1,7 +1,14 @@ -import { Field, InputType, ObjectType } from '@nestjs/graphql'; +import { Field, ID as IDType, InputType, ObjectType } from '@nestjs/graphql'; import { Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; -import { type ID, IdField, NameField, OptionalField } from '~/common'; +import { + type ID, + IdField, + IsId, + ListField, + NameField, + OptionalField, +} from '~/common'; import { OrganizationReach } from './organization-reach.dto'; import { OrganizationType } from './organization-type.dto'; import { Organization } from './organization.dto'; @@ -25,6 +32,17 @@ export abstract class UpdateOrganization { @OptionalField(() => [OrganizationReach]) readonly reach?: readonly OrganizationReach[]; + + @ListField(() => IDType, { optional: true }) + @IsId({ each: true }) + readonly joinedAlliances?: ReadonlyArray>; + + @ListField(() => IDType, { optional: true }) + @IsId({ each: true }) + readonly allianceMembers?: ReadonlyArray>; + + @IdField({ nullable: true }) + readonly parentId?: ID<'Organization'> | null; } @InputType() diff --git a/src/components/organization/organization.gel.repository.ts b/src/components/organization/organization.gel.repository.ts index 982841cc1f..12a5909a65 100644 --- a/src/components/organization/organization.gel.repository.ts +++ b/src/components/organization/organization.gel.repository.ts @@ -7,7 +7,12 @@ import { type OrganizationRepository } from './organization.repository'; @Injectable() export class OrganizationGelRepository extends RepoFor(Organization, { - hydrate: (organization) => organization['*'], + hydrate: (organization) => ({ + ...organization['*'], + allianceMembers: true, + joinedAlliances: true, + parent: true, + }), omit: ['create'], }) implements PublicOf @@ -16,6 +21,9 @@ export class OrganizationGelRepository return await this.defaults.create({ ...input, projectContext: e.insert(e.Project.Context, {}), + allianceMembers: input.allianceMembers ?? [], + joinedAlliances: input.joinedAlliances ?? [], + parent: input.parentId, }); } } diff --git a/src/components/organization/organization.module.ts b/src/components/organization/organization.module.ts index dc3aaa6172..75bcf353ec 100644 --- a/src/components/organization/organization.module.ts +++ b/src/components/organization/organization.module.ts @@ -1,5 +1,6 @@ import { forwardRef, Module } from '@nestjs/common'; import { splitDb } from '~/core'; +import { AllianceMembershipModule } from '../alliance-membership/alliance-membership.module'; import { AuthorizationModule } from '../authorization/authorization.module'; import { LocationModule } from '../location/location.module'; import { AddOrganizationReachMigration } from './migrations/add-reach.migration'; @@ -11,7 +12,11 @@ import { OrganizationResolver } from './organization.resolver'; import { OrganizationService } from './organization.service'; @Module({ - imports: [forwardRef(() => AuthorizationModule), LocationModule], + imports: [ + forwardRef(() => AuthorizationModule), + LocationModule, + AllianceMembershipModule, + ], providers: [ OrganizationResolver, OrganizationService, diff --git a/src/components/organization/organization.repository.ts b/src/components/organization/organization.repository.ts index 3fef1ba856..139d0d1175 100644 --- a/src/components/organization/organization.repository.ts +++ b/src/components/organization/organization.repository.ts @@ -4,6 +4,7 @@ import { CreationFailed, DuplicateException, type ID, + InputException, NotFoundException, ReadAfterCreationFailed, type UnsecuredDto, @@ -11,6 +12,7 @@ import { import { DtoRepository, OnIndex } from '~/core/database'; import { ACTIVE, + collect, createNode, defineSorters, filter, @@ -69,8 +71,42 @@ export class OrganizationRepository extends DtoRepository(Organization) { } async update(changes: UpdateOrganization) { - const { id, ...simpleChanges } = changes; + const { id, joinedAlliances, allianceMembers, parentId, ...simpleChanges } = + changes; await this.updateProperties({ id }, simpleChanges); + + if (joinedAlliances) { + try { + await this.updateRelationList({ + id: changes.id, + relation: 'joinedAlliances', + newList: joinedAlliances, + }); + } catch (e) { + throw e instanceof InputException + ? e.withField('organization.joinedAlliances') + : e; + } + } + + if (allianceMembers) { + try { + await this.updateRelationList({ + id: changes.id, + relation: 'allianceMembers', + newList: allianceMembers, + }); + } catch (e) { + throw e instanceof InputException + ? e.withField('organization.allianceMembers') + : e; + } + } + + if (parentId !== undefined) { + await this.updateRelation('parent', 'Organization', changes.id, parentId); + } + return await this.readOne(id); } @@ -107,11 +143,37 @@ export class OrganizationRepository extends DtoRepository(Organization) { .raw('WHERE size(projList) = 0') .return(`'High' as sensitivity`), ) + .subQuery('node', (sub) => + sub + .match([ + node('node'), + relation('out', '', 'alliance'), + node('alliance', 'Organization'), + ]) + .return(collect('alliance { .id }').as('alliance')), + ) + .subQuery('node', (sub) => + sub + .match([ + node('node'), + relation('out', '', 'member'), + node('member', 'Organization'), + ]) + .return(collect('member { .id }').as('member')), + ) + .optionalMatch([ + node('node'), + relation('out', '', 'parent', ACTIVE), + node('parent', 'Organization'), + ]) .apply(matchProps()) .return<{ dto: UnsecuredDto }>( merge('props', { scope: 'scopedRoles', sensitivity: 'sensitivity', + parent: 'parent { .id }', + joinedAlliances: 'alliance', + allianceMembers: 'member', }).as('dto'), ); } diff --git a/src/components/organization/organization.resolver.ts b/src/components/organization/organization.resolver.ts index e52a87ef30..1858932880 100644 --- a/src/components/organization/organization.resolver.ts +++ b/src/components/organization/organization.resolver.ts @@ -13,8 +13,12 @@ import { IdArg, IdField, ListArg, + loadSecuredIds, + mapSecuredValue, } from '~/common'; import { Loader, type LoaderOf } from '~/core'; +import { AllianceMembershipLoader } from '../alliance-membership/alliance-membership.loader'; +import { SecuredAllianceMemberships } from '../alliance-membership/dto'; import { LocationLoader } from '../location'; import { LocationListInput, SecuredLocationList } from '../location/dto'; import { OrganizationLoader, OrganizationService } from '../organization'; @@ -25,6 +29,7 @@ import { Organization, OrganizationListInput, OrganizationListOutput, + SecuredOrganization, UpdateOrganizationInput, UpdateOrganizationOutput, } from './dto'; @@ -129,4 +134,42 @@ export class OrganizationResolver { await this.orgs.removeLocation(organizationId, locationId); return await this.orgs.readOne(organizationId); } + + @ResolveField(() => SecuredOrganization) + async parent( + @Parent() organization: Organization, + @Loader(OrganizationLoader) organizations: LoaderOf, + ): Promise { + return await mapSecuredValue(organization.parent, ({ id }) => + organizations.load(id), + ); + } + + @ResolveField(() => SecuredAllianceMemberships) + async allianceMembers( + @Parent() organization: Organization, + @Loader(AllianceMembershipLoader) + loader: LoaderOf, + ): Promise { + return await loadSecuredIds(loader, { + ...organization.allianceMembers, + value: organization.allianceMembers.value.map( + (membership) => membership.id, + ), + }); + } + + @ResolveField(() => SecuredAllianceMemberships) + async joinedAlliances( + @Parent() organization: Organization, + @Loader(AllianceMembershipLoader) + loader: LoaderOf, + ): Promise { + return await loadSecuredIds(loader, { + ...organization.joinedAlliances, + value: organization.joinedAlliances.value.map( + (membership) => membership.id, + ), + }); + } } diff --git a/src/components/organization/organization.service.ts b/src/components/organization/organization.service.ts index 9eff85808b..687a38a195 100644 --- a/src/components/organization/organization.service.ts +++ b/src/components/organization/organization.service.ts @@ -30,6 +30,9 @@ export class OrganizationService { ) {} async create(input: CreateOrganization): Promise { + // if (input.joinedAlliances && input.joinedAlliances.length > 0) { + + // } const created = await this.repo.create(input); this.privileges.for(Organization, created).verifyCan('create');