diff --git a/dbschema/migrations/00009-m1gm5bm.edgeql b/dbschema/migrations/00009-m1gm5bm.edgeql new file mode 100644 index 0000000000..b2b6ba163d --- /dev/null +++ b/dbschema/migrations/00009-m1gm5bm.edgeql @@ -0,0 +1,73 @@ +CREATE MIGRATION m1urrlyf3tq3uotjkdxpwmlf5z3qqfrzlepjptx6l5cxkw45fy526a + ONTO m1gq2hsptfudyqzcqhaz3o5ikdckynzcdegdixqtrdtnisldpyqv6a +{ + CREATE TYPE Project::WorkflowEvent { + CREATE REQUIRED LINK project: default::Project { + SET readonly := true; + }; + CREATE REQUIRED PROPERTY at: std::datetime { + SET default := (std::datetime_of_statement()); + SET readonly := true; + }; + CREATE REQUIRED PROPERTY to: Project::Step { + SET readonly := true; + }; + CREATE REQUIRED LINK who: default::Actor { + SET default := (GLOBAL default::currentActor); + SET readonly := true; + }; + CREATE PROPERTY notes: default::RichText { + SET readonly := true; + }; + CREATE PROPERTY transitionKey: std::uuid { + SET readonly := true; + }; + }; + ALTER TYPE default::Project { + CREATE LINK workflowEvents := (.; @@ -40,27 +40,3 @@ export const ProjectStep = makeEnum({ description: SecuredEnum.descriptionFor('a project step'), }) export class SecuredProjectStep extends SecuredEnum(ProjectStep) {} - -export type TransitionType = EnumType; -export const TransitionType = makeEnum({ - name: 'TransitionType', - values: ['Neutral', 'Approve', 'Reject'], -}); - -@ObjectType() -export abstract class ProjectStepTransition { - @Field(() => ProjectStep) - to: ProjectStep; - - @Field() - label: string; - - @Field(() => TransitionType) - type: TransitionType; - - @Field(() => Boolean, { defaultValue: false }) - disabled?: boolean; - - @Field(() => String, { nullable: true }) - disabledReason?: string; -} diff --git a/src/components/project/dto/project.dto.ts b/src/components/project/dto/project.dto.ts index 31c9ff4d20..8303067f75 100644 --- a/src/components/project/dto/project.dto.ts +++ b/src/components/project/dto/project.dto.ts @@ -6,6 +6,7 @@ import { DateTime } from 'luxon'; import { keys as keysOf } from 'ts-transformer-keys'; import { MergeExclusive } from 'type-fest'; import { + Calculated, DateInterval, DateTimeField, DbLabel, @@ -117,11 +118,13 @@ class Project extends Interfaces { }) @DbLabel('ProjectStep') @DbSort(sortingForEnumIndex(ProjectStep)) + @Calculated() readonly step: SecuredProjectStep; @Field(() => ProjectStatus) @DbLabel('ProjectStatus') @DbSort(sortingForEnumIndex(ProjectStatus)) + @Calculated() readonly status: ProjectStatus; readonly primaryLocation: Secured | null>; @@ -144,6 +147,7 @@ class Project extends Interfaces { readonly initialMouEnd: SecuredDateNullable; @Field() + @Calculated() readonly stepChangedAt: SecuredDateTime; @Field() diff --git a/src/components/project/dto/update-project.dto.ts b/src/components/project/dto/update-project.dto.ts index f0282d7879..3be4d7cdfe 100644 --- a/src/components/project/dto/update-project.dto.ts +++ b/src/components/project/dto/update-project.dto.ts @@ -63,7 +63,10 @@ export abstract class UpdateProject { @DateField({ nullable: true }) readonly estimatedSubmission?: CalendarDate | null; - @Field(() => ProjectStep, { nullable: true }) + @Field(() => ProjectStep, { + nullable: true, + deprecationReason: 'Use `transitionProject` mutation instead', + }) readonly step?: ProjectStep; @SensitivityField({ diff --git a/src/components/project/project-member/project-member.edgedb.repository.ts b/src/components/project/project-member/project-member.edgedb.repository.ts index 2aeda691fb..43341e3245 100644 --- a/src/components/project/project-member/project-member.edgedb.repository.ts +++ b/src/components/project/project-member/project-member.edgedb.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { isIdLike, PublicOf } from '~/common'; +import { ID, isIdLike, PublicOf, Role } from '~/common'; import { e, RepoFor, ScopeOf } from '~/core/edgedb'; import { CreateProjectMember, @@ -33,6 +33,23 @@ export class ProjectMemberEdgeDBRepository return await this.db.run(query); } + async listAsNotifiers(projectId: ID, roles?: Role[]) { + const project = e.cast(e.Project, e.uuid(projectId)); + const members = e.select(project.members, (member) => ({ + filter: roles + ? e.op( + 'exists', + e.op(member.roles, 'intersect', e.cast(e.Role, e.set(...roles))), + ) + : undefined, + })); + const query = e.select(members.user, () => ({ + id: true, + email: true, + })); + return await this.db.run(query); + } + protected listFilters( member: ScopeOf, { filter: input }: ProjectMemberListInput, diff --git a/src/components/project/project-member/project-member.repository.ts b/src/components/project/project-member/project-member.repository.ts index 0a863b6c2e..12bf8f7043 100644 --- a/src/components/project/project-member/project-member.repository.ts +++ b/src/components/project/project-member/project-member.repository.ts @@ -6,6 +6,7 @@ import { ID, isIdLike, NotFoundException, + Role, ServerException, Session, UnsecuredDto, @@ -177,4 +178,41 @@ export class ProjectMemberRepository extends DtoRepository< .first(); return result!; // result from paginate() will always have 1 row. } + + async listAsNotifiers(projectId: ID, roles?: Role[]) { + return await this.db + .query() + .match([ + node('', 'Project', { id: projectId }), + relation('out', '', 'member', ACTIVE), + node('node', 'ProjectMember'), + relation('out', '', 'user', ACTIVE), + node('user', 'User'), + ]) + .apply((q) => + roles + ? q + .match([ + node('node'), + relation('out', '', 'roles', ACTIVE), + node('role', 'Property'), + ]) + .raw( + `WHERE size(apoc.coll.intersection(role.value, $filteredRoles)) > 0`, + { filteredRoles: roles }, + ) + : q, + ) + .with('user') + .optionalMatch([ + node('user'), + relation('out', '', 'email', ACTIVE), + node('email', 'EmailAddress'), + ]) + .return<{ id: ID; email: string | null }>([ + 'user.id as id', + 'email.value as email', + ]) + .run(); + } } diff --git a/src/components/project/project-step.resolver.ts b/src/components/project/project-step.resolver.ts deleted file mode 100644 index d6df5a2bc1..0000000000 --- a/src/components/project/project-step.resolver.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; -import { Loader, LoaderOf } from '@seedcompany/data-loader'; -import { stripIndent } from 'common-tags'; -import { AnonSession, ID, Session, viewOfChangeset } from '~/common'; -import { ProjectStepTransition, SecuredProjectStep } from './dto'; -import { ProjectLoader } from './project.loader'; -import { ProjectRules } from './project.rules'; - -@Resolver(SecuredProjectStep) -export class ProjectStepResolver { - constructor(private readonly projectRules: ProjectRules) {} - - @ResolveField(() => [ProjectStepTransition], { - description: 'The available steps a project can be transitioned to.', - }) - async transitions( - @Parent() step: SecuredProjectStep & { parentId: ID; changeset?: ID }, - @Loader(ProjectLoader) projects: LoaderOf, - @AnonSession() session: Session, - ): Promise { - if (!step.canRead || !step.canEdit || !step.value) { - return []; - } - const project = await projects.load({ - id: step.parentId, - view: viewOfChangeset(step.changeset), - }); - return await this.projectRules.getAvailableTransitions( - step.parentId, - session, - project.type, - undefined, - step.changeset, - ); - } - - @ResolveField(() => Boolean, { - description: stripIndent` - Is the current user allowed to bypass transitions entirely - and change the step to any other step? - `, - }) - async canBypassTransitions( - @AnonSession() session: Session, - ): Promise { - return await this.projectRules.canBypassWorkflow(session); - } -} diff --git a/src/components/project/project.edgedb.repository.ts b/src/components/project/project.edgedb.repository.ts index 88bdbf9067..71997f0c95 100644 --- a/src/components/project/project.edgedb.repository.ts +++ b/src/components/project/project.edgedb.repository.ts @@ -28,6 +28,7 @@ const hydrate = e.shape(e.Project, (project) => ({ marketingLocation: true, marketingRegionOverride: true, fieldRegion: true, + stepChangedAt: e.op(project.latestWorkflowEvent.at, '??', project.createdAt), owningOrganization: e.cast(e.uuid, null), // Not implemented going forward presetInventory: e.bool(false), // Not implemented going forward })); diff --git a/src/components/project/project.module.ts b/src/components/project/project.module.ts index 9934fe4dc1..851ba374c9 100644 --- a/src/components/project/project.module.ts +++ b/src/components/project/project.module.ts @@ -16,7 +16,6 @@ import * as handlers from './handlers'; import { InternshipProjectResolver } from './internship-project.resolver'; import { RenameTranslationToMomentumMigration } from './migrations/rename-translation-to-momentum.migration'; import { ProjectMemberModule } from './project-member/project-member.module'; -import { ProjectStepResolver } from './project-step.resolver'; import { ConcreteRepos, ProjectEdgeDBRepository, @@ -28,6 +27,7 @@ import { ProjectRules } from './project.rules'; import { ProjectService } from './project.service'; import { TranslationProjectResolver } from './translation-project.resolver'; import { ProjectUserConnectionResolver } from './user-connection.resolver'; +import { ProjectWorkflowModule } from './workflow/project-workflow.module'; @Module({ imports: [ @@ -43,6 +43,7 @@ import { ProjectUserConnectionResolver } from './user-connection.resolver'; PartnerModule, forwardRef(() => OrganizationModule), FinancialApproverModule, + ProjectWorkflowModule, ], providers: [ ProjectResolver, @@ -51,7 +52,6 @@ import { ProjectUserConnectionResolver } from './user-connection.resolver'; ProjectEngagementConnectionResolver, ProjectUserConnectionResolver, ProjectService, - ProjectStepResolver, ProjectRules, splitDb(ProjectRepository, ProjectEdgeDBRepository), ...Object.values(ConcreteRepos), diff --git a/src/components/project/project.rules.ts b/src/components/project/project.rules.ts index d3fdba6979..d4dabd941d 100644 --- a/src/components/project/project.rules.ts +++ b/src/components/project/project.rules.ts @@ -17,6 +17,7 @@ import { import { ConfigService, ILogger, Logger } from '~/core'; import { DatabaseService } from '~/core/database'; import { ACTIVE, INACTIVE, merge } from '~/core/database/query'; +import { ProjectStepChangedProps as EmailNotification } from '~/core/email/templates'; import { AuthenticationService } from '../authentication'; import { withoutScope } from '../authorization/dto'; import { EngagementService } from '../engagement'; @@ -24,16 +25,12 @@ import { EngagementStatus } from '../engagement/dto'; import { OrganizationService } from '../organization'; import { Organization } from '../organization/dto'; import { UserService } from '../user'; -import { User } from '../user/dto'; -import { - Project, - ProjectStep, - ProjectStepTransition, - ProjectType, - TransitionType, -} from './dto'; +import { Project, ProjectStep, ProjectType } from './dto'; import { FinancialApproverRepository } from './financial-approver'; import { ProjectService } from './project.service'; +import { ProjectWorkflowTransition, TransitionType } from './workflow/dto'; + +type ProjectStepTransition = Omit; type EmailAddress = string; @@ -55,17 +52,6 @@ interface StepRule { getNotifiers?: Notifiers; } -export interface EmailNotification { - recipient: Pick< - User, - 'email' | 'displayFirstName' | 'displayLastName' | 'timezone' - >; - changedBy: Pick; - project: Pick; - previousStep?: ProjectStep; - primaryPartnerName?: string | undefined; -} - const rolesThatCanBypassWorkflow: Role[] = [Role.Administrator]; @Injectable() @@ -916,7 +902,10 @@ export class ProjectRules { return []; } - return transitions; + return transitions.map((t) => ({ + id: t.to, // TODO stubbed + ...t, + })); } async canBypassWorkflow(session: Session) { diff --git a/src/components/project/project.service.ts b/src/components/project/project.service.ts index 8a59455522..0ce655bf99 100644 --- a/src/components/project/project.service.ts +++ b/src/components/project/project.service.ts @@ -68,7 +68,7 @@ import { SecuredProjectMemberList, } from './project-member/dto'; import { ProjectRepository } from './project.repository'; -import { ProjectRules } from './project.rules'; +import { ProjectWorkflowService } from './workflow/project-workflow.service'; @Injectable() export class ProjectService { @@ -86,7 +86,7 @@ export class ProjectService { private readonly config: ConfigService, private readonly privileges: Privileges, private readonly eventBus: IEventBus, - private readonly projectRules: ProjectRules, + private readonly workflow: ProjectWorkflowService, private readonly repo: ProjectRepository, private readonly projectChangeRequests: ProjectChangeRequestService, @Logger('project:service') private readonly logger: ILogger, @@ -242,7 +242,6 @@ export class ProjectService { input: UpdateProject, session: Session, changeset?: ID, - stepValidation = true, ): Promise> { const currentProject = await this.readOneUnsecured( input.id, @@ -255,17 +254,19 @@ export class ProjectService { 'project.sensitivity', ); - const changes = this.repo.getActualChanges(currentProject, input); + const { step: changedStep, ...changes } = this.repo.getActualChanges( + currentProject, + input, + ); this.privileges .for(session, resolveProjectType(currentProject), currentProject) .verifyChanges(changes, { pathPrefix: 'project' }); - if (changes.step && stepValidation) { - await this.projectRules.verifyStepChange( - currentProject, + if (changedStep) { + await this.workflow.executeTransitionLegacy( + this.secure(currentProject, session), + changedStep, session, - changes.step, - changeset, ); } diff --git a/src/components/project/workflow/dto/execute-progress-report-transition.input.ts b/src/components/project/workflow/dto/execute-progress-report-transition.input.ts new file mode 100644 index 0000000000..6e44c441da --- /dev/null +++ b/src/components/project/workflow/dto/execute-progress-report-transition.input.ts @@ -0,0 +1,36 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { stripIndent } from 'common-tags'; +import { ID, IdField, RichTextDocument, RichTextField } from '~/common'; +import { ProjectStep } from '../../dto'; + +@InputType() +export abstract class ExecuteProjectTransitionInput { + @IdField({ + description: 'The project ID to transition', + }) + readonly project: ID; + + @IdField({ + description: stripIndent` + The transition \`key\` to execute. + This is required unless specifying bypassing the workflow with a \`step\` input. + `, + nullable: true, + }) + readonly transition?: ID; + + @Field(() => ProjectStep, { + description: stripIndent` + Bypass the workflow, and go straight to this step. + \`transition\` is not required and ignored when using this. + `, + nullable: true, + }) + readonly step?: ProjectStep; + + @RichTextField({ + description: 'Any additional user notes related to this transition', + nullable: true, + }) + readonly notes?: RichTextDocument; +} diff --git a/src/components/project/workflow/dto/index.ts b/src/components/project/workflow/dto/index.ts new file mode 100644 index 0000000000..18d2f5a323 --- /dev/null +++ b/src/components/project/workflow/dto/index.ts @@ -0,0 +1,3 @@ +export * from './execute-progress-report-transition.input'; +export * from './workflow-event.dto'; +export * from './workflow-transition.dto'; diff --git a/src/components/project/workflow/dto/workflow-event.dto.ts b/src/components/project/workflow/dto/workflow-event.dto.ts new file mode 100644 index 0000000000..3294446553 --- /dev/null +++ b/src/components/project/workflow/dto/workflow-event.dto.ts @@ -0,0 +1,58 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { DateTime } from 'luxon'; +import { keys as keysOf } from 'ts-transformer-keys'; +import { + DateTimeField, + ID, + IdField, + Secured, + SecuredProps, + SecuredRichTextNullable, + SetUnsecuredType, +} from '~/common'; +import { e } from '~/core/edgedb'; +import { LinkTo, RegisterResource } from '~/core/resources'; +import { ProjectStep } from '../../dto'; +import type { InternalTransition } from '../transitions'; +import { ProjectWorkflowTransition as PublicTransition } from './workflow-transition.dto'; + +@RegisterResource({ db: e.Project.WorkflowEvent }) +@ObjectType() +export abstract class ProjectWorkflowEvent { + static readonly Props = keysOf(); + static readonly SecuredProps = keysOf>(); + static readonly BaseNodeProps = ['id', 'createdAt', 'step', 'transition']; + + @IdField() + readonly id: ID; + + readonly who: Secured>; + + @DateTimeField() + readonly at: DateTime; + + @Field(() => PublicTransition, { + nullable: true, + description: 'The transition taken, null if workflow was bypassed', + }) + readonly transition: + | (InternalTransition & SetUnsecuredType) + | null; + + // TODO maybe add `from`? + + @Field(() => ProjectStep) + readonly to: ProjectStep; + + @Field() + readonly notes: SecuredRichTextNullable; +} + +declare module '~/core/resources/map' { + interface ResourceMap { + ProjectWorkflowEvent: typeof ProjectWorkflowEvent; + } + interface ResourceDBMap { + ProjectWorkflowEvent: typeof e.Project.WorkflowEvent; + } +} diff --git a/src/components/project/workflow/dto/workflow-transition.dto.ts b/src/components/project/workflow/dto/workflow-transition.dto.ts new file mode 100644 index 0000000000..04b6a73c33 --- /dev/null +++ b/src/components/project/workflow/dto/workflow-transition.dto.ts @@ -0,0 +1,45 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { stripIndent } from 'common-tags'; +import { EnumType, ID, IdField, makeEnum } from '~/common'; +import { ProjectStep } from '../../dto'; + +export type TransitionType = EnumType; +export const TransitionType = makeEnum({ + name: 'TransitionType', + values: ['Neutral', 'Approve', 'Reject'], +}); + +@ObjectType('ProjectStepTransition', { + description: stripIndent` + A transition for the project workflow. + + This is not a normalized entity. + A transition represented by its \`key\` can have different field values + based on the project's state. + `, +}) +export abstract class ProjectWorkflowTransition { + @IdField({ + description: stripIndent` + An local identifier for this transition. + It cannot be used to globally identify a transition. + It is passed to \`transitionProject\`. + `, + }) + readonly key: ID; + + @Field(() => ProjectStep) + readonly to: ProjectStep; + + @Field() + readonly label: string; + + @Field(() => TransitionType) + readonly type: TransitionType; + + @Field(() => Boolean, { defaultValue: false }) + readonly disabled?: boolean; + + @Field(() => String, { nullable: true }) + readonly disabledReason?: string; +} diff --git a/src/components/project/workflow/events/project-transitioned.event.ts b/src/components/project/workflow/events/project-transitioned.event.ts new file mode 100644 index 0000000000..9dfd52d9d8 --- /dev/null +++ b/src/components/project/workflow/events/project-transitioned.event.ts @@ -0,0 +1,13 @@ +import type { UnsecuredDto } from '~/common'; +import type { Project, ProjectStep } from '../../dto'; +import type { ProjectWorkflowEvent as WorkflowEvent } from '../dto'; +import type { InternalTransition } from '../transitions'; + +export class ProjectTransitionedEvent { + constructor( + readonly project: Project, + readonly previousStep: ProjectStep, + readonly next: InternalTransition | ProjectStep, + readonly workflowEvent: UnsecuredDto, + ) {} +} diff --git a/src/components/project/workflow/handlers/project-workflow-notification.handler.ts b/src/components/project/workflow/handlers/project-workflow-notification.handler.ts new file mode 100644 index 0000000000..5032a3d07d --- /dev/null +++ b/src/components/project/workflow/handlers/project-workflow-notification.handler.ts @@ -0,0 +1,126 @@ +import { ModuleRef } from '@nestjs/core'; +import { asyncPool } from '@seedcompany/common'; +import { EmailService } from '@seedcompany/nestjs-email'; +import { UnsecuredDto } from '~/common'; +import { + ConfigService, + EventsHandler, + IEventHandler, + ILogger, + Logger, +} from '~/core'; +import { + ProjectStepChanged, + ProjectStepChangedProps, +} from '~/core/email/templates/project-step-changed.template'; +import { AuthenticationService } from '../../../authentication'; +import { ProjectService } from '../../../project'; +import { UserService } from '../../../user'; +import { User } from '../../../user/dto'; +import { Project, ProjectStep } from '../../dto'; +import { ProjectTransitionedEvent } from '../events/project-transitioned.event'; +import { Notifier, TeamMembers } from '../transitions/notifiers'; + +@EventsHandler(ProjectTransitionedEvent) +export class ProjectWorkflowNotificationHandler + implements IEventHandler +{ + constructor( + private readonly auth: AuthenticationService, + private readonly config: ConfigService, + private readonly users: UserService, + private readonly projects: ProjectService, + private readonly emailService: EmailService, + private readonly moduleRef: ModuleRef, + @Logger('progress-report:status-change-notifier') + private readonly logger: ILogger, + ) {} + + async handle(event: ProjectTransitionedEvent) { + const { previousStep, next, workflowEvent } = event; + const transition = typeof next !== 'string' ? next : undefined; + + const notifiers = [ + TeamMembers, + ...(transition?.notifiers ?? []), + // TODO on bypass: keep notifying members? add anyone else? + ]; + + const params = { project: event.project, moduleRef: this.moduleRef }; + const notifyees = ( + await Promise.all(notifiers.map((notifier) => notifier.resolve(params))) + ).flat(); + + if (notifyees.length === 0) { + return; + } + + this.logger.info('Notifying', { + emails: notifyees.flatMap((r) => r.email ?? []), + projectId: event.project.id, + previousStep: event.previousStep, + toStep: event.workflowEvent.to, + }); + + const changedBy = await this.users.readOneUnsecured( + workflowEvent.who.id, + this.config.rootUser.id, + ); + const project = await this.projects.readOneUnsecured( + event.project.id, + this.config.rootUser.id, + ); + + let primaryPartnerName: string | undefined; // TODO + + await asyncPool(1, notifyees, async (notifier) => { + if (!notifier.email) { + return; + } + + const props = await this.resolveTemplateProps( + notifier, + changedBy, + project, + previousStep, + primaryPartnerName, + ); + await this.emailService.send(notifier.email, ProjectStepChanged, props); + }); + } + + private async resolveTemplateProps( + notifier: Notifier, + changedBy: UnsecuredDto, + project: UnsecuredDto, + previousStep?: ProjectStep, + primaryPartnerName?: string, + ): Promise { + const recipientId = notifier.id ?? this.config.rootUser.id; + const recipientSession = await this.auth.sessionForUser(recipientId); + const recipient = notifier.id + ? await this.users.readOne(recipientId, recipientSession) + : ({ + email: { value: notifier.email, canRead: true, canEdit: false }, + displayFirstName: { + value: notifier.email!.split('@')[0], + canRead: true, + canEdit: false, + }, + displayLastName: { value: '', canRead: true, canEdit: false }, + timezone: { + value: this.config.defaultTimeZone, + canRead: true, + canEdit: false, + }, + } satisfies ProjectStepChangedProps['recipient']); + + return { + recipient, + changedBy: this.users.secure(changedBy, recipientSession), + project: this.projects.secure(project, recipientSession), + previousStep, + primaryPartnerName, + }; + } +} diff --git a/src/components/project/workflow/project-workflow-event.loader.ts b/src/components/project/workflow/project-workflow-event.loader.ts new file mode 100644 index 0000000000..42ee705431 --- /dev/null +++ b/src/components/project/workflow/project-workflow-event.loader.ts @@ -0,0 +1,15 @@ +import { ID } from '~/common'; +import { LoaderFactory, SessionAwareLoaderStrategy } from '~/core'; +import { ProjectWorkflowEvent as WorkflowEvent } from './dto'; +import { ProjectWorkflowService } from './project-workflow.service'; + +@LoaderFactory(() => WorkflowEvent) +export class ProjectWorkflowEventLoader extends SessionAwareLoaderStrategy { + constructor(private readonly service: ProjectWorkflowService) { + super(); + } + + async loadMany(ids: readonly ID[]) { + return await this.service.readMany(ids, this.session); + } +} diff --git a/src/components/project/workflow/project-workflow.flowchart.ts b/src/components/project/workflow/project-workflow.flowchart.ts new file mode 100644 index 0000000000..9a6b983000 --- /dev/null +++ b/src/components/project/workflow/project-workflow.flowchart.ts @@ -0,0 +1,125 @@ +import { Injectable } from '@nestjs/common'; +import { cacheable, cleanJoin, simpleSwitch } from '@seedcompany/common'; +import open from 'open'; +import * as uuid from 'uuid'; +import { deflateSync as deflate } from 'zlib'; +import { ProjectStep as Step } from '../dto'; +import { Transitions } from './transitions'; +import { DynamicStep } from './transitions/dynamic-step'; + +@Injectable() +export class ProjectWorkflowFlowchart { + /** Generate a flowchart in mermaid markup. */ + generateMarkup() { + const rgbHexAddAlpha = (rgb: string, alpha: number) => + rgb + alpha.toString(16).slice(2, 4); + const colorStyle = (color: string) => ({ + fill: color, + stroke: color.slice(0, 7), + }); + const styles = { + Approve: colorStyle(rgbHexAddAlpha('#23b800', 0.65)), + Reject: colorStyle(rgbHexAddAlpha('#ff0000', 0.7)), + Neutral: colorStyle(rgbHexAddAlpha('#000000', 0.17)), + State: colorStyle(rgbHexAddAlpha('#00bcff', 0.58)), + UnusedState: { fill: rgbHexAddAlpha('#00bcff', 0.58), stroke: '#ff0000' }, + }; + const dynamicToId = cacheable(new Map(), () => + uuid.v1().replaceAll(/-/g, ''), + ); + const usedSteps = new Set(); + const useStep = (step: Step) => { + usedSteps.add(step); + return step; + }; + + const graph = cleanJoin('\n', [ + 'flowchart TD', + ...Object.values(Transitions).flatMap((t) => { + const key = t.key.replaceAll(/-/g, ''); + const to = + typeof t.to === 'string' + ? `--> ${useStep(t.to)}` + : t.to.relatedSteps + ? `-."${t.to.description}".-> ${t.to.relatedSteps + .map(useStep) + .join(' & ')}` + : `--> ${dynamicToId(t.to)}`; + const conditions = t.conditions + ? '--"' + t.conditions.map((c) => c.description).join('\\n') + '"' + : ''; + const from = (t.from ? t.from.map(useStep) : ['*(*)']).join(' & '); + return [ + `%% ${t.name}`, + `${key}{{ ${t.label} }}:::${t.type}`, + `${from} ${conditions}--- ${key} ${to}`, + '', + ].join('\n'); + }), + '', + ...[...Step].map((step) => { + const { label } = Step.entry(step); + const className = `${usedSteps.has(step) ? '' : 'Unused'}State`; + return `${step}(${label}):::${className}`; + }), + '', + ...Object.entries(styles).flatMap(([type, style]) => { + const str = Object.entries(style) + .map(([key, value]) => `${key}:${value}`) + .join(','); + return str ? `classDef ${type} ${str}` : []; + }), + ]); + + return graph; + } + + /** + * Copy mermaid markup of workflow to clipboard + * ```bash + * echo '$(ProjectWorkflowFlowchart).dump()' | LOG_LEVELS='*=error' yarn repl | pbcopy + * ``` + */ + dump() { + // eslint-disable-next-line no-console + console.log(this.generateMarkup()); + } + + /** + * Open a generated SVG of workflow in browser + * ```bash + * echo '$(ProjectWorkflowFlowchart).open()' | yarn repl + * ``` + */ + open(type: 'edit' | 'view' | 'svg' = 'view') { + const url = this.generateUrl(this.generateMarkup(), type); + return open(url); + } + + protected generateUrl( + markup: string, + type: 'edit' | 'view' | 'svg' = 'view', + config = { theme: 'dark' }, + ) { + const doc = { + code: cleanJoin('\n', [ + // Can't figure out if this is needed or not. + // `%%{ init: ${JSON.stringify(config)} }%`, + markup, + ]), + mermaid: config, + }; + const baseUrl = simpleSwitch(type, { + view: 'https://mermaid.live/view#', + edit: 'https://mermaid.live/edit#', + svg: 'https://mermaid.ink/svg/', + })!; + const encoded = + 'pako:' + + deflate(JSON.stringify(doc), { level: 9 }) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + return baseUrl + encoded; + } +} diff --git a/src/components/project/workflow/project-workflow.granter.ts b/src/components/project/workflow/project-workflow.granter.ts new file mode 100644 index 0000000000..85f8c052a4 --- /dev/null +++ b/src/components/project/workflow/project-workflow.granter.ts @@ -0,0 +1,205 @@ +import { entries } from '@seedcompany/common'; +import { Query } from 'cypher-query-builder'; +import { inspect, InspectOptionsStylized } from 'util'; +import { ID, isIdLike, Many } from '~/common'; +import { Granter, ResourceGranter } from '../../authorization'; +import { action } from '../../authorization/policy/builder/perm-granter'; +import { PropsGranterFn } from '../../authorization/policy/builder/resource-granter'; +import { + Condition, + eqlInLiteralSet, + IsAllowedParams, +} from '../../authorization/policy/conditions'; +import { ProjectStep } from '../dto'; +import { ProjectWorkflowEvent as Event } from './dto'; +import { TransitionName, Transitions } from './transitions'; + +// As string literal so policies don't have to import enum +type Status = `${ProjectStep}`; + +@Granter(Event) +export class ProjectWorkflowEventGranter extends ResourceGranter { + get read() { + return this[action]('read'); + } + + /** + * Allow bypassing workflow to set certain statuses. + */ + get allowBypass(): this { + const cloned = this.or[action]('create'); + cloned.stagedCondition = this.stagedCondition; + cloned.trailingCondition = this.trailingCondition; + return cloned; + } + + /** + * Can read & execute all transitions. + */ + get executeAll(): this { + return this.transitions(entries(Transitions).map(([k]) => k)).execute; + } + + /** + * Can execute transition. + */ + get execute() { + return this[action]('create'); + } + + isTransitions(...transitions: Array>) { + return TransitionCondition.fromName(transitions.flat()); + } + + transitions(...transitions: Array>) { + return this.when(this.isTransitions(...transitions)); + } + + isStatus(...statuses: Array>) { + return TransitionCondition.fromEndStatus(statuses.flat()); + } + + status(...statuses: Array>) { + return this.when(this.isStatus(...statuses)); + } + + specifically(grants: PropsGranterFn): this { + return super.specifically(grants); + } +} + +interface TransitionCheck { + key: ID[]; + name?: TransitionName; + endStatus?: Status; +} + +class TransitionCondition implements Condition { + private readonly allowedTransitionKeys; + + protected constructor(private readonly checks: readonly TransitionCheck[]) { + this.allowedTransitionKeys = new Set(checks.flatMap((c) => c.key)); + } + + static fromName(transitions: readonly TransitionName[]) { + const allowed = new Set(transitions); + return new TransitionCondition( + [...allowed].map((name) => ({ + name, + key: [Transitions[name].key], + })), + ); + } + + static fromEndStatus(statuses: readonly Status[]) { + const allowed = new Set(statuses); + return new TransitionCondition( + [...allowed].map((endStatus) => ({ + endStatus, + key: Object.values(Transitions) + // TODO handle dynamic to? + .filter((t) => typeof t.to === 'string' && allowed.has(t.to)) + .map((t) => t.key), + })), + ); + } + + isAllowed({ object }: IsAllowedParams) { + if (!object) { + // We are expecting to be called without an object sometimes. + // These should be treated as false without error. + return false; + } + const transitionKey = object.transition; + if (!transitionKey) { + return false; + } + return this.allowedTransitionKeys.has( + isIdLike(transitionKey) ? transitionKey : transitionKey.key, + ); + } + + asCypherCondition(query: Query) { + // TODO bypasses to statuses won't work with this. How should these be filtered? + const required = query.params.addParam( + this.allowedTransitionKeys, + 'allowedTransitions', + ); + return `node.transition IN ${String(required)}`; + } + + asEdgeQLCondition() { + // TODO bypasses to statuses won't work with this. How should these be filtered? + const transitionAllowed = eqlInLiteralSet( + '.transitionKey', + this.allowedTransitionKeys, + ); + // If no transition then false + return `((${transitionAllowed}) ?? false)`; + } + + union(this: void, conditions: this[]) { + const checks = [ + ...new Map( + conditions + .flatMap((condition) => condition.checks) + .map((check) => { + const key = check.name + ? `name:${check.name}` + : `status:${check.endStatus!}`; + return [key, check]; + }), + ).values(), + ]; + return new TransitionCondition(checks); + } + + intersect(this: void, conditions: this[]) { + const checks = [...conditions[0].checks].filter((check1) => + conditions.every((cond) => + cond.checks.some( + (check2) => + check1.name === check2.name || + check1.endStatus === check2.endStatus, + ), + ), + ); + return new TransitionCondition(checks); + } + + [inspect.custom](_depth: number, _options: InspectOptionsStylized) { + const render = (label: string, items: readonly string[]) => { + const itemsStr = items.map((l) => ` ${l}`).join('\n'); + return `${label} {\n${itemsStr}\n}`; + }; + if (this.allowedTransitionKeys.size === 0) { + return 'No Transitions'; + } + const byName = this.checks.filter((c) => c.name); + const byEndStatus = this.checks.filter((c) => c.endStatus); + const transitions = + byName.length > 0 + ? render( + 'Transitions', + byName.map((c) => c.name!), + ) + : undefined; + const endStatuses = + byEndStatus.length > 0 + ? render( + 'End Statuses', + byEndStatus.map((c) => c.endStatus!), + ) + : undefined; + if (transitions && endStatuses) { + return `(${transitions} OR ${endStatuses})`; + } + return transitions ?? endStatuses!; + } +} + +declare module '../../authorization/policy/granters' { + interface GrantersOverride { + ProjectWorkflowEvent: ProjectWorkflowEventGranter; + } +} diff --git a/src/components/project/workflow/project-workflow.module.ts b/src/components/project/workflow/project-workflow.module.ts new file mode 100644 index 0000000000..748a2527dd --- /dev/null +++ b/src/components/project/workflow/project-workflow.module.ts @@ -0,0 +1,36 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { splitDb2 } from '~/core'; +import { UserModule } from '../../user/user.module'; +import { ProjectModule } from '../project.module'; +import { ProjectWorkflowNotificationHandler } from './handlers/project-workflow-notification.handler'; +import { ProjectWorkflowEventLoader } from './project-workflow-event.loader'; +import { ProjectWorkflowFlowchart } from './project-workflow.flowchart'; +import { ProjectWorkflowEventGranter } from './project-workflow.granter'; +import { ProjectWorkflowNeo4jRepository } from './project-workflow.neo4j.repository'; +import { ProjectWorkflowRepository } from './project-workflow.repository'; +import { ProjectWorkflowService } from './project-workflow.service'; +import { ProjectExecuteTransitionResolver } from './resolvers/project-execute-transition.resolver'; +import { ProjectTransitionsResolver } from './resolvers/project-transitions.resolver'; +import { ProjectWorkflowEventResolver } from './resolvers/project-workflow-event.resolver'; +import { ProjectWorkflowEventsResolver } from './resolvers/project-workflow-events.resolver'; + +@Module({ + imports: [forwardRef(() => UserModule), forwardRef(() => ProjectModule)], + providers: [ + ProjectTransitionsResolver, + ProjectExecuteTransitionResolver, + ProjectWorkflowEventsResolver, + ProjectWorkflowEventResolver, + ProjectWorkflowEventLoader, + ProjectWorkflowService, + ProjectWorkflowEventGranter, + splitDb2(ProjectWorkflowRepository, { + neo4j: ProjectWorkflowNeo4jRepository, + edge: ProjectWorkflowRepository, + }), + ProjectWorkflowFlowchart, + ProjectWorkflowNotificationHandler, + ], + exports: [ProjectWorkflowService], +}) +export class ProjectWorkflowModule {} diff --git a/src/components/project/workflow/project-workflow.neo4j.repository.ts b/src/components/project/workflow/project-workflow.neo4j.repository.ts new file mode 100644 index 0000000000..f671615a1e --- /dev/null +++ b/src/components/project/workflow/project-workflow.neo4j.repository.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import { inArray, node, Query, relation } from 'cypher-query-builder'; +import { SetRequired } from 'type-fest'; +import { + ID, + Order, + PublicOf, + ServerException, + Session, + UnsecuredDto, +} from '~/common'; +import { DtoRepository } from '~/core/database'; +import { + ACTIVE, + createNode, + createRelationships, + INACTIVE, + merge, + requestingUser, + sorting, +} from '~/core/database/query'; +import { IProject, ProjectStep } from '../dto'; +import { + ExecuteProjectTransitionInput, + ProjectWorkflowEvent as WorkflowEvent, +} from './dto'; +import { ProjectWorkflowRepository } from './project-workflow.repository'; + +@Injectable() +export class ProjectWorkflowNeo4jRepository + extends DtoRepository(WorkflowEvent) + implements PublicOf +{ + // @ts-expect-error It doesn't have match base signature + async readMany(ids: readonly ID[], session: Session) { + return await this.db + .query() + .apply(this.matchEvent()) + .where({ 'node.id': inArray(ids) }) + .apply(this.privileges.forUser(session).filterToReadable()) + .apply(this.hydrate()) + .map('dto') + .run(); + } + + async list(projectId: ID, session: Session) { + return await this.db + .query() + .apply(this.matchEvent()) + .where({ 'project.id': projectId }) + .match(requestingUser(session)) + .apply(this.privileges.forUser(session).filterToReadable()) + .apply(sorting(WorkflowEvent, { sort: 'createdAt', order: Order.ASC })) + .apply(this.hydrate()) + .map('dto') + .run(); + } + + protected matchEvent() { + return (query: Query) => + query.match([ + node('node', this.resource.dbLabel), + relation('in', '', ACTIVE), + node('project', 'Project'), + ]); + } + + protected hydrate() { + return (query: Query) => + query + .match([ + node('node'), + relation('out', undefined, 'who'), + node('who', 'User'), + ]) + .return<{ dto: UnsecuredDto }>( + merge('node', { + at: 'node.createdAt', + who: 'who { .id }', + }).as('dto'), + ); + } + + async recordEvent( + { project, ...props }: SetRequired, + session: Session, + ) { + const result = await this.db + .query() + .apply( + await createNode(WorkflowEvent, { + baseNodeProps: props, + }), + ) + .apply( + createRelationships(WorkflowEvent, { + in: { workflowEvent: ['Project', project] }, + out: { who: ['User', session.userId] }, + }), + ) + .apply(this.hydrate()) + .first(); + const event = result!.dto; + + await this.db.updateProperties({ + type: IProject, + object: { id: project }, + changes: { step: event.to, stepChangedAt: event.at }, + }); + + return event; + } + + async mostRecentStep( + projectId: ID<'Project'>, + steps: readonly ProjectStep[], + ) { + const result = await this.db + .query() + .match([ + node('node', 'Project', { id: projectId }), + relation('out', '', 'step', INACTIVE), + node('prop'), + ]) + .where({ 'prop.value': inArray(steps) }) + .with('prop') + .orderBy('prop.createdAt', 'DESC') + .return<{ step: ProjectStep }>(`prop.value as step`) + .first(); + if (!result) { + throw new ServerException("Failed to determine project's previous steps"); + } + return result.step ?? null; + } +} diff --git a/src/components/project/workflow/project-workflow.repository.ts b/src/components/project/workflow/project-workflow.repository.ts new file mode 100644 index 0000000000..2ab4268de5 --- /dev/null +++ b/src/components/project/workflow/project-workflow.repository.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { SetRequired } from 'type-fest'; +import { ID, Session } from '~/common'; +import { e, edgeql, RepoFor } from '~/core/edgedb'; +import { ProjectStep } from '../dto'; +import { ExecuteProjectTransitionInput, ProjectWorkflowEvent } from './dto'; + +@Injectable() +export class ProjectWorkflowRepository extends RepoFor(ProjectWorkflowEvent, { + hydrate: (event) => ({ + id: true, + who: true, + at: true, + transition: event.transitionKey, + to: true, + notes: true, + }), + omit: ['list', 'create', 'update', 'delete', 'readMany'], +}) { + async readMany(ids: readonly ID[], _session: Session) { + return await this.defaults.readMany(ids); + } + + async list(projectId: ID, _session: Session) { + const project = e.cast(e.Project, e.uuid(projectId)); + const query = e.select(project.workflowEvents, this.hydrate); + return await this.db.run(query); + } + + async recordEvent( + { project, ...props }: SetRequired, + _session: Session, + ) { + const created = e.insert(e.Project.WorkflowEvent, { + project: e.cast(e.Project, e.uuid(project)), + transitionKey: props.transition, + to: props.step, + notes: props.notes, + }); + const query = e.select(created, this.hydrate); + return await this.db.run(query); + } + + async mostRecentStep( + projectId: ID<'Project'>, + steps: readonly ProjectStep[], + ) { + const query = edgeql(` + with + project := $projectId, + steps := array_unpack(>$steps), + mostRecentEvent := ( + select project.workflowEvents + filter .to in steps if exists steps else true + order by .at desc + limit 1 + ) + select mostRecentEvent.to + `); + return await this.db.run(query, { projectId, steps }); + } +} diff --git a/src/components/project/workflow/project-workflow.service.ts b/src/components/project/workflow/project-workflow.service.ts new file mode 100644 index 0000000000..dee179d7d2 --- /dev/null +++ b/src/components/project/workflow/project-workflow.service.ts @@ -0,0 +1,210 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { + ID, + many, + Session, + UnauthorizedException, + UnsecuredDto, +} from '~/common'; +import { IEventBus, ResourceLoader } from '~/core'; +import { Privileges } from '../../authorization'; +import { Project, ProjectStep } from '../dto'; +import { + ExecuteProjectTransitionInput, + ProjectWorkflowEvent as WorkflowEvent, +} from './dto'; +import { ProjectTransitionedEvent } from './events/project-transitioned.event'; +import { ProjectWorkflowRepository } from './project-workflow.repository'; +import { Transitions } from './transitions'; + +@Injectable() +export class ProjectWorkflowService { + constructor( + private readonly privileges: Privileges, + private readonly resources: ResourceLoader, + private readonly repo: ProjectWorkflowRepository, + private readonly eventBus: IEventBus, + private readonly moduleRef: ModuleRef, + ) {} + + async list(report: Project, session: Session): Promise { + const dtos = await this.repo.list(report.id, session); + return dtos.map((dto) => this.secure(dto, session)); + } + + async readMany(ids: readonly ID[], session: Session) { + const dtos = await this.repo.readMany(ids, session); + return dtos.map((dto) => this.secure(dto, session)); + } + + private secure( + dto: UnsecuredDto, + session: Session, + ): WorkflowEvent { + const secured = this.privileges.for(session, WorkflowEvent).secure(dto); + return { + ...secured, + transition: dto.transition + ? Object.values(Transitions).find((t) => t.key === dto.transition) ?? + null + : null, + }; + } + + async getAvailableTransitions(project: Project, session: Session) { + const currentStep = project.step.value!; + + let available = Object.values(Transitions); + + // Filter out non applicable transitions + available = available.filter((t) => + t.from ? many(t.from).includes(currentStep) : true, + ); + + // Filter out transitions without authorization to execute + const p = this.privileges.for(session, WorkflowEvent); + available = available.filter((t) => + // I don't have a good way to type this right now. + // Context usage is still fuzzy when conditions need different shapes. + p.forContext({ transition: t.key } as any).can('create'), + ); + + const params = { project, moduleRef: this.moduleRef }; + + // Resolve conditions & filter as needed + const conditions = available.flatMap((t) => t.conditions ?? []); + const resolvedConditions = new Map( + await Promise.all( + [...new Set(conditions)].map( + async (condition) => + [condition, await condition.resolve(params)] as const, + ), + ), + ); + available = available.flatMap((t) => { + const conditions = + t.conditions?.map((c) => resolvedConditions.get(c)!) ?? []; + if (conditions.some((c) => c.status === 'OMIT')) { + return []; + } + if (conditions.every((c) => c.status === 'ENABLED')) { + return t; + } + const disabledReasons = conditions.flatMap((c) => + c.status === 'DISABLED' ? c.disabledReason ?? [] : [], + ); + return { + ...t, + disabled: true, + disabledReason: disabledReasons.join('\n'), // TODO split to list + }; + }); + + // Resolve dynamic to steps + const dynamicTos = available.flatMap((t) => + typeof t.to !== 'string' ? t.to : [], + ); + const resolvedTos = new Map( + await Promise.all( + dynamicTos.map(async (to) => [to, await to.resolve(params)] as const), + ), + ); + return available.map((t) => ({ + ...t, + to: typeof t.to !== 'string' ? resolvedTos.get(t.to)! : t.to, + })); + } + + canBypass(session: Session) { + return this.privileges.for(session, WorkflowEvent).can('create'); + } + + async executeTransition( + input: ExecuteProjectTransitionInput, + session: Session, + ) { + const { project: projectId, notes } = input; + + const { ProjectLoader } = await import('../project.loader'); + const projects = await this.resources.getLoader(ProjectLoader); + const loaderKey = { + id: projectId, + view: { active: true }, + } as const; + const previous = await projects.load(loaderKey); + + const next = await this.validateExecutionInput(input, previous, session); + + const unsecuredEvent = await this.repo.recordEvent( + { + project: projectId, + ...(typeof next !== 'string' + ? { transition: next.key, step: next.to } + : { step: next }), + notes, + }, + session, + ); + + projects.clear(loaderKey); + const updated = await projects.load(loaderKey); + + const event = new ProjectTransitionedEvent( + updated, + previous.step.value!, + next, + unsecuredEvent, + ); + await this.eventBus.publish(event); + + return updated; + } + + private async validateExecutionInput( + input: ExecuteProjectTransitionInput, + current: Project, + session: Session, + ) { + const { transition: transitionKey, step: overrideStatus } = input; + + if (overrideStatus) { + if (!this.canBypass(session)) { + throw new UnauthorizedException( + 'You do not have permission to bypass workflow. Specify a transition ID instead.', + ); + } + return overrideStatus; + } + + const available = await this.getAvailableTransitions(current, session); + const transition = available.find((t) => t.key === transitionKey); + if (!transition) { + throw new UnauthorizedException('This transition is not available'); + } + return transition; + } + + /** @deprecated */ + async executeTransitionLegacy( + currentProject: Project, + step: ProjectStep, + session: Session, + ) { + const transitions = await this.getAvailableTransitions( + currentProject, + session, + ); + // Pick the first matching to step. + // Lack of detail is one of the reasons why this is legacy logic. + const transition = transitions.find((t) => t.to === step); + + await this.executeTransition( + { + project: currentProject.id, + ...(transition ? { transition: transition.key } : { step }), + }, + session, + ); + } +} diff --git a/src/components/project/workflow/resolvers/project-execute-transition.resolver.ts b/src/components/project/workflow/resolvers/project-execute-transition.resolver.ts new file mode 100644 index 0000000000..bce3f94800 --- /dev/null +++ b/src/components/project/workflow/resolvers/project-execute-transition.resolver.ts @@ -0,0 +1,18 @@ +import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { LoggedInSession, Session } from '~/common'; +import { IProject, Project } from '../../dto'; +import { ExecuteProjectTransitionInput } from '../dto'; +import { ProjectWorkflowService } from '../project-workflow.service'; + +@Resolver() +export class ProjectExecuteTransitionResolver { + constructor(private readonly workflow: ProjectWorkflowService) {} + + @Mutation(() => IProject) + async transitionProject( + @Args({ name: 'input' }) input: ExecuteProjectTransitionInput, + @LoggedInSession() session: Session, + ): Promise { + return await this.workflow.executeTransition(input, session); + } +} diff --git a/src/components/project/workflow/resolvers/project-transitions.resolver.ts b/src/components/project/workflow/resolvers/project-transitions.resolver.ts new file mode 100644 index 0000000000..c48e921cd0 --- /dev/null +++ b/src/components/project/workflow/resolvers/project-transitions.resolver.ts @@ -0,0 +1,48 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { Loader, LoaderOf } from '@seedcompany/data-loader'; +import { stripIndent } from 'common-tags'; +import { + AnonSession, + ParentIdMiddlewareAdditions, + Session, + viewOfChangeset, +} from '~/common'; +import { SecuredProjectStep } from '../../dto'; +import { ProjectLoader } from '../../project.loader'; +import { ProjectWorkflowTransition } from '../dto/workflow-transition.dto'; +import { ProjectWorkflowService } from '../project-workflow.service'; + +@Resolver(SecuredProjectStep) +export class ProjectTransitionsResolver { + constructor(private readonly workflow: ProjectWorkflowService) {} + + @ResolveField(() => [ProjectWorkflowTransition], { + description: 'The available steps a project can be transitioned to.', + }) + async transitions( + @Parent() status: SecuredProjectStep & ParentIdMiddlewareAdditions, + @Loader(ProjectLoader) projects: LoaderOf, + @AnonSession() session: Session, + ): Promise { + if (!status.canRead || !status.value) { + return []; + } + const project = await projects.load({ + id: status.parentId, + view: viewOfChangeset(status.changeset), + }); + return await this.workflow.getAvailableTransitions(project, session); + } + + @ResolveField(() => Boolean, { + description: stripIndent` + Is the current user allowed to bypass transitions entirely + and change the status to any other step? + `, + }) + async canBypassTransitions( + @AnonSession() session: Session, + ): Promise { + return this.workflow.canBypass(session); + } +} diff --git a/src/components/project/workflow/resolvers/project-workflow-event.resolver.ts b/src/components/project/workflow/resolvers/project-workflow-event.resolver.ts new file mode 100644 index 0000000000..0ee91c5c2b --- /dev/null +++ b/src/components/project/workflow/resolvers/project-workflow-event.resolver.ts @@ -0,0 +1,17 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { mapSecuredValue } from '~/common'; +import { Loader, LoaderOf } from '~/core'; +import { UserLoader } from '../../../user'; +import { SecuredUser } from '../../../user/dto'; +import { ProjectWorkflowEvent as WorkflowEvent } from '../dto'; + +@Resolver(WorkflowEvent) +export class ProjectWorkflowEventResolver { + @ResolveField(() => SecuredUser) + async who( + @Parent() event: WorkflowEvent, + @Loader(UserLoader) users: LoaderOf, + ): Promise { + return await mapSecuredValue(event.who, ({ id }) => users.load(id)); + } +} diff --git a/src/components/project/workflow/resolvers/project-workflow-events.resolver.ts b/src/components/project/workflow/resolvers/project-workflow-events.resolver.ts new file mode 100644 index 0000000000..9ac26daa95 --- /dev/null +++ b/src/components/project/workflow/resolvers/project-workflow-events.resolver.ts @@ -0,0 +1,18 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { AnonSession, Session } from '~/common'; +import { IProject, Project } from '../../dto'; +import { ProjectWorkflowEvent as WorkflowEvent } from '../dto'; +import { ProjectWorkflowService } from '../project-workflow.service'; + +@Resolver(IProject) +export class ProjectWorkflowEventsResolver { + constructor(private readonly service: ProjectWorkflowService) {} + + @ResolveField(() => [WorkflowEvent]) + async workflowEvents( + @Parent() report: Project, + @AnonSession() session: Session, + ): Promise { + return await this.service.list(report, session); + } +} diff --git a/src/components/project/workflow/transitions/conditions.ts b/src/components/project/workflow/transitions/conditions.ts new file mode 100644 index 0000000000..c574b70190 --- /dev/null +++ b/src/components/project/workflow/transitions/conditions.ts @@ -0,0 +1,50 @@ +import { Promisable } from 'type-fest'; +import { EngagementService } from '../../../engagement'; +import { EngagementStatus } from '../../../engagement/dto'; +import { ResolveParams } from './dynamic-step'; + +export interface TransitionCondition { + description: string; + resolve: (params: ResolveParams) => Promisable<{ + status: 'ENABLED' | 'DISABLED' | 'OMIT'; + /** + * If not allowed, present transition anyway, as disabled, + * and include this string explaining why. + */ + disabledReason?: string; + }>; +} + +export const IsNotMultiplication: TransitionCondition = { + description: 'Momentum / Internship', + resolve({ project }: ResolveParams) { + return { + status: project.type !== 'MultiplicationTranslation' ? 'ENABLED' : 'OMIT', + }; + }, +}; + +export const IsMultiplication: TransitionCondition = { + description: 'Multiplication', + resolve({ project }: ResolveParams) { + return { + status: project.type === 'MultiplicationTranslation' ? 'ENABLED' : 'OMIT', + }; + }, +}; + +export const RequireOngoingEngagementsToBeFinalizingCompletion: TransitionCondition = + { + description: + 'All engagements must be Finalizing Completion or in a terminal status', + async resolve({ project, moduleRef }: ResolveParams) { + const repo = moduleRef.get(EngagementService); + const hasOngoing = await repo.hasOngoing(project.id, [ + EngagementStatus.FinalizingCompletion, + ]); + return { + status: hasOngoing ? 'DISABLED' : 'ENABLED', + disabledReason: `The project cannot be completed since some ongoing engagements are not "Finalizing Completion"`, + }; + }, + }; diff --git a/src/components/project/workflow/transitions/dynamic-step.ts b/src/components/project/workflow/transitions/dynamic-step.ts new file mode 100644 index 0000000000..f8fd0bd734 --- /dev/null +++ b/src/components/project/workflow/transitions/dynamic-step.ts @@ -0,0 +1,28 @@ +import { ModuleRef } from '@nestjs/core'; +import { Promisable } from 'type-fest'; +import { Project, ProjectStep, ProjectStep as Step } from '../../dto'; +import { ProjectWorkflowRepository } from '../project-workflow.repository'; + +export interface ResolveParams { + project: Project; + moduleRef: ModuleRef; +} + +export interface DynamicStep { + description: string; + resolve: (params: ResolveParams) => Promisable; + relatedSteps?: ProjectStep[]; +} + +export const BackTo = (...steps: ProjectStep[]): DynamicStep => ({ + description: 'Back', + relatedSteps: steps, + async resolve({ project, moduleRef }: ResolveParams) { + const repo = moduleRef.get(ProjectWorkflowRepository); + const found = await repo.mostRecentStep(project.id, steps); + return found ?? steps[0] ?? ProjectStep.EarlyConversations; + }, +}); + +export const BackToActive = BackTo(Step.Active, Step.ActiveChangedPlan); +export const BackToMostRecent = (steps: ProjectStep[]) => BackTo(...steps); diff --git a/src/components/project/workflow/transitions/index.ts b/src/components/project/workflow/transitions/index.ts new file mode 100644 index 0000000000..0a1143e523 --- /dev/null +++ b/src/components/project/workflow/transitions/index.ts @@ -0,0 +1,2 @@ +export * from './project-transitions'; +export type { InternalTransition } from './types'; diff --git a/src/components/project/workflow/transitions/notifiers.ts b/src/components/project/workflow/transitions/notifiers.ts new file mode 100644 index 0000000000..1f1701f3f8 --- /dev/null +++ b/src/components/project/workflow/transitions/notifiers.ts @@ -0,0 +1,61 @@ +import { Many } from '@seedcompany/common'; +import { MergeExclusive, Promisable } from 'type-fest'; +import { ID, Role } from '~/common'; +import { ConfigService } from '~/core'; +import { FinancialApproverRepository } from '../../financial-approver'; +import { ProjectMemberRepository } from '../../project-member/project-member.repository'; +import { ResolveParams } from './dynamic-step'; + +export interface TransitionNotifier { + description: string; + resolve: (params: ResolveParams) => Promisable>; +} + +export type Notifier = MergeExclusive< + { + id: ID<'User'>; + email?: string | null; + }, + { + id?: ID<'User'> | null; + email: string; + } +>; + +export const TeamMembers: TransitionNotifier = { + description: 'The project members', + async resolve({ project, moduleRef }: ResolveParams) { + return await moduleRef + .get(ProjectMemberRepository, { strict: false }) + .listAsNotifiers(project.id); + }, +}; + +export const TeamMembersWithRole = (...roles: Role[]): TransitionNotifier => ({ + description: 'The project members', + async resolve({ project, moduleRef }: ResolveParams) { + return await moduleRef + .get(ProjectMemberRepository, { strict: false }) + .listAsNotifiers(project.id, roles); + }, +}); + +export const FinancialApprovers: TransitionNotifier = { + description: 'All financial approvers according to the project type', + async resolve({ project, moduleRef }: ResolveParams) { + const repo = moduleRef.get(FinancialApproverRepository); + const approvers = await repo.read(project.type); + return approvers.map((approver) => approver.user); + }, +}; + +export const EmailDistros = (...emails: string[]) => ({ + description: `These email addresses: ${emails.join(', ')}`, + resolve({ moduleRef }: ResolveParams) { + const config = moduleRef.get(ConfigService); + if (!config.email.notifyDistributionLists) { + return []; + } + return emails.map((email) => ({ email })); + }, +}); diff --git a/src/components/project/workflow/transitions/project-transitions.ts b/src/components/project/workflow/transitions/project-transitions.ts new file mode 100644 index 0000000000..5a328977df --- /dev/null +++ b/src/components/project/workflow/transitions/project-transitions.ts @@ -0,0 +1,646 @@ +import { ProjectStep as Step } from '../../dto'; +import { TransitionType as Type } from '../dto'; +import { + IsMultiplication, + IsNotMultiplication, + RequireOngoingEngagementsToBeFinalizingCompletion, +} from './conditions'; +import { BackToActive, BackToMostRecent } from './dynamic-step'; +import { EmailDistros, FinancialApprovers, TeamMembers } from './notifiers'; +import { defineTransitions } from './types'; + +export type TransitionName = keyof typeof Transitions; + +// This also controls the order shown in the UI. +// Therefore, these should generally flow down. +// "Back" transitions should come before/above "forward" transitions. + +export const Transitions = defineTransitions({ + 'Early Conversations -> Pending Regional Director Approval': { + from: Step.EarlyConversations, + to: Step.PendingRegionalDirectorApproval, + label: 'Submit for Regional Director Approval', + type: Type.Approve, + conditions: IsMultiplication, + notifiers: TeamMembers, + }, + 'Early Conversations -> Pending Finance Confirmation': { + from: Step.EarlyConversations, + to: Step.PendingFinanceConfirmation, + label: 'Submit for Finance Confirmation', + type: Type.Approve, + conditions: IsMultiplication, + notifiers: TeamMembers, + }, + 'Early Conversations -> Pending Concept Approval': { + from: Step.EarlyConversations, + to: Step.PendingConceptApproval, + label: 'Submit for Concept Approval', + type: Type.Approve, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Early Conversations -> Did Not Develop': { + from: Step.EarlyConversations, + to: Step.DidNotDevelop, + label: 'End Development', + type: Type.Reject, + notifiers: TeamMembers, + }, + + // Pending Concept Approval + 'Pending Concept Approval -> Prep for Consultant Endorsement': { + from: Step.PendingConceptApproval, + to: Step.PrepForConsultantEndorsement, + label: 'Approve Concept', + type: Type.Approve, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Pending Concept Approval -> Early Conversations': { + from: Step.PendingConceptApproval, + to: Step.EarlyConversations, + label: 'Send Back for Corrections', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Pending Concept Approval -> Rejected': { + from: Step.PendingConceptApproval, + to: Step.Rejected, + label: 'Reject', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + + // Prep for Consultant Endorsement + 'Prep for Consultant Endorsement -> Pending Consultant Endorsement': { + from: Step.PrepForConsultantEndorsement, + to: Step.PendingConsultantEndorsement, + label: 'Submit for Consultant Endorsement', + type: Type.Approve, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Prep for Consultant Endorsement -> Pending Concept Approval': { + from: Step.PrepForConsultantEndorsement, + to: Step.PendingConceptApproval, + label: 'Resubmit for Concept Approval', + type: Type.Neutral, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Prep for Consultant Endorsement -> Did Not Develop': { + from: Step.PrepForConsultantEndorsement, + to: Step.DidNotDevelop, + label: 'End Development', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + + // Pending Consultant Endorsement + 'Pending Consultant Endorsement -> Prep for Financial Endorsement With Consultant Endorsement': + { + from: Step.PendingConsultantEndorsement, + to: Step.PrepForFinancialEndorsement, + label: 'Endorse Plan', + type: Type.Approve, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Pending Consultant Endorsement -> Prep for Financial Endorsement Without Consultant Endorsement': + { + from: Step.PendingConsultantEndorsement, + to: Step.PrepForFinancialEndorsement, + label: 'Do Not Endorse Plan', + type: Type.Neutral, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + + // Prep for Financial Endorsement + 'Prep for Financial Endorsement -> Pending Financial Endorsement': { + from: Step.PrepForFinancialEndorsement, + to: Step.PendingFinancialEndorsement, + label: 'Submit for Financial Endorsement', + type: Type.Approve, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Prep for Financial Endorsement -> Pending Consultant Endorsement': { + from: Step.PrepForFinancialEndorsement, + to: Step.PendingConsultantEndorsement, + label: 'Resubmit for Consultant Endorsement', + type: Type.Neutral, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Prep for Financial Endorsement -> Pending Concept Approval': { + from: Step.PrepForFinancialEndorsement, + to: Step.PendingConceptApproval, + label: 'Resubmit for Concept Approval', + type: Type.Neutral, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Prep for Financial Endorsement -> Did Not Develop': { + from: Step.PrepForFinancialEndorsement, + to: Step.DidNotDevelop, + label: 'End Development', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + + // Pending Financial Endorsement + 'Pending Financial Endorsement -> Finalizing Proposal With Financial Endorsement': + { + from: Step.PendingFinancialEndorsement, + to: Step.FinalizingProposal, + label: 'Endorse Project Plan', + type: Type.Approve, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Pending Financial Endorsement -> Finalizing Proposal Without Financial Endorsement': + { + from: Step.PendingFinancialEndorsement, + to: Step.FinalizingProposal, + label: 'Do Not Endorse Project Plan', + type: Type.Neutral, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + + // Finalizing Proposal + 'Finalizing Proposal -> Pending Regional Director Approval': { + from: Step.FinalizingProposal, + to: Step.PendingRegionalDirectorApproval, + label: 'Submit for Approval', + type: Type.Approve, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Finalizing Proposal -> Pending Financial Endorsement': { + from: Step.FinalizingProposal, + to: Step.PendingFinancialEndorsement, + label: 'Resubmit for Financial Endorsement', + type: Type.Neutral, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Finalizing Proposal -> Pending Consultant Endorsement': { + from: Step.FinalizingProposal, + to: Step.PendingConsultantEndorsement, + label: 'Resubmit for Consultant Endorsement', + type: Type.Neutral, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Finalizing Proposal -> Pending Concept Approval': { + from: Step.FinalizingProposal, + to: Step.PendingConceptApproval, + label: 'Resubmit for Concept Approval', + type: Type.Neutral, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Finalizing Proposal -> Did Not Develop': { + from: Step.FinalizingProposal, + to: Step.DidNotDevelop, + label: 'End Development', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + + // Pending Regional Director Approval + 'Pending Regional Director Approval -> Early Conversations': { + from: Step.PendingRegionalDirectorApproval, + to: Step.EarlyConversations, + label: 'Send Back for Corrections', + type: Type.Reject, + conditions: IsMultiplication, + notifiers: TeamMembers, + }, + 'Pending Regional Director Approval -> Pending Finance Confirmation': { + from: Step.PendingRegionalDirectorApproval, + to: Step.PendingFinanceConfirmation, + label: 'Approve Project', + type: Type.Approve, + notifiers: TeamMembers, + }, + 'Pending Regional Director Approval -> Pending Zone Director Approval': { + from: Step.PendingRegionalDirectorApproval, + to: Step.PendingZoneDirectorApproval, + label: 'Approve for Zonal Director Review', + type: Type.Approve, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Pending Regional Director Approval -> Finalizing Proposal': { + from: Step.PendingRegionalDirectorApproval, + to: Step.FinalizingProposal, + label: 'Send Back for Corrections', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Pending Regional Director Approval -> Did Not Develop': { + from: Step.PendingRegionalDirectorApproval, + to: Step.DidNotDevelop, + label: 'End Development', + type: Type.Reject, + conditions: IsMultiplication, + notifiers: TeamMembers, + }, + 'Pending Regional Director Approval -> Rejected': { + from: Step.PendingRegionalDirectorApproval, + to: Step.Rejected, + label: 'Reject', + type: Type.Reject, + notifiers: TeamMembers, + }, + + // Pending Zone Director Approval + 'Pending Zone Director Approval -> Pending Finance Confirmation': { + from: Step.PendingZoneDirectorApproval, + to: Step.PendingFinanceConfirmation, + label: 'Approve Project', + type: Type.Approve, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Pending Zone Director Approval -> Finalizing Proposal': { + from: Step.PendingZoneDirectorApproval, + to: Step.FinalizingProposal, + label: 'Send Back for Corrections', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + 'Pending Zone Director Approval -> Rejected': { + from: Step.PendingZoneDirectorApproval, + to: Step.Rejected, + label: 'Reject', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: TeamMembers, + }, + + // Pending Finance Confirmation + 'Pending Finance Confirmation -> Active': { + from: Step.PendingFinanceConfirmation, + to: Step.Active, + label: 'Confirm Project 🎉', + type: Type.Approve, + notifiers: [ + TeamMembers, + FinancialApprovers, + EmailDistros('project_approval@tsco.org', 'projects@tsco.org'), + ], + }, + 'Pending Finance Confirmation -> Pending Regional Director Approval': { + from: Step.PendingFinanceConfirmation, + to: Step.PendingRegionalDirectorApproval, + label: 'Send Back for Corrections', + type: Type.Reject, + conditions: IsMultiplication, + notifiers: [TeamMembers, FinancialApprovers], + }, + 'Pending Finance Confirmation -> Did Not Develop': { + from: Step.PendingFinanceConfirmation, + to: Step.DidNotDevelop, + label: 'End Development', + type: Type.Reject, + conditions: IsMultiplication, + notifiers: [TeamMembers, FinancialApprovers], + }, + 'Pending Finance Confirmation -> On Hold Finance Confirmation': { + from: Step.PendingFinanceConfirmation, + to: Step.OnHoldFinanceConfirmation, + label: 'Hold Project for Confirmation', + type: Type.Neutral, + conditions: IsNotMultiplication, + notifiers: [TeamMembers, FinancialApprovers], + }, + 'Pending Finance Confirmation -> Finalizing Proposal': { + from: Step.PendingFinanceConfirmation, + to: Step.FinalizingProposal, + label: 'Send Back for Corrections', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: [TeamMembers, FinancialApprovers], + }, + 'Pending Finance Confirmation -> Rejected': { + from: Step.PendingFinanceConfirmation, + to: Step.Rejected, + label: 'Reject', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: [TeamMembers, FinancialApprovers], + }, + + // On Hold Finance Confirmation + 'On Hold Finance Confirmation -> Active': { + from: Step.OnHoldFinanceConfirmation, + to: Step.Active, + label: 'Confirm Project 🎉', + type: Type.Approve, + conditions: IsNotMultiplication, + notifiers: [ + TeamMembers, + FinancialApprovers, + EmailDistros('project_approval@tsco.org', 'projects@tsco.org'), + ], + }, + 'On Hold Finance Confirmation -> Finalizing Proposal': { + from: Step.OnHoldFinanceConfirmation, + to: Step.FinalizingProposal, + label: 'Send Back for Corrections', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: [TeamMembers, FinancialApprovers], + }, + 'On Hold Finance Confirmation -> Rejected': { + from: Step.OnHoldFinanceConfirmation, + to: Step.Rejected, + label: 'Reject', + type: Type.Reject, + conditions: IsNotMultiplication, + notifiers: [TeamMembers, FinancialApprovers], + }, + + // Active + 'Active -> Discussing Change To Plan': { + from: [Step.Active, Step.ActiveChangedPlan], + to: Step.DiscussingChangeToPlan, + label: 'Discuss Change to Plan', + type: Type.Neutral, + notifiers: [ + TeamMembers, + FinancialApprovers, + EmailDistros('project_extension@tsco.org', 'project_revision@tsco.org'), + ], + }, + 'Active -> Discussing Termination': { + from: [Step.Active, Step.ActiveChangedPlan], + to: Step.DiscussingTermination, + label: 'Discuss Termination', + type: Type.Neutral, + notifiers: [ + TeamMembers, + FinancialApprovers, + EmailDistros('project_extension@tsco.org', 'project_revision@tsco.org'), + ], + }, + 'Active -> Finalizing Completion': { + from: [Step.Active, Step.ActiveChangedPlan], + to: Step.FinalizingCompletion, + label: 'Finalize Completion', + type: Type.Approve, + notifiers: [ + TeamMembers, + FinancialApprovers, + EmailDistros('project_extension@tsco.org', 'project_revision@tsco.org'), + ], + }, + + // Disucssing Change To Plan + 'Discussing Change To Plan -> Pending Change To Plan Approval': { + from: Step.DiscussingChangeToPlan, + to: Step.PendingChangeToPlanApproval, + label: 'Submit for Approval', + type: Type.Approve, + notifiers: [ + TeamMembers, + EmailDistros('project_extension@tsco.org', 'project_revision@tsco.org'), + ], + }, + 'Discussing Change To Plan -> Discussing Suspension': { + from: Step.DiscussingChangeToPlan, + to: Step.DiscussingSuspension, + label: 'Discuss Suspension', + type: Type.Neutral, + notifiers: [ + TeamMembers, + EmailDistros('project_extension@tsco.org', 'project_revision@tsco.org'), + ], + }, + 'Discussing Change To Plan -> Back To Active': { + from: Step.DiscussingChangeToPlan, + to: BackToActive, + label: 'Will Not Change Plan', + type: Type.Neutral, + notifiers: [ + TeamMembers, + EmailDistros('project_extension@tsco.org', 'project_revision@tsco.org'), + ], + }, + + // Pending Change To Plan Approval + 'Pending Change To Plan Approval & Confirmation -> Discussing Change To Plan': + { + from: [ + Step.PendingChangeToPlanApproval, + Step.PendingChangeToPlanConfirmation, + ], + to: Step.DiscussingChangeToPlan, + label: 'Send Back for Corrections', + type: Type.Reject, + notifiers: [ + TeamMembers, + FinancialApprovers, + EmailDistros('project_extension@tsco.org', 'project_revision@tsco.org'), + ], + }, + 'Pending Change To Plan Approval -> Pending Change To Plan Confirmation': { + from: Step.PendingChangeToPlanApproval, + to: Step.PendingChangeToPlanConfirmation, + label: 'Approve Change to Plan', + type: Type.Approve, + notifiers: [ + TeamMembers, + FinancialApprovers, + EmailDistros('project_extension@tsco.org', 'project_revision@tsco.org'), + ], + }, + 'Pending Change To Plan Approval & Confirmation -> Back To Active': { + from: [ + Step.PendingChangeToPlanApproval, + Step.PendingChangeToPlanConfirmation, + ], + to: BackToActive, + label: 'Reject Change to Plan', + type: Type.Reject, + notifiers: [ + TeamMembers, + FinancialApprovers, + EmailDistros('project_extension@tsco.org', 'project_revision@tsco.org'), + ], + }, + + // Pending Change To Plan Confirmation + 'Pending Change To Plan Confirmation -> Active Changed Plan': { + from: Step.PendingChangeToPlanConfirmation, + to: Step.ActiveChangedPlan, + label: 'Approve Change to Plan', + type: Type.Approve, + notifiers: [ + TeamMembers, + FinancialApprovers, + EmailDistros('project_extension@tsco.org', 'project_revision@tsco.org'), + ], + }, + + // Discussing Suspension + 'Discussing Suspension -> Pending Suspension Approval': { + from: Step.DiscussingSuspension, + to: Step.PendingSuspensionApproval, + label: 'Submit for Approval', + type: Type.Neutral, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + 'Discussing Suspension -> Back To Active': { + from: Step.DiscussingSuspension, + to: BackToActive, + label: 'Will Not Suspend', + type: Type.Neutral, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + + // Pending Suspension Approval + 'Pending Suspension Approval -> Discussing Suspension': { + from: Step.PendingSuspensionApproval, + to: Step.DiscussingSuspension, + label: 'Send Back for Corrections', + type: Type.Reject, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + 'Pending Suspension Approval -> Suspended': { + from: Step.PendingSuspensionApproval, + to: Step.Suspended, + label: 'Approve Suspension', + type: Type.Approve, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + 'Pending Suspension Approval -> Back To Active': { + from: Step.PendingSuspensionApproval, + to: BackToActive, + label: 'Reject Suspension', + type: Type.Reject, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + + // Suspended + 'Suspended -> Discussing Reactivation': { + from: Step.Suspended, + to: Step.DiscussingReactivation, + label: 'Discuss Reactivation', + type: Type.Neutral, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + 'Suspended -> Discussing Termination': { + from: Step.Suspended, + to: Step.DiscussingTermination, + label: 'Discuss Termination', + type: Type.Neutral, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + + // Discussing Reactivation + 'Discussing Reactivation -> Pending Reactivation Approval': { + from: Step.DiscussingReactivation, + to: Step.PendingReactivationApproval, + label: 'Submit for Approval', + type: Type.Approve, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + 'Discussing Reactivation -> Discussing Termination': { + from: Step.DiscussingReactivation, + to: Step.DiscussingTermination, + label: 'Discuss Termination', + type: Type.Neutral, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + + // Pending Reactivation Approval + 'Pending Reactivation Approval -> Active Changed Plan': { + from: Step.PendingReactivationApproval, + to: Step.ActiveChangedPlan, + label: 'Approve Reactivation', + type: Type.Approve, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + 'Pending Reactivation Approval -> Discussing Reactivation': { + from: Step.PendingReactivationApproval, + to: Step.DiscussingReactivation, + label: 'Send Back for Corrections', + type: Type.Reject, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + 'Pending Reactivation Approval -> Discussing Termination': { + from: Step.PendingReactivationApproval, + to: Step.DiscussingTermination, + label: 'Discuss Termination', + type: Type.Neutral, + notifiers: [TeamMembers, EmailDistros('project_suspension@tsco.org')], + }, + + // Discussing Termination + 'Discussing Termination -> Pending Termination Approval': { + from: Step.DiscussingTermination, + to: Step.PendingTerminationApproval, + label: 'Submit for Approval', + type: Type.Approve, + notifiers: [TeamMembers, EmailDistros('project_termination@tsco.org')], + }, + 'Discussing Termination & Pending Termination Approval -> Back To Most Recent': + { + from: [Step.DiscussingTermination, Step.PendingTerminationApproval], + to: BackToMostRecent([ + Step.Active, + Step.ActiveChangedPlan, + Step.DiscussingReactivation, + Step.Suspended, + ]), + label: 'Will Not Terminate', + type: Type.Neutral, + notifiers: [TeamMembers, EmailDistros('project_termination@tsco.org')], + }, + + // Pending Termination Approval + 'Pending Termination Approval -> Terminated': { + from: Step.PendingTerminationApproval, + to: Step.Terminated, + label: 'Approve Termination', + type: Type.Approve, + notifiers: [TeamMembers, EmailDistros('project_termination@tsco.org')], + }, + 'Pending Termination Approval -> Discussing Termination': { + from: Step.PendingTerminationApproval, + to: Step.DiscussingTermination, + label: 'Send Back for Corrections', + type: Type.Reject, + notifiers: [TeamMembers, EmailDistros('project_termination@tsco.org')], + }, + + // Finalizing Completion + 'Finalizing Completion -> Back To Active': { + from: Step.FinalizingCompletion, + to: BackToActive, + label: 'Still Working', + type: Type.Neutral, + notifiers: [TeamMembers, EmailDistros('project_closing@tsco.org')], + }, + 'Finalizing Completion -> Completed': { + from: Step.FinalizingCompletion, + to: Step.Completed, + label: 'Complete 🎉', + type: Type.Approve, + conditions: RequireOngoingEngagementsToBeFinalizingCompletion, + notifiers: [TeamMembers, EmailDistros('project_closing@tsco.org')], + }, +}); diff --git a/src/components/project/workflow/transitions/types.ts b/src/components/project/workflow/transitions/types.ts new file mode 100644 index 0000000000..7c9abbb039 --- /dev/null +++ b/src/components/project/workflow/transitions/types.ts @@ -0,0 +1,53 @@ +import { mapValues } from '@seedcompany/common'; +import { Merge } from 'type-fest'; +import * as uuid from 'uuid'; +import { ID, Many, maybeMany } from '~/common'; +import { ProjectStep as Step } from '../../dto'; +import { ProjectWorkflowTransition as PublicTransition } from '../dto/workflow-transition.dto'; +import { TransitionCondition } from './conditions'; +import { DynamicStep } from './dynamic-step'; +import { TransitionNotifier } from './notifiers'; +import type { TransitionName } from './project-transitions'; + +const PROJECT_TRANSITION_NAMESPACE = '8297b9a1-b50b-4ec9-9021-a0347424b3ec'; + +export type TransitionInput = Merge< + PublicTransition, + { + key?: ID | string; + from?: Many; + to: Step | DynamicStep; + conditions?: Many; + notifiers?: Many; + } +>; + +export type InternalTransition = Merge< + PublicTransition, + { + name: TransitionName; + from?: readonly Step[]; + to: Step | DynamicStep; + conditions?: readonly TransitionCondition[]; + notifiers?: readonly TransitionNotifier[]; + } +>; + +export const defineTransitions = ( + obj: Record, +) => + mapValues( + obj, + (name, transition): InternalTransition => ({ + name: name as TransitionName, + ...transition, + from: maybeMany(transition.from), + key: (transition.key ?? hashId(name)) as ID, + conditions: maybeMany(transition.conditions), + notifiers: maybeMany(transition.notifiers), + }), + ).asRecord; + +function hashId(name: string) { + return uuid.v5(name, PROJECT_TRANSITION_NAMESPACE); +} diff --git a/src/components/user/user.service.ts b/src/components/user/user.service.ts index c13d717ac5..1ccd32d34d 100644 --- a/src/components/user/user.service.ts +++ b/src/components/user/user.service.ts @@ -81,6 +81,13 @@ export class UserService { return this.secure(user, session); } + async readOneUnsecured( + id: ID, + session: Session | ID, + ): Promise> { + return await this.userRepo.readOne(id, session); + } + async readMany(ids: readonly ID[], session: Session) { const users = await this.userRepo.readMany(ids, session); return users.map((dto) => this.secure(dto, session)); diff --git a/src/core/email/templates/project-step-changed.template.tsx b/src/core/email/templates/project-step-changed.template.tsx index 26274fda94..fb153b7629 100644 --- a/src/core/email/templates/project-step-changed.template.tsx +++ b/src/core/email/templates/project-step-changed.template.tsx @@ -6,23 +6,35 @@ import { Section, Text, } from '@seedcompany/nestjs-email/templates'; -import { EmailNotification as StepChangeNotification } from '../../../components/project'; import { + Project, ProjectStep as Step, ProjectType as Type, } from '../../../components/project/dto'; +import { User } from '../../../components/user/dto'; import { EmailTemplate, Heading } from './base'; import { FormattedDateTime } from './formatted-date-time'; import { useFrontendUrl } from './frontend-url'; import { UserRef } from './user-ref'; +export interface ProjectStepChangedProps { + recipient: Pick< + User, + 'email' | 'displayFirstName' | 'displayLastName' | 'timezone' + >; + changedBy: Pick; + project: Pick; + previousStep?: Step; + primaryPartnerName?: string | undefined; +} + export function ProjectStepChanged({ project, changedBy, previousStep: oldStepVal, recipient, primaryPartnerName, -}: StepChangeNotification) { +}: ProjectStepChangedProps) { const projectUrl = useFrontendUrl(`/projects/${project.id}`); const projectName = project.name.value; const projectType = Type.entry(project.type); diff --git a/test/engagement.e2e-spec.ts b/test/engagement.e2e-spec.ts index e0067e25b0..ed55e6e4de 100644 --- a/test/engagement.e2e-spec.ts +++ b/test/engagement.e2e-spec.ts @@ -16,9 +16,9 @@ import { Project, ProjectStatus, ProjectStep, - ProjectStepTransition, ProjectType, } from '../src/components/project/dto'; +import { ProjectWorkflowTransition } from '../src/components/project/workflow/dto'; import { User } from '../src/components/user/dto'; import { createDirectProduct, @@ -1057,7 +1057,7 @@ describe('Engagement e2e', () => { const toCompletedTransition = projectQueryResult.project.step.transitions.find( - (t: ProjectStepTransition) => t.to === 'Completed', + (t: ProjectWorkflowTransition) => t.to === 'Completed', ); expect(projectQueryResult.project.step.value).toBe( diff --git a/test/utility/transition-project.ts b/test/utility/transition-project.ts index e4233dd1a7..a36526bf75 100644 --- a/test/utility/transition-project.ts +++ b/test/utility/transition-project.ts @@ -2,9 +2,9 @@ import { ID } from '~/common'; import { Project, ProjectStep, - ProjectStepTransition, SecuredProjectStep, } from '../../src/components/project/dto'; +import { ProjectWorkflowTransition } from '../../src/components/project/workflow/dto'; import { TestApp } from './create-app'; import { gql } from './gql-tag'; import { Raw } from './raw.type'; @@ -35,7 +35,7 @@ export const stepsFromEarlyConversationToBeforeTerminated = [ ]; type SecuredStep = SecuredProjectStep & { - transitions: ProjectStepTransition[]; + transitions: ProjectWorkflowTransition[]; }; export const changeProjectStep = async ( diff --git a/yarn.lock b/yarn.lock index 5f2a2a0cac..41a767e9a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,13 +3688,6 @@ __metadata: languageName: node linkType: hard -"@types/pako@npm:^2.0.2": - version: 2.0.2 - resolution: "@types/pako@npm:2.0.2" - checksum: 10c0/92ccd1df1b953389c7a1473ec906402354128a34bb77b1cf7ecd801b87c77103e3cad9575a846a51bc1d8d1a32b70837742b1932e67b65bdbfef80d5b2d41e90 - languageName: node - linkType: hard - "@types/parse-json@npm:^4.0.0": version: 4.0.1 resolution: "@types/parse-json@npm:4.0.1" @@ -5378,7 +5371,6 @@ __metadata: "@types/lodash": "npm:^4.14.200" "@types/luxon": "npm:^3.3.3" "@types/node": "npm:^20.12.5" - "@types/pako": "npm:^2.0.2" "@types/prismjs": "npm:^1.26.2" "@types/react": "npm:^18.2.33" "@types/stack-trace": "npm:^0.0.32" @@ -5432,7 +5424,6 @@ __metadata: nanoid: "npm:^4.0.2" neo4j-driver: "npm:^5.20.0" p-retry: "npm:^5.1.2" - pako: "npm:^2.1.0" pkg-up: "npm:^4.0.0" plur: "npm:^5.1.0" prismjs-terminal: "npm:^1.2.3" @@ -10828,13 +10819,6 @@ __metadata: languageName: node linkType: hard -"pako@npm:^2.1.0": - version: 2.1.0 - resolution: "pako@npm:2.1.0" - checksum: 10c0/8e8646581410654b50eb22a5dfd71159cae98145bd5086c9a7a816ec0370b5f72b4648d08674624b3870a521e6a3daffd6c2f7bc00fdefc7063c9d8232ff5116 - languageName: node - linkType: hard - "param-case@npm:^2.1.1": version: 2.1.1 resolution: "param-case@npm:2.1.1"