From c87626d3580fc613860a7dc273afc0fd88082630 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Tue, 7 May 2024 17:00:35 -0500 Subject: [PATCH 1/8] Add Partner.startDate to EdgeDB schema --- dbschema/partner.esdl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dbschema/partner.esdl b/dbschema/partner.esdl index ad65974f87..bdd517fa5b 100644 --- a/dbschema/partner.esdl +++ b/dbschema/partner.esdl @@ -31,6 +31,8 @@ module default { multi languagesOfConsulting: Language; multi fieldRegions: FieldRegion; multi countries: Location; + + startDate: cal::local_date; } } From 65d8eb31251eb689373faa69d706ce363b623a19 Mon Sep 17 00:00:00 2001 From: Brent Kulwicki Date: Tue, 7 May 2024 15:52:58 -0500 Subject: [PATCH 2/8] Add Org/Partner address string to schema & seeds --- dbschema/migrations/00007-m15wwix.edgeql | 11 +++++++++++ dbschema/organization.esdl | 3 ++- dbschema/partner.esdl | 3 ++- dbschema/seeds/006.organizations.edgeql | 16 ++++++++++++++++ dbschema/seeds/007.partners.edgeql | 1 + 5 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 dbschema/migrations/00007-m15wwix.edgeql diff --git a/dbschema/migrations/00007-m15wwix.edgeql b/dbschema/migrations/00007-m15wwix.edgeql new file mode 100644 index 0000000000..e4b423ec30 --- /dev/null +++ b/dbschema/migrations/00007-m15wwix.edgeql @@ -0,0 +1,11 @@ +CREATE MIGRATION m1s2cbqfqayiw2giggpp3dlfwrxnmpaziw7irc4h74chugwr4noluq + ONTO m1rxhi72zd43b4hwanwavpsfx7yo5vfngfr4ngmxq4thpuvi3t45qa +{ + ALTER TYPE default::Organization { + CREATE PROPERTY address: std::str; + }; + ALTER TYPE default::Partner { + CREATE PROPERTY address: std::str; + CREATE PROPERTY startDate: cal::local_date; + }; +}; diff --git a/dbschema/organization.esdl b/dbschema/organization.esdl index f5d9cdd77f..73250c4be0 100644 --- a/dbschema/organization.esdl +++ b/dbschema/organization.esdl @@ -5,7 +5,8 @@ module default { } acronym: str; - #address: str; #TODO - this needs figured out - needed on here and Partner? + #TODO - this needs figured out - needed on here and Partner? + address: str; multi types: Organization::Type; multi reach: Organization::Reach; diff --git a/dbschema/partner.esdl b/dbschema/partner.esdl index bdd517fa5b..d3d244ffa4 100644 --- a/dbschema/partner.esdl +++ b/dbschema/partner.esdl @@ -15,7 +15,8 @@ module default { constraint regexp(r'^[A-Z]{3}$'); } - #address: str; #TODO - this needs figured out - needed on here and Organization? + #TODO - this needs figured out - needed on here and Organization? + address: str; multi types: Partner::Type; multi financialReportingTypes: Partnership::FinancialReportingType; diff --git a/dbschema/seeds/006.organizations.edgeql b/dbschema/seeds/006.organizations.edgeql index 5d18523432..7ea0f116e8 100644 --- a/dbschema/seeds/006.organizations.edgeql +++ b/dbschema/seeds/006.organizations.edgeql @@ -2,90 +2,105 @@ with organizationsJson := to_json('[ { "name": "Eriador Church", + "address": "123 Sesame Street, Dallas, TX 12345", "acronym": "EC", "types": ["Church", "Mission"], "reach": ["Local", "National"] }, { "name": "Ered Luin Translation Syndicate", + "address": "456 Wall Street, New York, NY 12345", "acronym": "ELTW", "types": ["Parachurch", "Mission"], "reach": ["National", "Regional"] }, { "name": "Rohan Linguistics", + "address": "1650 Main St., Atlanta, GA", "acronym": "RL", "types": ["Church"], "reach": ["Local", "Regional"] }, { "name": "Rhun For Zero", + "address": "975 1st Street, Jacksonville, FL 98765", "acronym": "RFZ", "types": ["TranslationOrganization", "Mission"], "reach": ["Global"] }, { "name": "Gondor Foundation", + "address": "4567 2nd Street, Jeffersonville, IN 47130", "acronym": "GF", "types": ["Church", "TranslationOrganization"], "reach": ["Local"] }, { "name": "Ered Mithrim Group", + "address": "5577 Market St., Georgetown, KY 40200", "acronym": "EMG", "types": ["Church", "Parachurch"], "reach": ["Local", "Global"] }, { "name": "The Rivendell Partnership", + "address": "1234 Highway 64 NW, Georgetown, IN 47122", "acronym": "TRP", "types": ["Church"], "reach": ["Global", "Regional"] }, { "name": "Dwarvish/Elvish Alliance", + "address": "1234 Highway 64 NW, Georgetown, IN 47122", "acronym": "DEA", "types": ["Parachurch", "Mission"], "reach": ["Global"] }, { "name": "The Buckland Organization", + "address": "22 Peachtree Drive, Windsor Mill, MD 21244", "acronym": "BO", "types": ["Alliance", "Mission", "Parachurch"], "reach": ["Local", "Global", "Regional"] }, { "name": "Fellowship of Halfing Languages", + "address": "45 W. Southampton Dr., Eastpointe, MI 48021", "acronym": "FAHL", "types": ["Alliance"], "reach": ["Local", "National"] }, { "name": "Rivers and Mountains Translation Group", + "address": "738 Trout Street, Hollywood, FL 33020", "acronym": "RMTG", "types": ["Church", "Mission"], "reach": ["Local", "Regional"] }, { "name": "Hobbiton Ministry", + "address": "659 Cherry Hill Ave., Parkville, MD 21234", "acronym": "HM", "types": ["Church", "Mission"], "reach": ["National", "Regional"] }, { "name": "Linguistics Seminary of Sutherland", + "address": "46 Thatcher St., Port Jefferson Station, NY 11776", "acronym": "LSS", "types": ["Church"], "reach": ["National", "Regional", "Global"] }, { "name": "Heart and Minds Across Belegaer", + "address": "9766 W. Hawthorne Avenue, Strongsville, OH 44136", "acronym": "HMAB", "types": ["Church", "Mission"], "reach": ["National", "Regional"] }, { "name": "The Gray Havens Initiative", + "address": "6 S. Shore Circle, Thornton, CO 80241", "acronym": "GHI", "types": ["Mission"], "reach": ["National", "Regional", "Global"] @@ -98,6 +113,7 @@ with (insert Organization { projectContext := (insert Project::Context), name := organization['name'], + address := organization['address'], acronym := organization['acronym'], types := json_array_unpack(organization['types']), reach := json_array_unpack(organization['reach']) diff --git a/dbschema/seeds/007.partners.edgeql b/dbschema/seeds/007.partners.edgeql index 24a9ea9542..d4561d9817 100644 --- a/dbschema/seeds/007.partners.edgeql +++ b/dbschema/seeds/007.partners.edgeql @@ -112,6 +112,7 @@ with organization := organization, projectContext := organization.projectContext, name := organization.name, + address := organization.address, pmcEntityCode := partner['pmcEntityCode'], types := json_array_unpack(partner['types']), financialReportingTypes := json_array_unpack(partner['financialReportingTypes']), From 7c63ec8c0e622423c810c2cd226e83a8bdcfa514 Mon Sep 17 00:00:00 2001 From: Brent Kulwicki Date: Tue, 7 May 2024 15:55:20 -0500 Subject: [PATCH 3/8] Correct nullable properties in API/GQL --- src/components/organization/dto/organization.dto.ts | 2 +- src/components/partner/dto/partner.dto.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/organization/dto/organization.dto.ts b/src/components/organization/dto/organization.dto.ts index 82e5f71ba0..e004f63a41 100644 --- a/src/components/organization/dto/organization.dto.ts +++ b/src/components/organization/dto/organization.dto.ts @@ -37,7 +37,7 @@ export class Organization extends Resource { readonly acronym: SecuredStringNullable; @Field() - readonly address: SecuredString; + readonly address: SecuredStringNullable; @SensitivityField({ description: diff --git a/src/components/partner/dto/partner.dto.ts b/src/components/partner/dto/partner.dto.ts index b8f1184513..0c1178fad9 100644 --- a/src/components/partner/dto/partner.dto.ts +++ b/src/components/partner/dto/partner.dto.ts @@ -67,7 +67,7 @@ export class Partner extends Interfaces { readonly financialReportingTypes: SecuredFinancialReportingTypes; @Field() - readonly pmcEntityCode: SecuredString; + readonly pmcEntityCode: SecuredStringNullable; @Field() readonly globalInnovationsClient: SecuredBoolean; @@ -76,7 +76,7 @@ export class Partner extends Interfaces { readonly active: SecuredBoolean; @Field() - readonly address: SecuredString; + readonly address: SecuredStringNullable; readonly languageOfWiderCommunication: Secured | null>; From 66fcd00361a4ee1c4f7b0c9eeabf61a721c909a5 Mon Sep 17 00:00:00 2001 From: Brent Kulwicki Date: Tue, 7 May 2024 16:23:48 -0500 Subject: [PATCH 4/8] Refactor Organization service/repo Co-authored-by: Carson Full --- .../organization/organization.repository.ts | 38 ++++++++---- .../organization/organization.service.ts | 59 ++++++------------- 2 files changed, 46 insertions(+), 51 deletions(-) diff --git a/src/components/organization/organization.repository.ts b/src/components/organization/organization.repository.ts index 0becabc43b..8887289b81 100644 --- a/src/components/organization/organization.repository.ts +++ b/src/components/organization/organization.repository.ts @@ -1,8 +1,13 @@ import { Injectable } from '@nestjs/common'; import { node, Query, relation } from 'cypher-query-builder'; -import { ChangesOf } from '~/core/database/changes'; -import { ID, Session, UnsecuredDto } from '../../common'; -import { DtoRepository } from '../../core'; +import { + DuplicateException, + ID, + ServerException, + Session, + UnsecuredDto, +} from '~/common'; +import { DtoRepository } from '~/core/database'; import { ACTIVE, createNode, @@ -15,7 +20,7 @@ import { rankSens, requestingUser, sorting, -} from '../../core/database/query'; +} from '~/core/database/query'; import { CreateOrganization, Organization, @@ -28,7 +33,14 @@ export class OrganizationRepository extends DtoRepository< typeof Organization, [session: Session] >(Organization) { - async create(input: CreateOrganization) { + async create(input: CreateOrganization, session: Session) { + if (!(await this.isUnique(input.name))) { + throw new DuplicateException( + 'organization.name', + 'Organization with this name already exists', + ); + } + const initialProps = { name: input.name, acronym: input.acronym, @@ -43,14 +55,18 @@ export class OrganizationRepository extends DtoRepository< .apply(await createNode(Organization, { initialProps })) .return<{ id: ID }>('node.id as id'); - return await query.first(); + const result = await query.first(); + if (!result) { + throw new ServerException('Failed to create organization'); + } + + return await this.readOne(result.id, session); } - async update( - existing: Organization, - changes: ChangesOf, - ) { - return await this.updateProperties(existing, changes); + async update(changes: UpdateOrganization, session: Session) { + const { id, ...simpleChanges } = changes; + await this.updateProperties({ id }, simpleChanges); + return await this.readOne(id, session); } protected hydrate(session: Session) { diff --git a/src/components/organization/organization.service.ts b/src/components/organization/organization.service.ts index e4f8f95a0a..c6f476f96f 100644 --- a/src/components/organization/organization.service.ts +++ b/src/components/organization/organization.service.ts @@ -1,14 +1,12 @@ import { Injectable } from '@nestjs/common'; import { - DuplicateException, ID, ObjectView, ServerException, Session, UnsecuredDto, -} from '../../common'; -import { ConfigService, HandleIdLookup, ILogger, Logger } from '../../core'; -import { mapListResults } from '../../core/database/results'; +} from '~/common'; +import { HandleIdLookup } from '~/core'; import { Privileges } from '../authorization'; import { LocationListInput, @@ -27,8 +25,6 @@ import { OrganizationRepository } from './organization.repository'; @Injectable() export class OrganizationService { constructor( - @Logger('org:service') private readonly logger: ILogger, - private readonly config: ConfigService, private readonly privileges: Privileges, private readonly locationService: LocationService, private readonly repo: OrganizationRepository, @@ -38,26 +34,11 @@ export class OrganizationService { input: CreateOrganization, session: Session, ): Promise { - this.privileges.for(session, Organization).verifyCan('create'); + const created = await this.repo.create(input, session); - if (!(await this.repo.isUnique(input.name))) { - throw new DuplicateException( - 'organization.name', - 'Organization with this name already exists', - ); - } - - const result = await this.repo.create(input); - - if (!result) { - throw new ServerException('failed to create default org'); - } - - const id = result.id; - - this.logger.debug(`organization created`, { id }); + this.privileges.for(session, Organization, created).verifyCan('create'); - return await this.readOne(id, session); + return this.secure(created, session); } @HandleIdLookup(Organization) @@ -66,26 +47,19 @@ export class OrganizationService { session: Session, _view?: ObjectView, ): Promise { - this.logger.debug(`Read Organization`, { - id: orgId, - userId: session.userId, - }); - const result = await this.repo.readOne(orgId, session); - return await this.secure(result, session); + return this.secure(result, session); } async readMany(ids: readonly ID[], session: Session) { const organizations = await this.repo.readMany(ids, session); - return await Promise.all( - organizations.map((dto) => this.secure(dto, session)), - ); + return organizations.map((dto) => this.secure(dto, session)); } - private async secure( + private secure( dto: UnsecuredDto, session: Session, - ): Promise { + ): Organization { return this.privileges.for(session, Organization).secure(dto); } @@ -101,7 +75,12 @@ export class OrganizationService { .for(session, Organization, organization) .verifyChanges(changes); - return await this.repo.update(organization, changes); + const updated = await this.repo.update( + { id: input.id, ...changes }, + session, + ); + + return this.secure(updated, session); } async delete(id: ID, session: Session): Promise { @@ -112,11 +91,8 @@ export class OrganizationService { try { await this.repo.deleteNode(object); } catch (exception) { - this.logger.error('Failed to delete', { id, exception }); throw new ServerException('Failed to delete', exception); } - - this.logger.debug(`deleted organization with id`, { id }); } async list( @@ -124,7 +100,10 @@ export class OrganizationService { session: Session, ): Promise { const results = await this.repo.list(input, session); - return await mapListResults(results, (dto) => this.secure(dto, session)); + return { + ...results, + items: results.items.map((dto) => this.secure(dto, session)), + }; } async addLocation(organizationId: ID, locationId: ID): Promise { From 95fca12a89aeb46d2e1c892a011c6faa553e5882 Mon Sep 17 00:00:00 2001 From: Brent Kulwicki Date: Tue, 7 May 2024 16:35:03 -0500 Subject: [PATCH 5/8] Add Organization EdgeDB queries Co-authored-by: Carson Full --- .../organization.edgedb.repository.ts | 21 +++++++++++++++++++ .../organization/organization.module.ts | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/components/organization/organization.edgedb.repository.ts diff --git a/src/components/organization/organization.edgedb.repository.ts b/src/components/organization/organization.edgedb.repository.ts new file mode 100644 index 0000000000..45735fae2c --- /dev/null +++ b/src/components/organization/organization.edgedb.repository.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { PublicOf } from '~/common'; +import { e, RepoFor } from '~/core/edgedb'; +import { CreateOrganization, Organization } from './dto'; +import { OrganizationRepository } from './organization.repository'; + +@Injectable() +export class OrganizationEdgeDBRepository + extends RepoFor(Organization, { + hydrate: (organization) => organization['*'], + omit: ['create'], + }) + implements PublicOf +{ + async create(input: CreateOrganization) { + return await this.defaults.create({ + ...input, + projectContext: e.insert(e.Project.Context, {}), + }); + } +} diff --git a/src/components/organization/organization.module.ts b/src/components/organization/organization.module.ts index 9fc59fc258..2764f3c5fb 100644 --- a/src/components/organization/organization.module.ts +++ b/src/components/organization/organization.module.ts @@ -1,8 +1,10 @@ import { forwardRef, Module } from '@nestjs/common'; +import { splitDb } from '~/core'; import { AuthorizationModule } from '../authorization/authorization.module'; import { LocationModule } from '../location/location.module'; import { AddOrganizationReachMigration } from './migrations/add-reach.migration'; import { AddOrganizationTypeMigration } from './migrations/add-type.migration'; +import { OrganizationEdgeDBRepository } from './organization.edgedb.repository'; import { OrganizationLoader } from './organization.loader'; import { OrganizationRepository } from './organization.repository'; import { OrganizationResolver } from './organization.resolver'; @@ -13,7 +15,7 @@ import { OrganizationService } from './organization.service'; providers: [ OrganizationResolver, OrganizationService, - OrganizationRepository, + splitDb(OrganizationRepository, OrganizationEdgeDBRepository), OrganizationLoader, AddOrganizationReachMigration, AddOrganizationTypeMigration, From 8fc99e87e3bcbefa396322c30db2323be9dfdefe Mon Sep 17 00:00:00 2001 From: Carson Full Date: Tue, 7 May 2024 16:53:12 -0500 Subject: [PATCH 6/8] Update getChanges to support `LinkTo[]` --- src/core/database/changes.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/core/database/changes.ts b/src/core/database/changes.ts index 0ba98277cb..41112fc0a5 100644 --- a/src/core/database/changes.ts +++ b/src/core/database/changes.ts @@ -73,8 +73,10 @@ type ChangeOf = Val extends SetChangeType type RawChangeOf = IsFileField extends true ? CreateDefinedFileVersionInput - : Val extends LinkTo - ? ID + : Val extends LinkTo + ? ID + : Val extends ReadonlyArray> + ? ReadonlyArray> : Val; type IsFileField = Val extends LinkTo<'File'> @@ -133,7 +135,7 @@ export const getChanges = } const key = isRelation(res, prop) ? prop.slice(0, -2) : prop; let existing = unwrapSecured(existingObject[key]); - // Unwrap existing refs of IDs to input IDs. + // Unwrap existing LinkTo to input ID. if ( typeof change === 'string' && existing && @@ -142,6 +144,16 @@ export const getChanges = ) { existing = existing.id; } + // Unwrap existing LinkTo[] to input ID[]. + if ( + Array.isArray(change) && + typeof change[0] === 'string' && + Array.isArray(existing) && + typeof existing[0] === 'object' && + typeof existing[0].id === 'string' + ) { + existing = existing.map(({ id }) => id); + } return !isSame(change, existing); }); From 67df716bdbac9f46024e615935e4a2a704fb05f8 Mon Sep 17 00:00:00 2001 From: Brent Kulwicki Date: Tue, 7 May 2024 16:54:57 -0500 Subject: [PATCH 7/8] Refactor Partner service/repo Co-authored-by: Carson Full --- .../partner/dto/create-partner.dto.ts | 14 ++-- src/components/partner/dto/partner.dto.ts | 32 ++++----- .../partner/dto/update-partner.dto.ts | 14 ++-- src/components/partner/partner.repository.ts | 53 +++++++++------ src/components/partner/partner.resolver.ts | 23 ++++--- src/components/partner/partner.service.ts | 66 +++++++------------ 6 files changed, 96 insertions(+), 106 deletions(-) diff --git a/src/components/partner/dto/create-partner.dto.ts b/src/components/partner/dto/create-partner.dto.ts index 0c970a3900..0e22744c99 100644 --- a/src/components/partner/dto/create-partner.dto.ts +++ b/src/components/partner/dto/create-partner.dto.ts @@ -7,13 +7,9 @@ import { DateField, ID, IdField, - IdOf, IsId, NameField, -} from '../../../common'; -import { Location } from '../../../components/location'; -import { FieldRegion } from '../../field-region'; -import type { Language } from '../../language'; +} from '~/common'; import { FinancialReportingType } from '../../partnership/dto/financial-reporting-type.enum'; import { PartnerType } from './partner-type.enum'; import { Partner } from './partner.dto'; @@ -50,21 +46,21 @@ export abstract class CreatePartner { readonly address?: string; @IdField({ nullable: true }) - readonly languageOfWiderCommunicationId?: IdOf | null; + readonly languageOfWiderCommunicationId?: ID<'Language'> | null; @Field(() => [IDType], { nullable: true }) @IsId({ each: true }) @Transform(({ value }) => uniq(value)) - readonly countries?: ReadonlyArray> = []; + readonly countries?: ReadonlyArray> = []; @Field(() => [IDType], { nullable: true }) @IsId({ each: true }) @Transform(({ value }) => uniq(value)) - readonly fieldRegions?: ReadonlyArray> = []; + readonly fieldRegions?: ReadonlyArray> = []; @Field(() => [IDType], { name: 'languagesOfConsulting', nullable: true }) @Transform(({ value }) => uniq(value)) - readonly languagesOfConsulting?: ReadonlyArray> = []; + readonly languagesOfConsulting?: ReadonlyArray> = []; @DateField({ nullable: true }) readonly startDate?: CalendarDate; diff --git a/src/components/partner/dto/partner.dto.ts b/src/components/partner/dto/partner.dto.ts index 0c1178fad9..5dd4dbee2c 100644 --- a/src/components/partner/dto/partner.dto.ts +++ b/src/components/partner/dto/partner.dto.ts @@ -2,12 +2,8 @@ import { Type } from '@nestjs/common'; import { Field, ObjectType } from '@nestjs/graphql'; import { DateTime } from 'luxon'; import { keys as keysOf } from 'ts-transformer-keys'; -import { e } from '~/core/edgedb'; -import { RegisterResource } from '~/core/resources'; import { DateTimeField, - ID, - IdOf, IntersectionType, Resource, ResourceRelationsShape, @@ -17,14 +13,12 @@ import { SecuredEnumList, SecuredProperty, SecuredProps, - SecuredString, + SecuredStringNullable, Sensitivity, SensitivityField, -} from '../../../common'; -import { Location } from '../../../components/location'; -import { ScopedRole } from '../../authorization'; -import { FieldRegion } from '../../field-region'; -import type { Language } from '../../language'; +} from '~/common'; +import { e } from '~/core/edgedb'; +import { LinkTo, RegisterResource } from '~/core/resources'; import { FinancialReportingType } from '../../partnership/dto'; import { Pinnable } from '../../pin/dto'; import { Postable } from '../../post/dto'; @@ -56,9 +50,9 @@ export class Partner extends Interfaces { ...Postable.Relations, } satisfies ResourceRelationsShape); - readonly organization: Secured; + readonly organization: Secured>; - readonly pointOfContact: Secured; + readonly pointOfContact: Secured | null>; @Field() readonly types: SecuredPartnerTypes; @@ -78,14 +72,16 @@ export class Partner extends Interfaces { @Field() readonly address: SecuredStringNullable; - readonly languageOfWiderCommunication: Secured | null>; + readonly languageOfWiderCommunication: Secured | null>; - readonly fieldRegions: Required>>>; + readonly fieldRegions: Required< + Secured>> + >; - readonly countries: Required>>>; + readonly countries: Required>>>; readonly languagesOfConsulting: Required< - Secured>> + Secured>> >; @Field() @@ -98,10 +94,6 @@ export class Partner extends Interfaces { description: "Based on the project's sensitivity", }) readonly sensitivity: Sensitivity; - - // A list of non-global roles the requesting user has available for this object. - // This is just a cache, to prevent extra db lookups within the same request. - declare readonly scope: ScopedRole[]; } @ObjectType({ diff --git a/src/components/partner/dto/update-partner.dto.ts b/src/components/partner/dto/update-partner.dto.ts index 684d31d5cd..43f34d2547 100644 --- a/src/components/partner/dto/update-partner.dto.ts +++ b/src/components/partner/dto/update-partner.dto.ts @@ -7,13 +7,9 @@ import { DateField, ID, IdField, - IdOf, IsId, NameField, -} from '../../../common'; -import { Location } from '../../../components/location'; -import { FieldRegion } from '../../field-region'; -import type { Language } from '../../language'; +} from '~/common'; import { FinancialReportingType } from '../../partnership/dto'; import { PartnerType } from './partner-type.enum'; import { Partner } from './partner.dto'; @@ -50,21 +46,21 @@ export abstract class UpdatePartner { readonly address?: string; @IdField({ nullable: true }) - readonly languageOfWiderCommunicationId?: IdOf | null; + readonly languageOfWiderCommunicationId?: ID<'Language'> | null; @Field(() => [IDType], { nullable: true }) @IsId({ each: true }) @Transform(({ value }) => (value ? uniq(value) : undefined)) - readonly countries?: ReadonlyArray>; + readonly countries?: ReadonlyArray>; @Field(() => [IDType], { nullable: true }) @IsId({ each: true }) @Transform(({ value }) => (value ? uniq(value) : undefined)) - readonly fieldRegions?: ReadonlyArray>; + readonly fieldRegions?: ReadonlyArray>; @Field(() => [IDType], { name: 'languagesOfConsulting', nullable: true }) @Transform(({ value }) => (value ? uniq(value) : undefined)) - readonly languagesOfConsulting?: ReadonlyArray>; + readonly languagesOfConsulting?: ReadonlyArray>; @DateField({ nullable: true }) readonly startDate?: CalendarDate | null; diff --git a/src/components/partner/partner.repository.ts b/src/components/partner/partner.repository.ts index a48e63be69..b9a2e77954 100644 --- a/src/components/partner/partner.repository.ts +++ b/src/components/partner/partner.repository.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { node, Query, relation } from 'cypher-query-builder'; import { DateTime } from 'luxon'; -import { ChangesOf } from '~/core/database/changes'; import { CalendarDate, + DuplicateException, ID, InputException, ServerException, @@ -47,7 +47,15 @@ export class PartnerRepository extends DtoRepository< return result?.id; } - async create(input: CreatePartner) { + async create(input: CreatePartner, session: Session) { + const partnerExists = await this.partnerIdByOrg(input.organizationId); + if (partnerExists) { + throw new DuplicateException( + 'partner.organizationId', + 'Partner for organization already exists.', + ); + } + const initialProps = { types: input.types, financialReportingTypes: input.financialReportingTypes, @@ -80,11 +88,13 @@ export class PartnerRepository extends DtoRepository< if (!result) { throw new ServerException('Failed to create partner'); } - return result.id; + + return await this.readOne(result.id, session); } - async update(partner: Partner, changes: ChangesOf) { + async update(changes: UpdatePartner, session: Session) { const { + id, pointOfContactId, languageOfWiderCommunicationId, fieldRegions, @@ -93,13 +103,13 @@ export class PartnerRepository extends DtoRepository< ...simpleChanges } = changes; - await this.updateProperties(partner, simpleChanges); + await this.updateProperties({ id }, simpleChanges); if (pointOfContactId !== undefined) { await this.updateRelation( 'pointOfContact', 'User', - partner.id, + changes.id, pointOfContactId, ); } @@ -108,7 +118,7 @@ export class PartnerRepository extends DtoRepository< await this.updateRelation( 'languageOfWiderCommunication', 'Language', - partner.id, + changes.id, languageOfWiderCommunicationId, ); } @@ -116,7 +126,7 @@ export class PartnerRepository extends DtoRepository< if (countries) { try { await this.updateRelationList({ - id: partner.id, + id: changes.id, relation: 'countries', newList: countries, }); @@ -130,7 +140,7 @@ export class PartnerRepository extends DtoRepository< if (fieldRegions) { try { await this.updateRelationList({ - id: partner.id, + id: changes.id, relation: 'fieldRegions', newList: fieldRegions, }); @@ -144,7 +154,7 @@ export class PartnerRepository extends DtoRepository< if (languagesOfConsulting) { try { await this.updateRelationList({ - id: partner.id, + id: changes.id, relation: 'languagesOfConsulting', newList: languagesOfConsulting, }); @@ -154,6 +164,8 @@ export class PartnerRepository extends DtoRepository< : e; } } + + return await this.readOne(id, session); } protected hydrate(session: Session) { @@ -194,7 +206,7 @@ export class PartnerRepository extends DtoRepository< relation('out', '', 'fieldRegions'), node('fieldRegions', 'FieldRegion'), ]) - .return(collect('fieldRegions.id').as('fieldRegionsIds')), + .return(collect('fieldRegions { .id }').as('fieldRegions')), ) .subQuery('node', (sub) => sub @@ -203,7 +215,7 @@ export class PartnerRepository extends DtoRepository< relation('out', '', 'countries'), node('countries', 'Location'), ]) - .return(collect('countries.id').as('countriesIds')), + .return(collect('countries { .id }').as('countries')), ) .subQuery('node', (sub) => sub @@ -213,7 +225,9 @@ export class PartnerRepository extends DtoRepository< node('languagesOfConsulting', 'Language'), ]) .return( - 'collect(languagesOfConsulting.id) as languagesOfConsultingIds', + collect('languagesOfConsulting { .id }').as( + 'languagesOfConsulting', + ), ), ) .apply(matchProps()) @@ -235,12 +249,13 @@ export class PartnerRepository extends DtoRepository< .return<{ dto: UnsecuredDto }>( merge('props', { sensitivity: 'sensitivity', - organization: 'organization.id', - pointOfContact: 'pointOfContact.id', - languageOfWiderCommunication: 'languageOfWiderCommunication.id', - fieldRegions: 'fieldRegionsIds', - countries: 'countriesIds', - languagesOfConsulting: 'languagesOfConsultingIds', + organization: 'organization { .id }', + pointOfContact: 'pointOfContact { .id }', + languageOfWiderCommunication: + 'languageOfWiderCommunication { .id }', + fieldRegions: 'fieldRegions', + countries: 'countries', + languagesOfConsulting: 'languagesOfConsulting', scope: 'scopedRoles', pinned: 'exists((:User { id: $requestingUser })-[:pinned]->(node))', }).as('dto'), diff --git a/src/components/partner/partner.resolver.ts b/src/components/partner/partner.resolver.ts index aa6bf5a6bb..2c86164645 100644 --- a/src/components/partner/partner.resolver.ts +++ b/src/components/partner/partner.resolver.ts @@ -78,7 +78,7 @@ export class PartnerResolver { @Parent() partner: Partner, @Loader(OrganizationLoader) organizations: LoaderOf, ): Promise { - return await mapSecuredValue(partner.organization, (id) => + return await mapSecuredValue(partner.organization, ({ id }) => organizations.load(id), ); } @@ -88,7 +88,7 @@ export class PartnerResolver { @Parent() partner: Partner, @Loader(UserLoader) users: LoaderOf, ): Promise { - return await mapSecuredValue(partner.pointOfContact, (id) => + return await mapSecuredValue(partner.pointOfContact, ({ id }) => users.load(id), ); } @@ -98,8 +98,9 @@ export class PartnerResolver { @Parent() partner: Partner, @Loader(LanguageLoader) languages: LoaderOf, ): Promise { - return await mapSecuredValue(partner.languageOfWiderCommunication, (id) => - languages.load({ id, view: { active: true } }), + return await mapSecuredValue( + partner.languageOfWiderCommunication, + ({ id }) => languages.load({ id, view: { active: true } }), ); } @@ -108,7 +109,10 @@ export class PartnerResolver { @Parent() partner: Partner, @Loader(FieldRegionLoader) loader: LoaderOf, ): Promise { - return await loadSecuredIds(loader, partner.fieldRegions); + return await loadSecuredIds(loader, { + ...partner.fieldRegions, + value: partner.fieldRegions.value.map((region) => region.id), + }); } @ResolveField(() => SecuredLocations) @@ -116,7 +120,10 @@ export class PartnerResolver { @Parent() partner: Partner, @Loader(LocationLoader) loader: LoaderOf, ): Promise { - return await loadSecuredIds(loader, partner.countries); + return await loadSecuredIds(loader, { + ...partner.countries, + value: partner.countries.value.map((country) => country.id), + }); } @ResolveField(() => SecuredLanguages) @@ -124,10 +131,10 @@ export class PartnerResolver { @Parent() partner: Partner, @Loader(LanguageLoader) loader: LoaderOf, ): Promise { - const { value: ids, ...rest } = partner.languagesOfConsulting; + const { value: languages, ...rest } = partner.languagesOfConsulting; const value = await loadManyIgnoreMissingThrowAny( loader, - ids.map((id) => ({ id, view: { active: true } } as const)), + languages.map(({ id }) => ({ id, view: { active: true } } as const)), ); return { ...rest, value }; } diff --git a/src/components/partner/partner.service.ts b/src/components/partner/partner.service.ts index 2d14b09744..6aad0edd1c 100644 --- a/src/components/partner/partner.service.ts +++ b/src/components/partner/partner.service.ts @@ -1,8 +1,6 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { - DuplicateException, ID, - IdOf, InputException, loadManyIgnoreMissingThrowAny, NotFoundException, @@ -10,9 +8,8 @@ import { ServerException, Session, UnsecuredDto, -} from '../../common'; -import { HandleIdLookup, ILogger, Logger, ResourceLoader } from '../../core'; -import { mapListResults } from '../../core/database/results'; +} from '~/common'; +import { HandleIdLookup, ResourceLoader } from '~/core'; import { Privileges } from '../authorization'; import { LanguageListInput, @@ -40,7 +37,6 @@ import { PartnerRepository } from './partner.repository'; @Injectable() export class PartnerService { constructor( - @Logger('partner:service') private readonly logger: ILogger, private readonly privileges: Privileges, @Inject(forwardRef(() => ProjectService)) private readonly projectService: ProjectService & {}, @@ -51,36 +47,23 @@ export class PartnerService { ) {} async create(input: CreatePartner, session: Session): Promise { - this.privileges.for(session, Partner).verifyCan('create'); this.verifyFinancialReportingType( input.financialReportingTypes, input.types, ); - const partnerExists = await this.repo.partnerIdByOrg(input.organizationId); - if (partnerExists) { - throw new DuplicateException( - 'partner.organizationId', - 'Partner for organization already exists.', - ); - } - if (input.countries) { await this.verifyCountries(input.countries); } - const id = await this.repo.create(input); + const created = await this.repo.create(input, session); - this.logger.debug(`Partner created`, { id }); - return await this.readOne(id, session); + this.privileges.for(session, Partner, created).verifyCan('create'); + + return this.secure(created, session); } async readOnePartnerByOrgId(id: ID, session: Session): Promise { - this.logger.debug(`Read Partner by Org Id`, { - id: id, - userId: session.userId, - }); - const partnerId = await this.repo.partnerIdByOrg(id); if (!partnerId) throw new NotFoundException('No Partner Exists for this Org Id'); @@ -94,31 +77,26 @@ export class PartnerService { session: Session, _view?: ObjectView, ): Promise { - this.logger.debug(`Read Partner by Partner Id`, { - id: id, - userId: session.userId, - }); - const result = await this.repo.readOne(id, session); - return await this.secure(result, session); + return this.secure(result, session); } async readMany(ids: readonly ID[], session: Session) { const partners = await this.repo.readMany(ids, session); - return await Promise.all(partners.map((dto) => this.secure(dto, session))); + return partners.map((dto) => this.secure(dto, session)); } - private async secure(dto: UnsecuredDto, session: Session) { + private secure(dto: UnsecuredDto, session: Session) { return this.privileges.for(session, Partner).secure(dto); } async update(input: UpdatePartner, session: Session): Promise { - const partner = await this.readOne(input.id, session); + const partner = await this.repo.readOne(input.id, session); if ( !this.validateFinancialReportingType( - input.financialReportingTypes ?? partner.financialReportingTypes.value, - input.types ?? partner.types.value, + input.financialReportingTypes ?? partner.financialReportingTypes, + input.types ?? partner.types, ) ) { if (input.financialReportingTypes && input.types) { @@ -140,9 +118,15 @@ export class PartnerService { await this.verifyCountries(changes.countries); } - await this.repo.update(partner, changes); + const updated = await this.repo.update( + { + id: partner.id, + ...changes, + }, + session, + ); - return await this.readOne(input.id, session); + return this.secure(updated, session); } async delete(id: ID, session: Session): Promise { @@ -153,11 +137,8 @@ export class PartnerService { try { await this.repo.deleteNode(object); } catch (exception: any) { - this.logger.error('Failed to delete', { id, exception }); throw new ServerException('Failed to delete', exception); } - - this.logger.debug(`deleted partner with id`, { id }); } async list( @@ -165,7 +146,10 @@ export class PartnerService { session: Session, ): Promise { const results = await this.repo.list(input, session); - return await mapListResults(results, (dto) => this.secure(dto, session)); + return { + ...results, + items: results.items.map((dto) => this.secure(dto, session)), + }; } async listProjects( @@ -224,7 +208,7 @@ export class PartnerService { : true; } - private async verifyCountries(ids: ReadonlyArray>) { + private async verifyCountries(ids: ReadonlyArray>) { const loader = await this.resourceLoader.getLoader(LocationLoader); const locations = await loadManyIgnoreMissingThrowAny(loader, ids); const invalidIds = locations.flatMap((location) => From 51c93e6ac0e42cbe45e4582b58a20a510d644082 Mon Sep 17 00:00:00 2001 From: Brent Kulwicki Date: Tue, 7 May 2024 17:01:13 -0500 Subject: [PATCH 8/8] Add Partner EdgeDB queries Co-authored-by: Carson Full --- .../partner/partner.edgedb.repository.ts | 38 +++++++++++++++++++ src/components/partner/partner.module.ts | 4 +- src/components/partner/partner.repository.ts | 2 +- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 src/components/partner/partner.edgedb.repository.ts diff --git a/src/components/partner/partner.edgedb.repository.ts b/src/components/partner/partner.edgedb.repository.ts new file mode 100644 index 0000000000..bf8d6e7047 --- /dev/null +++ b/src/components/partner/partner.edgedb.repository.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { ID, PublicOf } from '~/common'; +import { e, RepoFor } from '~/core/edgedb'; +import { CreatePartner, Partner } from './dto'; +import { PartnerRepository } from './partner.repository'; + +@Injectable() +export class PartnerEdgeDBRepository + extends RepoFor(Partner, { + hydrate: (partner) => ({ + ...partner['*'], + organization: true, + pointOfContact: true, + languageOfWiderCommunication: true, + fieldRegions: true, + countries: true, + languagesOfConsulting: true, + }), + omit: ['create'], + }) + implements PublicOf +{ + async create(input: CreatePartner) { + const organization = e.cast(e.Organization, e.uuid(input.organizationId)); + return await this.defaults.create({ + name: organization.name, + ...input, + }); + } + + async partnerIdByOrg(organizationId: ID) { + const organization = e.cast(e.Organization, e.uuid(organizationId)); + const partner = e.select(e.Partner, () => ({ + filter_single: { organization }, + })); + return await this.db.run(partner.id); + } +} diff --git a/src/components/partner/partner.module.ts b/src/components/partner/partner.module.ts index 90362ab774..0c32cd9efb 100644 --- a/src/components/partner/partner.module.ts +++ b/src/components/partner/partner.module.ts @@ -1,9 +1,11 @@ import { forwardRef, Module } from '@nestjs/common'; +import { splitDb } from '~/core'; import { AuthorizationModule } from '../authorization/authorization.module'; import { LanguageModule } from '../language/language.module'; import { OrganizationModule } from '../organization/organization.module'; import { ProjectModule } from '../project/project.module'; import { UserModule } from '../user/user.module'; +import { PartnerEdgeDBRepository } from './partner.edgedb.repository'; import { PartnerLoader } from './partner.loader'; import { PartnerRepository } from './partner.repository'; import { PartnerResolver } from './partner.resolver'; @@ -20,7 +22,7 @@ import { PartnerService } from './partner.service'; providers: [ PartnerResolver, PartnerService, - PartnerRepository, + splitDb(PartnerRepository, PartnerEdgeDBRepository), PartnerLoader, ], exports: [PartnerService], diff --git a/src/components/partner/partner.repository.ts b/src/components/partner/partner.repository.ts index b9a2e77954..1c3aa26134 100644 --- a/src/components/partner/partner.repository.ts +++ b/src/components/partner/partner.repository.ts @@ -44,7 +44,7 @@ export class PartnerRepository extends DtoRepository< ]) .return<{ id: ID }>('partner.id as id') .first(); - return result?.id; + return result?.id ?? null; } async create(input: CreatePartner, session: Session) {