From d82be69fdb61ce517ab2871e3682a52e983bf331 Mon Sep 17 00:00:00 2001 From: Adam Lang Date: Thu, 9 May 2024 20:38:07 -0700 Subject: [PATCH 01/27] chore: add pinnable edgedb query --- src/components/pin/dto/pinnable.dto.ts | 4 + src/components/pin/pin.edgedb.repository.ts | 83 +++++++++++++++++++++ src/components/pin/pin.module.ts | 8 +- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/components/pin/pin.edgedb.repository.ts diff --git a/src/components/pin/dto/pinnable.dto.ts b/src/components/pin/dto/pinnable.dto.ts index 36945789c1..5694f7b0a0 100644 --- a/src/components/pin/dto/pinnable.dto.ts +++ b/src/components/pin/dto/pinnable.dto.ts @@ -1,10 +1,14 @@ import { Field, InterfaceType } from '@nestjs/graphql'; +import { keys } from 'ts-transformer-keys'; import { ID, IdField } from '../../../common'; @InterfaceType({ description: 'An item that can be pinned', }) export class Pinnable { + static readonly Props = keys(); + static readonly SecuredProps = keys(); + @IdField() readonly id: ID; diff --git a/src/components/pin/pin.edgedb.repository.ts b/src/components/pin/pin.edgedb.repository.ts new file mode 100644 index 0000000000..cd91006eb9 --- /dev/null +++ b/src/components/pin/pin.edgedb.repository.ts @@ -0,0 +1,83 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DateTime } from 'luxon'; +import { ID, PublicOf, Session } from '~/common'; +import { Client } from '~/core/edgedb'; +import { PinRepository } from './pin.repository'; + +@Injectable() +export class PinEdgeDBRepository implements PublicOf { + constructor(@Inject(Client) private readonly client: Client) {} + + async isPinned(id: ID, session: Session): Promise { + const res: boolean | null = await this.client.querySingle( + ` + select exists( + select BaseNode { + id, + pinned_by := . { + id + } + } + filter .id = $id + and .pinned_by.id = $userId + ); + `, + { + id, + userId: session.userId, + }, + ); + + return Boolean(res); + } + + async add(id: ID, session: Session): Promise { + const createdAt = DateTime.local(); + + await this.client.query( + ` + with BaseNode := ( + select BaseNode + filter .id = $id + ), + User := ( + select User + filter .id = $userId + ) + insert PinnedRelation { + node := BaseNode, + user := User, + createdAt := $createdAt + } + unless conflict on (.node, .user) + `, + { + id, + userId: session.userId, + createdAt: createdAt.toJSDate(), + }, + ); + } + + async remove(id: ID, session: Session): Promise { + await this.client.query( + ` + with BaseNode := ( + select BaseNode + filter .id = $id + ), + User := ( + select User + filter .id = $userId + ) + delete PinnedRelation + filter .node = BaseNode + and .user = User + `, + { + id, + userId: session.userId, + }, + ); + } +} diff --git a/src/components/pin/pin.module.ts b/src/components/pin/pin.module.ts index af39df436c..443dda13e8 100644 --- a/src/components/pin/pin.module.ts +++ b/src/components/pin/pin.module.ts @@ -1,10 +1,16 @@ import { Module } from '@nestjs/common'; +import { splitDb } from '~/core'; +import { PinEdgeDBRepository } from './pin.edgedb.repository'; import { PinRepository } from './pin.repository'; import { PinResolver } from './pin.resolver'; import { PinService } from './pin.service'; @Module({ - providers: [PinResolver, PinService, PinRepository], + providers: [ + PinResolver, + PinService, + splitDb(PinRepository, PinEdgeDBRepository), + ], exports: [PinService], }) export class PinModule {} From 03125c125a490ac6ed37f442b06a6f885058ec3e Mon Sep 17 00:00:00 2001 From: Carson Full Date: Thu, 9 May 2024 11:50:36 -0500 Subject: [PATCH 02/27] Add /graphql to logged path for DX --- src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 21f006cfe7..0b3a55450d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,7 +29,7 @@ async function bootstrap() { app.enableShutdownHooks(); await app.listen(config.port, () => { - app.get(Logger).log(`Listening at ${config.hostUrl}`); + app.get(Logger).log(`Listening at ${config.hostUrl}graphql`); }); } bootstrap().catch((err) => { From 4292a6d87647a7875b36e7752fa04b83935a9183 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Thu, 9 May 2024 11:51:16 -0500 Subject: [PATCH 03/27] Remove the deprecated `version` from docker compose files --- docker-compose.override.example.yml | 1 - docker-compose.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/docker-compose.override.example.yml b/docker-compose.override.example.yml index d9ca8d67a1..b709e36c8c 100644 --- a/docker-compose.override.example.yml +++ b/docker-compose.override.example.yml @@ -1,5 +1,4 @@ # Copy this file to docker-compose.override.yml to use -version: '3.7' services: db: diff --git a/docker-compose.yml b/docker-compose.yml index a6fccc858e..5f7041ca37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.7' services: db: image: neo4j:5.10-enterprise From e73bcaee18b05a4d941076acc4f93462234d797a Mon Sep 17 00:00:00 2001 From: Carson Full Date: Thu, 9 May 2024 15:29:54 -0500 Subject: [PATCH 04/27] Financial Approvers (#3206) --- dbschema/migrations/00008-m1pgopp.edgeql | 17 ++++ dbschema/project.esdl | 13 +++ .../dto/financial-approver.dto.ts | 39 +++++++++ .../project/financial-approver/dto/index.ts | 1 + .../financial-approver-neo4j.repository.ts | 87 +++++++++++++++++++ .../financial-approver.module.ts | 17 ++++ .../financial-approver.repository.ts | 57 ++++++++++++ .../financial-approver.resolver.ts | 56 ++++++++++++ .../project/financial-approver/index.ts | 1 + src/components/project/project.module.ts | 2 + src/core/database/split-db.provider.ts | 13 +++ 11 files changed, 303 insertions(+) create mode 100644 dbschema/migrations/00008-m1pgopp.edgeql create mode 100644 src/components/project/financial-approver/dto/financial-approver.dto.ts create mode 100644 src/components/project/financial-approver/dto/index.ts create mode 100644 src/components/project/financial-approver/financial-approver-neo4j.repository.ts create mode 100644 src/components/project/financial-approver/financial-approver.module.ts create mode 100644 src/components/project/financial-approver/financial-approver.repository.ts create mode 100644 src/components/project/financial-approver/financial-approver.resolver.ts create mode 100644 src/components/project/financial-approver/index.ts diff --git a/dbschema/migrations/00008-m1pgopp.edgeql b/dbschema/migrations/00008-m1pgopp.edgeql new file mode 100644 index 0000000000..fe24e0c897 --- /dev/null +++ b/dbschema/migrations/00008-m1pgopp.edgeql @@ -0,0 +1,17 @@ +CREATE MIGRATION m1gq2hsptfudyqzcqhaz3o5ikdckynzcdegdixqtrdtnisldpyqv6a + ONTO m1s2cbqfqayiw2giggpp3dlfwrxnmpaziw7irc4h74chugwr4noluq +{ + CREATE SCALAR TYPE Project::Type EXTENDING enum; + CREATE TYPE Project::FinancialApprover { + CREATE REQUIRED LINK user: default::User { + CREATE CONSTRAINT std::exclusive; + }; + CREATE REQUIRED MULTI PROPERTY projectTypes: Project::Type; + CREATE ACCESS POLICY CanInsertDeleteGeneratedFromAppPoliciesForFinancialApprover + ALLOW DELETE, INSERT USING ((default::Role.Administrator IN GLOBAL default::currentRoles)); + CREATE ACCESS POLICY CanSelectUpdateReadGeneratedFromAppPoliciesForFinancialApprover + ALLOW SELECT, UPDATE READ USING (EXISTS (({'Administrator', 'Leadership'} INTERSECT GLOBAL default::currentRoles))); + CREATE ACCESS POLICY CanUpdateWriteGeneratedFromAppPoliciesForFinancialApprover + ALLOW UPDATE WRITE; + }; +}; diff --git a/dbschema/project.esdl b/dbschema/project.esdl index 27630213c9..81103f9f2d 100644 --- a/dbschema/project.esdl +++ b/dbschema/project.esdl @@ -176,6 +176,19 @@ module Project { required single property isMember := exists .projectContext.projects.membership; } + scalar type Type extending enum< + MomentumTranslation, + MultiplicationTranslation, + Internship + >; + + type FinancialApprover { + required user: default::User { + constraint exclusive; + }; + required multi projectTypes: Type; + } + type Context { annotation description := "\ A type that holds a reference to a list of projects. \ diff --git a/src/components/project/financial-approver/dto/financial-approver.dto.ts b/src/components/project/financial-approver/dto/financial-approver.dto.ts new file mode 100644 index 0000000000..2cccf35a99 --- /dev/null +++ b/src/components/project/financial-approver/dto/financial-approver.dto.ts @@ -0,0 +1,39 @@ +import { Field, InputType, ObjectType } from '@nestjs/graphql'; +import { keys as keysOf } from 'ts-transformer-keys'; +import { ID, IdField, SecuredProps, UnsecuredDto } from '~/common'; +import { LinkTo, RegisterResource } from '~/core'; +import { e } from '~/core/edgedb'; +import { User } from '../../../user'; +import { ProjectType } from '../../dto'; + +@RegisterResource({ + db: e.Project.FinancialApprover, +}) +@ObjectType('ProjectTypeFinancialApprover') +export class FinancialApprover { + static readonly Props = keysOf(); + static readonly SecuredProps = keysOf>(); + + readonly user: LinkTo<'User'> & Pick, 'email'>; + + @Field(() => [ProjectType]) + readonly projectTypes: readonly [ProjectType, ...ProjectType[]]; +} + +@InputType('ProjectTypeFinancialApproverInput') +export abstract class FinancialApproverInput { + @IdField() + readonly user: ID<'User'>; + + @Field(() => [ProjectType]) + readonly projectTypes: readonly [ProjectType, ...ProjectType[]]; +} + +declare module '~/core/resources/map' { + interface ResourceMap { + FinancialApprover: typeof FinancialApprover; + } + interface ResourceDBMap { + FinancialApprover: typeof e.Project.FinancialApprover; + } +} diff --git a/src/components/project/financial-approver/dto/index.ts b/src/components/project/financial-approver/dto/index.ts new file mode 100644 index 0000000000..e1f188cf2b --- /dev/null +++ b/src/components/project/financial-approver/dto/index.ts @@ -0,0 +1 @@ +export * from './financial-approver.dto'; diff --git a/src/components/project/financial-approver/financial-approver-neo4j.repository.ts b/src/components/project/financial-approver/financial-approver-neo4j.repository.ts new file mode 100644 index 0000000000..f95294448f --- /dev/null +++ b/src/components/project/financial-approver/financial-approver-neo4j.repository.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { many, Many } from '@seedcompany/common'; +import { node, Query, relation } from 'cypher-query-builder'; +import { PublicOf, ServerException } from '~/common'; +import { CommonRepository } from '~/core/database'; +import { ACTIVE, merge } from '~/core/database/query'; +import { ProjectType } from '../dto/project-type.enum'; +import { FinancialApprover, FinancialApproverInput } from './dto'; +import { FinancialApproverRepository } from './financial-approver.repository'; + +@Injectable() +export class FinancialApproverNeo4jRepository + extends CommonRepository + implements PublicOf +{ + async read(types?: Many) { + const query = this.db + .query() + .match([ + node('node', 'ProjectTypeFinancialApprover'), + relation('out', '', 'financialApprover', ACTIVE), + node('user', 'User'), + ]) + .apply((q) => + types + ? q.raw( + `WHERE size(apoc.coll.intersection(node.projectTypes, $types)) > 0`, + { types: many(types) }, + ) + : q, + ) + .apply(this.hydrate()); + return await query.run(); + } + + async write(input: FinancialApproverInput) { + if (input.projectTypes.length === 0) { + const query = this.db + .query() + .match([ + node('node', 'ProjectTypeFinancialApprover'), + relation('out', '', 'financialApprover', ACTIVE), + node('user', 'User', { id: input.user }), + ]) + .detachDelete('node'); + await query.run(); + return null; + } + + const query = this.db + .query() + .match(node('user', 'User', { id: input.user })) + .merge([ + node('node', 'ProjectTypeFinancialApprover'), + relation('out', '', 'financialApprover', { active: true }), + node('user'), + ]) + .setValues({ + 'node.projectTypes': input.projectTypes, + }) + .apply(this.hydrate()); + + const result = await query.first(); + if (!result) { + throw new ServerException('Failed to set financial approver.'); + } + + return result; + } + + private hydrate() { + return (query: Query) => + query + .with('node, user') + .optionalMatch([ + node('user'), + relation('out', '', 'email', ACTIVE), + node('email'), + ]) + .return<{ dto: FinancialApprover }>( + merge('node', { + user: merge('user { .id }', { email: 'email.value' }), + }).as('dto'), + ) + .map('dto'); + } +} diff --git a/src/components/project/financial-approver/financial-approver.module.ts b/src/components/project/financial-approver/financial-approver.module.ts new file mode 100644 index 0000000000..75efd5718a --- /dev/null +++ b/src/components/project/financial-approver/financial-approver.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { splitDb2 } from '~/core'; +import { FinancialApproverNeo4jRepository } from './financial-approver-neo4j.repository'; +import { FinancialApproverRepository } from './financial-approver.repository'; +import { FinancialApproverResolver } from './financial-approver.resolver'; + +@Module({ + providers: [ + FinancialApproverResolver, + splitDb2(FinancialApproverRepository, { + edge: FinancialApproverRepository, + neo4j: FinancialApproverNeo4jRepository, + }), + ], + exports: [FinancialApproverRepository], +}) +export class FinancialApproverModule {} diff --git a/src/components/project/financial-approver/financial-approver.repository.ts b/src/components/project/financial-approver/financial-approver.repository.ts new file mode 100644 index 0000000000..de238adf2a --- /dev/null +++ b/src/components/project/financial-approver/financial-approver.repository.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { many, Many } from '@seedcompany/common'; +import { e, EdgeDB } from '~/core/edgedb'; +import { ProjectType } from '../dto'; +import { FinancialApproverInput } from './dto'; + +@Injectable() +export class FinancialApproverRepository { + constructor(private readonly db: EdgeDB) {} + + async read(types?: Many) { + const query = e.select(e.Project.FinancialApprover, (approver) => ({ + projectTypes: true, + user: () => ({ id: true, email: true }), + ...(types + ? { + filter: e.op( + 'exists', + e.op( + approver.projectTypes, + 'intersect', + e.cast(e.Project.Type, e.set(...many(types))), + ), + ), + } + : {}), + })); + return await this.db.run(query); + } + + async write({ user: userId, projectTypes }: FinancialApproverInput) { + const user = e.cast(e.User, e.cast(e.uuid, userId)); + + if (projectTypes.length === 0) { + const query = e.delete(e.Project.FinancialApprover, (fa) => ({ + filter: e.op(fa.user, '=', user), + })); + await this.db.run(query); + return null; + } + + const written = e + .insert(e.Project.FinancialApprover, { user, projectTypes }) + .unlessConflict(({ user }) => ({ + on: user, + else: e.update(e.Project.FinancialApprover, (approver) => ({ + filter_single: e.op(approver.user, '=', user), + set: { projectTypes }, + })), + })); + const query = e.select(written, () => ({ + projectTypes: true, + user: () => ({ id: true, email: true }), + })); + return await this.db.run(query); + } +} diff --git a/src/components/project/financial-approver/financial-approver.resolver.ts b/src/components/project/financial-approver/financial-approver.resolver.ts new file mode 100644 index 0000000000..12a989c87b --- /dev/null +++ b/src/components/project/financial-approver/financial-approver.resolver.ts @@ -0,0 +1,56 @@ +import { + Args, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import { LoggedInSession, Session } from '~/common'; +import { Loader, LoaderOf } from '~/core'; +import { Privileges } from '../../authorization'; +import { User, UserLoader } from '../../user'; +import { ProjectType } from '../dto/project-type.enum'; +import { FinancialApprover, FinancialApproverInput } from './dto'; +import { FinancialApproverRepository } from './financial-approver.repository'; + +@Resolver(FinancialApprover) +export class FinancialApproverResolver { + constructor( + private readonly repo: FinancialApproverRepository, + private readonly privileges: Privileges, + ) {} + + @Query(() => [FinancialApprover]) + async projectTypeFinancialApprovers( + @Args({ + name: 'projectTypes', + type: () => [ProjectType], + nullable: true, + }) + types: readonly ProjectType[] | undefined, + @LoggedInSession() _: Session, // require login + ): Promise { + return await this.repo.read(types); + } + + @Mutation(() => FinancialApprover, { + description: 'Set a user as a financial approver for some project types', + nullable: true, + }) + async setProjectTypeFinancialApprover( + @Args('input') input: FinancialApproverInput, + @LoggedInSession() session: Session, + ): Promise { + this.privileges.for(session, FinancialApprover).verifyCan('edit'); + return await this.repo.write(input); + } + + @ResolveField(() => User) + async user( + @Parent() { user }: FinancialApprover, + @Loader(UserLoader) users: LoaderOf, + ): Promise { + return await users.load(user.id); + } +} diff --git a/src/components/project/financial-approver/index.ts b/src/components/project/financial-approver/index.ts new file mode 100644 index 0000000000..a03886210e --- /dev/null +++ b/src/components/project/financial-approver/index.ts @@ -0,0 +1 @@ +export * from './financial-approver.repository'; diff --git a/src/components/project/project.module.ts b/src/components/project/project.module.ts index 1d23236768..9934fe4dc1 100644 --- a/src/components/project/project.module.ts +++ b/src/components/project/project.module.ts @@ -11,6 +11,7 @@ import { PartnershipModule } from '../partnership/partnership.module'; import { ProjectChangeRequestModule } from '../project-change-request/project-change-request.module'; import { UserModule } from '../user/user.module'; import { ProjectEngagementConnectionResolver } from './engagement-connection.resolver'; +import { FinancialApproverModule } from './financial-approver/financial-approver.module'; import * as handlers from './handlers'; import { InternshipProjectResolver } from './internship-project.resolver'; import { RenameTranslationToMomentumMigration } from './migrations/rename-translation-to-momentum.migration'; @@ -41,6 +42,7 @@ import { ProjectUserConnectionResolver } from './user-connection.resolver'; forwardRef(() => AuthorizationModule), PartnerModule, forwardRef(() => OrganizationModule), + FinancialApproverModule, ], providers: [ ProjectResolver, diff --git a/src/core/database/split-db.provider.ts b/src/core/database/split-db.provider.ts index 1cc0dfeee6..c6ffa78fe7 100644 --- a/src/core/database/split-db.provider.ts +++ b/src/core/database/split-db.provider.ts @@ -16,3 +16,16 @@ export const splitDb = ( return await moduleRef.create(cls); }, } satisfies Provider); + +export const splitDb2 = ( + token: Type, + repos: { edge: Type>; neo4j: Type> }, +) => + ({ + provide: token, + inject: [ModuleRef, ConfigService], + useFactory: async (moduleRef: ModuleRef, config: ConfigService) => { + const cls = config.databaseEngine === 'edgedb' ? repos.edge : repos.neo4j; + return await moduleRef.create(cls); + }, + } satisfies Provider); From cf457b4f3ae9ba9a6a6877dec91b90f6cab0f5d0 Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 6 May 2024 14:58:53 -0700 Subject: [PATCH 05/27] Register story EdgeDB repository --- src/components/story/story.module.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/story/story.module.ts b/src/components/story/story.module.ts index 0fcf16076d..980fd1becf 100644 --- a/src/components/story/story.module.ts +++ b/src/components/story/story.module.ts @@ -1,6 +1,8 @@ import { forwardRef, Module } from '@nestjs/common'; +import { splitDb } from '~/core'; import { AuthorizationModule } from '../authorization/authorization.module'; import { ScriptureModule } from '../scripture'; +import { StoryEdgeDBRepository } from './story.edgedb.repository'; import { StoryLoader } from './story.loader'; import { StoryRepository } from './story.repository'; import { StoryResolver } from './story.resolver'; @@ -8,7 +10,12 @@ import { StoryService } from './story.service'; @Module({ imports: [forwardRef(() => AuthorizationModule), ScriptureModule], - providers: [StoryResolver, StoryService, StoryRepository, StoryLoader], + providers: [ + StoryResolver, + StoryService, + splitDb(StoryRepository, StoryEdgeDBRepository), + StoryLoader, + ], exports: [StoryService], }) export class StoryModule {} From 918e6d5b92c76191d07c068261658c329823e828 Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 6 May 2024 14:57:58 -0700 Subject: [PATCH 06/27] Fix scripture verseId query types --- src/components/scripture/edgedb.utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/scripture/edgedb.utils.ts b/src/components/scripture/edgedb.utils.ts index 19cd5f1d97..2418e75199 100644 --- a/src/components/scripture/edgedb.utils.ts +++ b/src/components/scripture/edgedb.utils.ts @@ -13,7 +13,7 @@ const verse = e.tuple({ book: e.str, chapter: e.int16, verse: e.int16, - verseId: e.int32, + verseId: e.int16, }); const verseRangeType = e.tuple({ @@ -66,13 +66,13 @@ export const insert = (param: $expr_Param) => { book: e.cast(e.str, start.book), chapter: e.cast(e.int16, start.chapter), verse: e.cast(e.int16, start.verse), - verseId: e.cast(e.int32, start.verseId), + verseId: e.cast(e.int16, start.verseId), }), end: e.insert(e.Scripture.Verse, { book: e.cast(e.str, end.book), chapter: e.cast(e.int16, end.chapter), verse: e.cast(e.int16, end.verse), - verseId: e.cast(e.int32, end.verseId), + verseId: e.cast(e.int16, end.verseId), }), }); }), From 9d17f3ebfb39a3cde6e5d27ea1f598683681f4d6 Mon Sep 17 00:00:00 2001 From: William Harris Date: Tue, 7 May 2024 17:37:08 -0700 Subject: [PATCH 07/27] Update delete method to use repo.readOne --- src/components/story/story.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/story/story.service.ts b/src/components/story/story.service.ts index aa779aab87..1215a1ca3d 100644 --- a/src/components/story/story.service.ts +++ b/src/components/story/story.service.ts @@ -63,7 +63,7 @@ export class StoryService { } async delete(id: ID, session: Session): Promise { - const story = await this.readOne(id, session); + const story = await this.repo.readOne(id); this.privileges.for(session, Story, story).verifyCan('delete'); From f2578810474ee4c03210310ee07259412f5da385 Mon Sep 17 00:00:00 2001 From: Will Harris <39035380+willdch@users.noreply.github.com> Date: Thu, 9 May 2024 14:14:25 -0700 Subject: [PATCH 08/27] [EdgeDB] Film queries (#3185) --- src/components/film/film.edgedb.repository.ts | 53 ++++++++++ src/components/film/film.module.ts | 9 +- src/components/film/film.repository.ts | 98 +++++++++++++++---- src/components/film/film.service.ts | 93 ++++-------------- 4 files changed, 160 insertions(+), 93 deletions(-) create mode 100644 src/components/film/film.edgedb.repository.ts diff --git a/src/components/film/film.edgedb.repository.ts b/src/components/film/film.edgedb.repository.ts new file mode 100644 index 0000000000..fbdb32209b --- /dev/null +++ b/src/components/film/film.edgedb.repository.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { PublicOf, UnsecuredDto } from '~/common'; +import { e, RepoFor } from '~/core/edgedb'; +import * as scripture from '../scripture/edgedb.utils'; +import { CreateFilm, Film, UpdateFilm } from './dto'; +import { FilmRepository } from './film.repository'; + +@Injectable() +export class FilmEdgedbRepository + extends RepoFor(Film, { + hydrate: (film) => ({ + ...film['*'], + scriptureReferences: scripture.hydrate(film.scripture), + }), + omit: ['create', 'update'], + }) + implements PublicOf +{ + async create(input: CreateFilm): Promise> { + const query = e.params( + { name: e.str, scripture: e.optional(scripture.type) }, + ($) => { + const created = e.insert(this.resource.db, { + name: $.name, + scripture: scripture.insert($.scripture), + }); + return e.select(created, this.hydrate); + }, + ); + return await this.db.run(query, { + name: input.name, + scripture: scripture.valueOptional(input.scriptureReferences), + }); + } + + async update({ id, ...changes }: UpdateFilm): Promise> { + const query = e.params({ scripture: e.optional(scripture.type) }, ($) => { + const film = e.cast(e.Film, e.uuid(id)); + const updated = e.update(film, () => ({ + set: { + ...(changes.name ? { name: changes.name } : {}), + ...(changes.scriptureReferences !== undefined + ? { scripture: scripture.insert($.scripture) } + : {}), + }, + })); + return e.select(updated, this.hydrate); + }); + return await this.db.run(query, { + scripture: scripture.valueOptional(changes.scriptureReferences), + }); + } +} diff --git a/src/components/film/film.module.ts b/src/components/film/film.module.ts index 0af767dc1d..bc14c96bc2 100644 --- a/src/components/film/film.module.ts +++ b/src/components/film/film.module.ts @@ -1,6 +1,8 @@ import { forwardRef, Module } from '@nestjs/common'; +import { splitDb } from '~/core'; import { AuthorizationModule } from '../authorization/authorization.module'; import { ScriptureModule } from '../scripture'; +import { FilmEdgedbRepository } from './film.edgedb.repository'; import { FilmLoader } from './film.loader'; import { FilmRepository } from './film.repository'; import { FilmResolver } from './film.resolver'; @@ -8,7 +10,12 @@ import { FilmService } from './film.service'; @Module({ imports: [forwardRef(() => AuthorizationModule), ScriptureModule], - providers: [FilmResolver, FilmService, FilmRepository, FilmLoader], + providers: [ + FilmResolver, + FilmService, + splitDb(FilmRepository, FilmEdgedbRepository), + FilmLoader, + ], exports: [FilmService], }) export class FilmModule {} diff --git a/src/components/film/film.repository.ts b/src/components/film/film.repository.ts index fbedc16804..b936edc238 100644 --- a/src/components/film/film.repository.ts +++ b/src/components/film/film.repository.ts @@ -1,60 +1,118 @@ import { Injectable } from '@nestjs/common'; -import { node, Query } from 'cypher-query-builder'; -import { ChangesOf } from '~/core/database/changes'; -import { ID, Session } from '../../common'; -import { DbTypeOf, DtoRepository } from '../../core'; +import { Query } from 'cypher-query-builder'; +import { + DuplicateException, + ID, + PaginatedListType, + ServerException, + Session, + UnsecuredDto, +} from '~/common'; +import { DbTypeOf, DtoRepository } from '~/core'; import { createNode, matchProps, merge, paginate, - requestingUser, sorting, -} from '../../core/database/query'; -import { ScriptureReferenceRepository } from '../scripture'; +} from '~/core/database/query'; +import { + ScriptureReferenceRepository, + ScriptureReferenceService, +} from '../scripture'; import { CreateFilm, Film, FilmListInput, UpdateFilm } from './dto'; @Injectable() export class FilmRepository extends DtoRepository(Film) { - constructor(private readonly scriptureRefs: ScriptureReferenceRepository) { + constructor( + private readonly scriptureRefsRepository: ScriptureReferenceRepository, + private readonly scriptureRefsService: ScriptureReferenceService, + ) { super(); } - async create(input: CreateFilm) { + async create(input: CreateFilm, session: Session) { + if (!(await this.isUnique(input.name))) { + throw new DuplicateException( + 'film.name', + 'Film with this name already exists', + ); + } + const initialProps = { name: input.name, canDelete: true, }; - return await this.db + const result = await this.db .query() .apply(await createNode(Film, { initialProps })) .return<{ id: ID }>('node.id as id') .first(); + + if (!result) { + throw new ServerException('failed to create a film'); + } + + await this.scriptureRefsService.create( + result.id, + input.scriptureReferences, + session, + ); + + return await this.readOne(result.id); } - async update( - existing: Film, - simpleChanges: Omit, 'scriptureReferences'>, - ) { - await this.updateProperties(existing, simpleChanges); + async update(input: UpdateFilm) { + const { id, name, scriptureReferences } = input; + await this.updateProperties({ id }, { name }); + if (scriptureReferences !== undefined) { + await this.scriptureRefsService.update(id, scriptureReferences); + } + return await this.readOne(input.id); + } + + async readOne(id: ID) { + return (await super.readOne(id)) as UnsecuredDto; } - async list({ filter, ...input }: FilmListInput, session: Session) { + async readMany( + ids: readonly ID[], + ): Promise>> { + const items = await super.readMany(ids); + return items.map((r) => ({ + ...r, + scriptureReferences: this.scriptureRefsService.parseList( + r.scriptureReferences, + ), + })); + } + + async list({ + filter, + ...input + }: FilmListInput): Promise>> { const result = await this.db .query() - .match(requestingUser(session)) - .match(node('node', 'Film')) + .matchNode('node', 'Film') .apply(sorting(Film, input)) .apply(paginate(input, this.hydrate())) .first(); - return result!; // result from paginate() will always have 1 row. + return { + ...result!, + items: result!.items.map((r) => ({ + ...r, + scriptureReferences: this.scriptureRefsService.parseList( + r.scriptureReferences, + ), + })), + }; } protected hydrate() { return (query: Query) => query .apply(matchProps()) - .subQuery('node', this.scriptureRefs.list()) + .subQuery('node', this.scriptureRefsRepository.list()) .return<{ dto: DbTypeOf }>( merge('props', { scriptureReferences: 'scriptureReferences', diff --git a/src/components/film/film.service.ts b/src/components/film/film.service.ts index b450e8e014..26e5d8f784 100644 --- a/src/components/film/film.service.ts +++ b/src/components/film/film.service.ts @@ -1,18 +1,15 @@ import { Injectable } from '@nestjs/common'; import { - DuplicateException, ID, ObjectView, - SecuredList, ServerException, Session, -} from '../../common'; -import { DbTypeOf, HandleIdLookup, ILogger, Logger } from '../../core'; -import { ifDiff } from '../../core/database/changes'; -import { mapListResults } from '../../core/database/results'; + UnsecuredDto, +} from '~/common'; +import { HandleIdLookup } from '~/core'; +import { ifDiff } from '~/core/database/changes'; import { Privileges } from '../authorization'; import { isScriptureEqual } from '../scripture'; -import { ScriptureReferenceService } from '../scripture/scripture-reference.service'; import { CreateFilm, Film, @@ -25,111 +22,63 @@ import { FilmRepository } from './film.repository'; @Injectable() export class FilmService { constructor( - @Logger('film:service') private readonly logger: ILogger, - private readonly scriptureRefs: ScriptureReferenceService, private readonly privileges: Privileges, private readonly repo: FilmRepository, ) {} async create(input: CreateFilm, session: Session): Promise { - this.privileges.for(session, Film).verifyCan('create'); - - if (!(await this.repo.isUnique(input.name))) { - throw new DuplicateException( - 'film.name', - 'Film with this name already exists', - ); - } - - try { - const result = await this.repo.create(input); - - if (!result) { - throw new ServerException('failed to create a film'); - } - - await this.scriptureRefs.create( - result.id, - input.scriptureReferences, - session, - ); - - this.logger.debug(`flim created`, { id: result.id }); - return await this.readOne(result.id, session); - } catch (exception) { - this.logger.error(`Could not create film`, { - exception, - userId: session.userId, - }); - throw new ServerException('Could not create film', exception); - } + const dto = await this.repo.create(input, session); + this.privileges.for(session, Film, dto).verifyCan('create'); + return this.secure(dto, session); } @HandleIdLookup(Film) async readOne(id: ID, session: Session, _view?: ObjectView): Promise { - this.logger.debug(`Read film`, { - id, - userId: session.userId, - }); - const result = await this.repo.readOne(id); - return await this.secure(result, session); + return this.secure(result, session); } async readMany(ids: readonly ID[], session: Session) { const films = await this.repo.readMany(ids); - return await Promise.all(films.map((dto) => this.secure(dto, session))); + return films.map((dto) => this.secure(dto, session)); } - private async secure(dto: DbTypeOf, session: Session): Promise { - return this.privileges.for(session, Film).secure({ - ...dto, - scriptureReferences: this.scriptureRefs.parseList( - dto.scriptureReferences, - ), - }); + private secure(dto: UnsecuredDto, session: Session): Film { + return this.privileges.for(session, Film).secure(dto); } async update(input: UpdateFilm, session: Session): Promise { - const film = await this.readOne(input.id, session); + const film = await this.repo.readOne(input.id); const changes = { ...this.repo.getActualChanges(film, input), scriptureReferences: ifDiff(isScriptureEqual)( input.scriptureReferences, - film.scriptureReferences.value, + film.scriptureReferences, ), }; this.privileges.for(session, Film, film).verifyChanges(changes); - const { scriptureReferences, ...simpleChanges } = changes; - - await this.scriptureRefs.update(input.id, scriptureReferences); - await this.repo.update(film, simpleChanges); - - return await this.readOne(input.id, session); + const updated = await this.repo.update({ id: input.id, ...changes }); + return this.secure(updated, session); } async delete(id: ID, session: Session): Promise { - const film = await this.readOne(id, session); + const film = await this.repo.readOne(id); this.privileges.for(session, Film, film).verifyCan('delete'); try { await this.repo.deleteNode(film); } catch (exception) { - this.logger.error('Failed to delete', { id, exception }); throw new ServerException('Failed to delete', exception); } - - this.logger.debug(`deleted film with id`, { id }); } async list(input: FilmListInput, session: Session): Promise { - if (this.privileges.for(session, Film).can('read')) { - const results = await this.repo.list(input, session); - return await mapListResults(results, (dto) => this.secure(dto, session)); - } else { - return SecuredList.Redacted; - } + const results = await this.repo.list(input); + return { + ...results, + items: results.items.map((dto) => this.secure(dto, session)), + }; } } From 1b559a85372b2a70390dfde8178a384c66903c5b Mon Sep 17 00:00:00 2001 From: Will Harris <39035380+willdch@users.noreply.github.com> Date: Thu, 9 May 2024 15:47:21 -0700 Subject: [PATCH 09/27] [EdgeDB] EthnoArt queries (#3205) --- .../ethno-art/ethno-art.edgedb.repository.ts | 56 +++++++++++ src/components/ethno-art/ethno-art.module.ts | 4 +- .../ethno-art/ethno-art.repository.ts | 96 +++++++++++++++---- src/components/ethno-art/ethno-art.service.ts | 94 +++++------------- 4 files changed, 162 insertions(+), 88 deletions(-) create mode 100644 src/components/ethno-art/ethno-art.edgedb.repository.ts diff --git a/src/components/ethno-art/ethno-art.edgedb.repository.ts b/src/components/ethno-art/ethno-art.edgedb.repository.ts new file mode 100644 index 0000000000..605aceaf2f --- /dev/null +++ b/src/components/ethno-art/ethno-art.edgedb.repository.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { PublicOf, UnsecuredDto } from '~/common'; +import { e, RepoFor } from '~/core/edgedb'; +import * as scripture from '../scripture/edgedb.utils'; +import { CreateEthnoArt, EthnoArt, UpdateEthnoArt } from './dto'; +import { EthnoArtRepository } from './ethno-art.repository'; + +@Injectable() +export class EthnoArtEdgeDBRepository + extends RepoFor(EthnoArt, { + hydrate: (ethnoArt) => ({ + ...ethnoArt['*'], + scriptureReferences: scripture.hydrate(ethnoArt.scripture), + }), + omit: ['create', 'update'], + }) + implements PublicOf +{ + async create(input: CreateEthnoArt): Promise> { + const query = e.params( + { name: e.str, scripture: e.optional(scripture.type) }, + ($) => { + const created = e.insert(this.resource.db, { + name: $.name, + scripture: scripture.insert($.scripture), + }); + return e.select(created, this.hydrate); + }, + ); + return await this.db.run(query, { + name: input.name, + scripture: scripture.valueOptional(input.scriptureReferences), + }); + } + + async update({ + id, + ...changes + }: UpdateEthnoArt): Promise> { + const query = e.params({ scripture: e.optional(scripture.type) }, ($) => { + const ethnoArt = e.cast(e.EthnoArt, e.uuid(id)); + const updated = e.update(ethnoArt, () => ({ + set: { + ...(changes.name ? { name: changes.name } : {}), + ...(changes.scriptureReferences !== undefined + ? { scripture: scripture.insert($.scripture) } + : {}), + }, + })); + return e.select(updated, this.hydrate); + }); + return await this.db.run(query, { + scripture: scripture.valueOptional(changes.scriptureReferences), + }); + } +} diff --git a/src/components/ethno-art/ethno-art.module.ts b/src/components/ethno-art/ethno-art.module.ts index 695a93a4db..b66fbe063d 100644 --- a/src/components/ethno-art/ethno-art.module.ts +++ b/src/components/ethno-art/ethno-art.module.ts @@ -1,6 +1,8 @@ import { forwardRef, Module } from '@nestjs/common'; +import { splitDb } from '~/core'; import { AuthorizationModule } from '../authorization/authorization.module'; import { ScriptureModule } from '../scripture/scripture.module'; +import { EthnoArtEdgeDBRepository } from './ethno-art.edgedb.repository'; import { EthnoArtLoader } from './ethno-art.loader'; import { EthnoArtRepository } from './ethno-art.repository'; import { EthnoArtResolver } from './ethno-art.resolver'; @@ -11,7 +13,7 @@ import { EthnoArtService } from './ethno-art.service'; providers: [ EthnoArtLoader, EthnoArtResolver, - EthnoArtRepository, + splitDb(EthnoArtRepository, EthnoArtEdgeDBRepository), EthnoArtService, ], exports: [EthnoArtService], diff --git a/src/components/ethno-art/ethno-art.repository.ts b/src/components/ethno-art/ethno-art.repository.ts index 7989e13931..4da67b0850 100644 --- a/src/components/ethno-art/ethno-art.repository.ts +++ b/src/components/ethno-art/ethno-art.repository.ts @@ -1,16 +1,25 @@ import { Injectable } from '@nestjs/common'; import { Query } from 'cypher-query-builder'; -import { ChangesOf } from '~/core/database/changes'; -import { ID } from '../../common'; -import { DbTypeOf, DtoRepository } from '../../core'; +import { + DuplicateException, + ID, + PaginatedListType, + ServerException, + Session, + UnsecuredDto, +} from '~/common'; +import { DbTypeOf, DtoRepository } from '~/core'; import { createNode, matchProps, merge, paginate, sorting, -} from '../../core/database/query'; -import { ScriptureReferenceRepository } from '../scripture'; +} from '~/core/database/query'; +import { + ScriptureReferenceRepository, + ScriptureReferenceService, +} from '../scripture'; import { CreateEthnoArt, EthnoArt, @@ -20,46 +29,95 @@ import { @Injectable() export class EthnoArtRepository extends DtoRepository(EthnoArt) { - constructor(private readonly scriptureRefs: ScriptureReferenceRepository) { + constructor( + private readonly scriptureRefsRepository: ScriptureReferenceRepository, + private readonly scriptureRefsService: ScriptureReferenceService, + ) { super(); } - async create(input: CreateEthnoArt) { + + async create(input: CreateEthnoArt, session: Session) { + if (!(await this.isUnique(input.name))) { + throw new DuplicateException( + 'ethnoArt.name', + 'Ethno art with this name already exists', + ); + } + const initialProps = { name: input.name, canDelete: true, }; - return await this.db + const result = await this.db .query() .apply(await createNode(EthnoArt, { initialProps })) .return<{ id: ID }>('node.id as id') .first(); + + if (!result) { + throw new ServerException('Failed to create ethno art'); + } + + await this.scriptureRefsService.create( + result.id, + input.scriptureReferences, + session, + ); + + return await this.readOne(result.id); } - async update( - existing: EthnoArt, - simpleChanges: Omit< - ChangesOf, - 'scriptureReferences' - >, - ) { - await this.updateProperties(existing, simpleChanges); + async update(input: UpdateEthnoArt) { + const { id, name, scriptureReferences } = input; + await this.updateProperties({ id }, { name }); + if (scriptureReferences !== undefined) { + await this.scriptureRefsService.update(id, scriptureReferences); + } + return await this.readOne(input.id); + } + + async readOne(id: ID) { + return (await super.readOne(id)) as UnsecuredDto; } - async list(input: EthnoArtListInput) { + async readMany( + ids: readonly ID[], + ): Promise>> { + const items = await super.readMany(ids); + return items.map((r) => ({ + ...r, + scriptureReferences: this.scriptureRefsService.parseList( + r.scriptureReferences, + ), + })); + } + + async list({ + filter, + ...input + }: EthnoArtListInput): Promise>> { const result = await this.db .query() .matchNode('node', 'EthnoArt') .apply(sorting(EthnoArt, input)) .apply(paginate(input, this.hydrate())) .first(); - return result!; // result from paginate() will always have 1 row. + return { + ...result!, + items: result!.items.map((r) => ({ + ...r, + scriptureReferences: this.scriptureRefsService.parseList( + r.scriptureReferences, + ), + })), + }; } protected hydrate() { return (query: Query) => query .apply(matchProps()) - .subQuery('node', this.scriptureRefs.list()) + .subQuery('node', this.scriptureRefsRepository.list()) .return<{ dto: DbTypeOf }>( merge('props', { scriptureReferences: 'scriptureReferences', diff --git a/src/components/ethno-art/ethno-art.service.ts b/src/components/ethno-art/ethno-art.service.ts index 257249b170..a9c3c19dcc 100644 --- a/src/components/ethno-art/ethno-art.service.ts +++ b/src/components/ethno-art/ethno-art.service.ts @@ -1,20 +1,20 @@ import { Injectable } from '@nestjs/common'; import { - DuplicateException, ID, ObjectView, ServerException, Session, -} from '../../common'; -import { DbTypeOf, HandleIdLookup, ILogger, Logger } from '../../core'; -import { ifDiff } from '../../core/database/changes'; -import { mapListResults } from '../../core/database/results'; + UnsecuredDto, +} from '~/common'; +import { HandleIdLookup } from '~/core'; +import { ifDiff } from '~/core/database/changes'; import { Privileges } from '../authorization'; -import { isScriptureEqual, ScriptureReferenceService } from '../scripture'; +import { isScriptureEqual } from '../scripture'; import { CreateEthnoArt, EthnoArt, EthnoArtListInput, + EthnoArtListOutput, UpdateEthnoArt, } from './dto'; import { EthnoArtRepository } from './ethno-art.repository'; @@ -22,42 +22,14 @@ import { EthnoArtRepository } from './ethno-art.repository'; @Injectable() export class EthnoArtService { constructor( - @Logger('ethno-art:service') private readonly logger: ILogger, - private readonly scriptureRefs: ScriptureReferenceService, private readonly privileges: Privileges, private readonly repo: EthnoArtRepository, ) {} async create(input: CreateEthnoArt, session: Session): Promise { - this.privileges.for(session, EthnoArt).verifyCan('create'); - if (!(await this.repo.isUnique(input.name))) { - throw new DuplicateException( - 'ethnoArt.name', - 'Ethno art with this name already exists', - ); - } - - try { - const result = await this.repo.create(input); - - if (!result) { - throw new ServerException('Failed to create ethno art'); - } - - await this.scriptureRefs.create( - result.id, - input.scriptureReferences, - session, - ); - - this.logger.debug(`ethno art created`, { id: result.id }); - return await this.readOne(result.id, session); - } catch (exception) { - this.logger.error('Could not create ethno art', { - exception, - }); - throw new ServerException('Could not create ethno art', exception); - } + const dto = await this.repo.create(input, session); + this.privileges.for(session, EthnoArt, dto).verifyCan('create'); + return this.secure(dto, session); } @HandleIdLookup(EthnoArt) @@ -67,67 +39,53 @@ export class EthnoArtService { _view?: ObjectView, ): Promise { const result = await this.repo.readOne(id); - return await this.secure(result, session); + return this.secure(result, session); } async readMany(ids: readonly ID[], session: Session) { const ethnoArt = await this.repo.readMany(ids); - return await Promise.all(ethnoArt.map((dto) => this.secure(dto, session))); + return ethnoArt.map((dto) => this.secure(dto, session)); } - private async secure( - dto: DbTypeOf, - session: Session, - ): Promise { - return this.privileges.for(session, EthnoArt).secure({ - ...dto, - scriptureReferences: this.scriptureRefs.parseList( - dto.scriptureReferences, - ), - }); + private secure(dto: UnsecuredDto, session: Session): EthnoArt { + return this.privileges.for(session, EthnoArt).secure(dto); } async update(input: UpdateEthnoArt, session: Session): Promise { - const ethnoArt = await this.readOne(input.id, session); - + const ethnoArt = await this.repo.readOne(input.id); const changes = { ...this.repo.getActualChanges(ethnoArt, input), scriptureReferences: ifDiff(isScriptureEqual)( input.scriptureReferences, - ethnoArt.scriptureReferences.value, + ethnoArt.scriptureReferences, ), }; - this.privileges.for(session, EthnoArt, ethnoArt).verifyChanges(changes); - const { scriptureReferences, ...simpleChanges } = changes; - - await this.scriptureRefs.update(input.id, scriptureReferences); - - await this.repo.update(ethnoArt, simpleChanges); - - return await this.readOne(input.id, session); + const updated = await this.repo.update({ id: input.id, ...changes }); + return this.secure(updated, session); } async delete(id: ID, session: Session): Promise { - const ethnoArt = await this.readOne(id, session); + const ethnoArt = await this.repo.readOne(id); this.privileges.for(session, EthnoArt, ethnoArt).verifyCan('delete'); try { await this.repo.deleteNode(ethnoArt); } catch (exception) { - this.logger.error('Failed to delete', { id, exception }); throw new ServerException('Failed to delete', exception); } - - this.logger.debug(`deleted ethnoArt with id`, { id }); } - async list(input: EthnoArtListInput, session: Session) { - // -- don't need a check for canList. all roles are allowed to see at least one prop, - // and this isn't a sensitive component. + async list( + input: EthnoArtListInput, + session: Session, + ): Promise { const results = await this.repo.list(input); - return await mapListResults(results, (dto) => this.secure(dto, session)); + return { + ...results, + items: results.items.map((dto) => this.secure(dto, session)), + }; } } From abfb1d0874ec0b10eb33da3ccb867a6cf44eceef Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Thu, 9 May 2024 17:31:17 -0500 Subject: [PATCH 10/27] Use FinancialApprovers instead of all Controllers for project workflow --- src/components/project/project.rules.ts | 34 +++++++++---------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/components/project/project.rules.ts b/src/components/project/project.rules.ts index 560e9a39c7..8f5e598bfc 100644 --- a/src/components/project/project.rules.ts +++ b/src/components/project/project.rules.ts @@ -26,6 +26,7 @@ import { ProjectStepTransition, TransitionType, } from './dto'; +import { FinancialApproverRepository } from './financial-approver'; import { ProjectService } from './project.service'; type EmailAddress = string; @@ -73,6 +74,7 @@ export class ProjectRules { @Inject(forwardRef(() => AuthenticationService)) private readonly auth: AuthenticationService & {}, private readonly configService: ConfigService, + private readonly financialApproverRepo: FinancialApproverRepository, // eslint-disable-next-line @seedcompany/no-unused-vars @Logger('project:rules') private readonly logger: ILogger, ) {} @@ -85,6 +87,10 @@ export class ProjectRules { ): Promise { const mostRecentPreviousStep = (steps: ProjectStep[]) => this.getMostRecentPreviousStep(id, steps, changeset); + const financialApprovers = async () => + (await this.financialApproverRepo.read(projectType)).map( + ({ user }) => user.id, + ); const isMultiplication = projectType === ProjectType.MultiplicationTranslation; @@ -405,7 +411,7 @@ export class ProjectRules { type: TransitionType.Approve, label: 'Confirm Project 🎉', notifiers: async () => [ - ...(await this.getRoleEmails(Role.Controller)), + ...(await financialApprovers()), 'project_approval@tsco.org', 'projects@tsco.org', ], @@ -413,7 +419,7 @@ export class ProjectRules { ], getNotifiers: async () => [ ...(await this.getProjectTeamUserIds(id)), - ...(await this.getRoleEmails(Role.Controller)), + ...(await financialApprovers()), ], }; case ProjectStep.OnHoldFinanceConfirmation: @@ -425,7 +431,7 @@ export class ProjectRules { type: TransitionType.Approve, label: 'Confirm Project 🎉', notifiers: async () => [ - ...(await this.getRoleEmails(Role.Controller)), + ...(await financialApprovers()), 'project_approval@tsco.org', 'projects@tsco.org', ], @@ -443,7 +449,7 @@ export class ProjectRules { ], getNotifiers: async () => [ ...(await this.getProjectTeamUserIds(id)), - ...(await this.getRoleEmails(Role.Controller)), + ...(await financialApprovers()), ], }; case ProjectStep.Active: @@ -500,7 +506,7 @@ export class ProjectRules { ], getNotifiers: async () => [ ...(await this.getProjectTeamUserIds(id)), - ...(await this.getRoleEmails(Role.Controller)), + ...(await financialApprovers()), 'project_extension@tsco.org', 'project_revision@tsco.org', ], @@ -598,7 +604,7 @@ export class ProjectRules { ], getNotifiers: async () => [ ...(await this.getProjectTeamUserIds(id)), - ...(await this.getRoleEmails(Role.Controller)), + ...(await financialApprovers()), 'project_extension@tsco.org', 'project_revision@tsco.org', ], @@ -1046,22 +1052,6 @@ export class ProjectRules { return users?.ids ?? []; } - private async getRoleEmails(role: Role): Promise { - const emails = await this.db - .query() - .match([ - node('email', 'EmailAddress'), - relation('in', '', 'email', ACTIVE), - node('user', 'User'), - relation('out', '', 'roles', ACTIVE), - node('role', 'Property', { value: role }), - ]) - .return<{ emails: string[] }>('collect(email.value) as emails') - .first(); - - return emails?.emails ?? []; - } - /** Of the given steps which one was the most recent previous step */ private async getMostRecentPreviousStep( id: ID, From 32e804881f3a4d25a19c7e9695cc784f1aad800b Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Fri, 10 May 2024 14:30:39 -0500 Subject: [PATCH 11/27] Add the project type to step changed notification --- src/components/project/project.rules.ts | 2 +- src/core/email/templates/project-step-changed.template.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/project/project.rules.ts b/src/components/project/project.rules.ts index 8f5e598bfc..73a8e7163f 100644 --- a/src/components/project/project.rules.ts +++ b/src/components/project/project.rules.ts @@ -55,7 +55,7 @@ export interface EmailNotification { 'email' | 'displayFirstName' | 'displayLastName' | 'timezone' >; changedBy: Pick; - project: Pick; + project: Pick; previousStep?: ProjectStep; } diff --git a/src/core/email/templates/project-step-changed.template.tsx b/src/core/email/templates/project-step-changed.template.tsx index 6d1f3b5c79..a673834708 100644 --- a/src/core/email/templates/project-step-changed.template.tsx +++ b/src/core/email/templates/project-step-changed.template.tsx @@ -63,6 +63,8 @@ export function ProjectStepChanged({ value={project.modifiedAt} timezone={recipient.timezone.value} /> +
+ Project Type: {project.type}