diff --git a/source/SIL.AppBuilder.Portal/common/BullJobTypes.ts b/source/SIL.AppBuilder.Portal/common/BullJobTypes.ts index 1b7e9e4c9..9931ce692 100644 --- a/source/SIL.AppBuilder.Portal/common/BullJobTypes.ts +++ b/source/SIL.AppBuilder.Portal/common/BullJobTypes.ts @@ -1,8 +1,9 @@ import { Channels } from './build-engine-api/types.js'; +import { RoleId } from './public/prisma.js'; export enum ScriptoriaJobType { Test = 'Test', - ReassignUserTasks = 'ReassignUserTasks', + ModifyUserTasks = 'ModifyUserTasks', CreateProduct = 'CreateProduct', BuildProduct = 'BuildProduct', EmailReviewers = 'EmailReviewers', @@ -19,11 +20,47 @@ export interface TestJob { time: number; } -export interface SyncUserTasksJob { - type: ScriptoriaJobType.ReassignUserTasks; - projectId: number; +export enum UserTaskOp { + Delete = 'Delete', + Update = 'Update', + Create = 'Create', + Reassign = 'Reassign' } +type UserTaskOpConfig = ( + | ({ + type: UserTaskOp.Delete | UserTaskOp.Create | UserTaskOp.Update; + } & ( + | { by: 'All' } + | { by: 'Role'; roles: RoleId[] } + | { + by: 'UserId'; + users: number[]; + } + )) + | { + type: UserTaskOp.Reassign; + by?: 'UserIdMapping' // <- This is literally just so TS doesn't complain + userMapping: { from: number; to: number }[]; + } +); + +// Using type here instead of interface for easier composition +export type ModifyUserTasksJob = ( + | { + scope: 'Project'; + projectId: number; + } + | { + scope: 'Product'; + productId: string; + } +) & { + type: ScriptoriaJobType.ModifyUserTasks; + comment?: string; // just ignore comment for Delete and Reassign + operation: UserTaskOpConfig; +}; + export interface CreateProductJob { type: ScriptoriaJobType.CreateProduct; productId: string; @@ -86,7 +123,7 @@ export type ScriptoriaJob = JobTypeMap[keyof JobTypeMap]; export type JobTypeMap = { [ScriptoriaJobType.Test]: TestJob; - [ScriptoriaJobType.ReassignUserTasks]: SyncUserTasksJob; + [ScriptoriaJobType.ModifyUserTasks]: ModifyUserTasksJob; [ScriptoriaJobType.CreateProduct]: CreateProductJob; [ScriptoriaJobType.BuildProduct]: BuildProductJob; [ScriptoriaJobType.EmailReviewers]: EmailReviewersJob; diff --git a/source/SIL.AppBuilder.Portal/common/databaseProxy/Projects.ts b/source/SIL.AppBuilder.Portal/common/databaseProxy/Projects.ts index 814ab04bd..31e18c209 100644 --- a/source/SIL.AppBuilder.Portal/common/databaseProxy/Projects.ts +++ b/source/SIL.AppBuilder.Portal/common/databaseProxy/Projects.ts @@ -1,5 +1,8 @@ import type { Prisma } from '@prisma/client'; -import { ScriptoriaJobType } from '../BullJobTypes.js'; +import { + ScriptoriaJobType, + UserTaskOp +} from '../BullJobTypes.js'; import { scriptoriaQueue } from '../bullmq.js'; import prisma from '../prisma.js'; import type { RequirePrimitive } from './utility.js'; @@ -19,7 +22,13 @@ import type { RequirePrimitive } from './utility.js'; export async function create( projectData: RequirePrimitive ): Promise { - if (!(await validateProjectBase(projectData.OrganizationId, projectData.GroupId, projectData.OwnerId))) + if ( + !(await validateProjectBase( + projectData.OrganizationId, + projectData.GroupId, + projectData.OwnerId + )) + ) return false; // No additional verification steps @@ -63,9 +72,16 @@ export async function update( // If the owner has changed, we need to reassign all the user tasks related to this project // TODO: But we don't need to change *every* user task, just the tasks associated with the owner. if (ownerId && ownerId !== existing?.OwnerId) { - scriptoriaQueue.add(ScriptoriaJobType.ReassignUserTasks, { - type: ScriptoriaJobType.ReassignUserTasks, - projectId: id + scriptoriaQueue.add(`Reassign tasks for Project #${id} (New Owner)`, { + type: ScriptoriaJobType.ModifyUserTasks, + scope: 'Project', + projectId: id, + operation: { + type: UserTaskOp.Reassign, + userMapping: [ + { from: existing.OwnerId, to: ownerId } + ] + } }); } } catch (e) { diff --git a/source/SIL.AppBuilder.Portal/common/databaseProxy/utility.ts b/source/SIL.AppBuilder.Portal/common/databaseProxy/utility.ts index 67fcc3b07..a9b175687 100644 --- a/source/SIL.AppBuilder.Portal/common/databaseProxy/utility.ts +++ b/source/SIL.AppBuilder.Portal/common/databaseProxy/utility.ts @@ -1,4 +1,5 @@ import prisma from '../prisma.js'; +import { RoleId } from '../public/prisma.js'; export type RequirePrimitive = { [K in keyof T]: Extract; @@ -36,3 +37,44 @@ export async function getOrCreateUser(profile: { } }); } + +export async function allUsersByRole(projectId: number) { + const project = await prisma.projects.findUnique({ + where: { + Id: projectId + }, + select: { + Organization: { + select: { + UserRoles: { + where: { + RoleId: RoleId.OrgAdmin + }, + select: { + UserId: true + } + } + } + }, + OwnerId: true, + Authors: { + select: { + UserId: true + } + } + } + }); + + const map = new Map(); + + map.set( + RoleId.OrgAdmin, + project.Organization.UserRoles.map((u) => u.UserId) + ); + map.set(RoleId.AppBuilder, [project.OwnerId]); + map.set( + RoleId.Author, + project.Authors.map((a) => a.UserId) + ); + return map; +} diff --git a/source/SIL.AppBuilder.Portal/common/workflow/default-workflow.ts b/source/SIL.AppBuilder.Portal/common/workflow/default-workflow.ts index fb7cc2606..851db9c89 100644 --- a/source/SIL.AppBuilder.Portal/common/workflow/default-workflow.ts +++ b/source/SIL.AppBuilder.Portal/common/workflow/default-workflow.ts @@ -307,7 +307,7 @@ export const DefaultWorkflow = setup({ entry: [ assign({ instructions: 'waiting' }), ({ context }) => { - scriptoriaQueue.add(`Create Product (${context.productId})`, { + scriptoriaQueue.add(`Create Product #${context.productId}`, { type: ScriptoriaJobType.CreateProduct, productId: context.productId }, @@ -471,7 +471,7 @@ export const DefaultWorkflow = setup({ instructions: 'waiting' }), ({ context }) => { - scriptoriaQueue.add(`Build Product (${context.productId})`, { + scriptoriaQueue.add(`Build Product #${context.productId}`, { type: ScriptoriaJobType.BuildProduct, productId: context.productId, // TODO: assign targets @@ -700,7 +700,7 @@ export const DefaultWorkflow = setup({ entry: [ assign({ instructions: 'waiting' }), ({ context }) => { - scriptoriaQueue.add(`Publish Product (${context.productId})`, { + scriptoriaQueue.add(`Publish Product #${context.productId}`, { type: ScriptoriaJobType.PublishProduct, productId: context.productId, // TODO: How should these values be determined? diff --git a/source/SIL.AppBuilder.Portal/common/workflow/index.ts b/source/SIL.AppBuilder.Portal/common/workflow/index.ts index 40b535e5d..8e5762f78 100644 --- a/source/SIL.AppBuilder.Portal/common/workflow/index.ts +++ b/source/SIL.AppBuilder.Portal/common/workflow/index.ts @@ -11,18 +11,19 @@ import DatabaseWrites from '../databaseProxy/index.js'; import { WorkflowContext, WorkflowInput, - UserRoleFeature, WorkflowConfig, - ProductType, ActionType, StateNode, WorkflowEvent, MetaFilter, - WorkflowTransitionMeta, Snapshot } from '../public/workflow.js'; import prisma from '../prisma.js'; import { RoleId, ProductTransitionType } from '../public/prisma.js'; +import { scriptoriaQueue } from '../bullmq.js'; +import { BullMQ } from '../index.js'; +import { allUsersByRole } from '../databaseProxy/utility.js'; +import { Prisma } from '@prisma/client'; /** * Wraps a workflow instance and provides methods to interact. @@ -31,37 +32,49 @@ export class Workflow { private flow: Actor | null; private productId: string; private currentState: XStateNode | null; - private URFeatures: UserRoleFeature[]; - private productType: ProductType; + private config: WorkflowConfig; - private constructor(input: WorkflowInput) { - this.productId = input.productId; - this.URFeatures = input.URFeatures; - this.productType = input.productType; + private constructor(productId: string, config: WorkflowConfig) { + this.productId = productId; + this.config = config; } /* PUBLIC METHODS */ /** Create a new workflow instance and populate the database tables. */ public static async create(productId: string, input: WorkflowConfig): Promise { - const flow = new Workflow({productId, ...input}); + const flow = new Workflow(productId, input); flow.flow = createActor(DefaultWorkflow, { inspect: (e) => { if (e.type === '@xstate.snapshot') flow.inspect(e); }, - input: {productId, ...input} + input: { productId, ...input } }); flow.flow.start(); - flow.populateTransitions(); - flow.updateUserTasks(); + DatabaseWrites.productTransitions.create({ + data: { + ProductId: productId, + DateTransition: new Date(), + TransitionType: ProductTransitionType.StartWorkflow + } + }); + scriptoriaQueue.add(`Create UserTasks for Product #${productId}`, { + type: BullMQ.ScriptoriaJobType.ModifyUserTasks, + scope: 'Product', + productId: productId, + operation: { + type: BullMQ.UserTaskOp.Create, + by: 'All' + } + }); return flow; } /** Restore from a snapshot in the database. */ public static async restore(productId: string): Promise { const snap = await Workflow.getSnapshot(productId); - const flow = new Workflow(snap.context); + const flow = new Workflow(snap.context.productId, snap.context); flow.flow = createActor(DefaultWorkflow, { snapshot: snap ? DefaultWorkflow.resolveState(snap) : undefined, inspect: (e) => { @@ -113,13 +126,28 @@ export class Workflow { /** Returns a list of valid transitions from the current state. */ public availableTransitions(): TransitionDefinition[][] { - return this.currentState !== null ? this.filterTransitions(this.currentState.on) : []; + return this.currentState !== null + ? Workflow.availableTransitionsFromNode(this.currentState, this.config) + : []; + } + + public static availableTransitionsFromName(stateName: string, config: WorkflowConfig) { + return Workflow.availableTransitionsFromNode( + DefaultWorkflow.getStateNodeById(Workflow.stateIdFromName(stateName)), + config + ); + } + + public static availableTransitionsFromNode(s: XStateNode, config: WorkflowConfig) { + return Workflow.filterTransitions(s.on, config); } /** Transform state machine definition into something more easily usable by the visualization algorithm */ public serializeForVisualization(): StateNode[] { const machine = DefaultWorkflow; - const states = Object.entries(machine.states).filter(([k, v]) => this.filterMeta(v.meta)); + const states = Object.entries(machine.states).filter(([k, v]) => + Workflow.filterMeta(this.config, v.meta) + ); const lookup = states.map((s) => s[0]); const actions: StateNode[] = []; return states @@ -127,8 +155,8 @@ export class Workflow { return { id: lookup.indexOf(k), label: k, - connections: this.filterTransitions(v.on).map((o) => { - let target = this.targetStringFromEvent(o[0]); + connections: Workflow.filterTransitions(v.on, this.config).map((o) => { + let target = Workflow.targetStringFromEvent(o[0]); if (!target) { target = o[0].eventType; lookup.push(target); @@ -155,9 +183,9 @@ export class Workflow { }), inCount: states .map(([k, v]) => { - return this.filterTransitions(v.on).map((e) => { + return Workflow.filterTransitions(v.on, this.config).map((e) => { // treat no target on transition as self target - return { from: k, to: this.targetStringFromEvent(e[0]) || k }; + return { from: k, to: Workflow.targetStringFromEvent(e[0]) || k }; }); }) .reduce((p, c) => { @@ -177,19 +205,34 @@ export class Workflow { const snap = this.flow.getSnapshot(); this.currentState = DefaultWorkflow.getStateNodeById(`#${DefaultWorkflow.id}.${snap.value}`); - if (old && this.stateName(old) !== snap.value) { + if (old && Workflow.stateName(old) !== snap.value) { await this.updateProductTransitions( event.event.userId, - this.stateName(old), - this.stateName(this.currentState), + Workflow.stateName(old), + Workflow.stateName(this.currentState), event.event.type, event.event.comment || undefined ); } await this.createSnapshot(snap.context); - if (old && this.stateName(old) !== snap.value) { - await this.updateUserTasks(event.event.comment || undefined); + if (old && Workflow.stateName(old) !== snap.value) { + // delete user tasks immediately + await DatabaseWrites.userTasks.deleteMany({ + where: { + ProductId: this.productId + } + }); + scriptoriaQueue.add(`Update UserTasks for Product #${this.productId}`, { + type: BullMQ.ScriptoriaJobType.ModifyUserTasks, + scope: 'Product', + productId: this.productId, + comment: event.event.comment || undefined, + operation: { + type: BullMQ.UserTaskOp.Update, + by: 'All' + } + }); } } @@ -201,13 +244,13 @@ export class Workflow { create: { ProductId: this.productId, Snapshot: JSON.stringify({ - value: this.stateName(this.currentState), + value: Workflow.stateName(this.currentState), context: context } as Snapshot) }, update: { Snapshot: JSON.stringify({ - value: this.stateName(this.currentState), + value: Workflow.stateName(this.currentState), context: context } as Snapshot) } @@ -215,10 +258,13 @@ export class Workflow { } /** Filter a states transitions based on provided context */ - private filterTransitions(on: TransitionDefinitionMap) { + public static filterTransitions( + on: TransitionDefinitionMap, + config: WorkflowConfig + ) { return Object.values(on) - .map((v) => v.filter((t) => this.filterMeta(t.meta))) - .filter((v) => v.length > 0 && this.filterMeta(v[0].meta)); + .map((v) => v.filter((t) => Workflow.filterMeta(config, t.meta))) + .filter((v) => v.length > 0 && Workflow.filterMeta(config, v[0].meta)); } /** @@ -229,130 +275,97 @@ export class Workflow { * - AND * - One of the provided product types matches the context */ - private filterMeta(meta?: MetaFilter) { + public static filterMeta(filter: WorkflowConfig, meta?: MetaFilter) { return ( meta === undefined || ((meta.URFeatures !== undefined - ? meta.URFeatures.filter((urf) => this.URFeatures.includes(urf)).length > 0 + ? meta.URFeatures.filter((urf) => filter.URFeatures.includes(urf)).length > 0 : true) && - (meta.productTypes !== undefined ? meta.productTypes.includes(this.productType) : true)) + (meta.productTypes !== undefined ? meta.productTypes.includes(filter.productType) : true)) ); } - /** - * Delete all tasks for a product. - * Then create new tasks based on the provided user roles: - * - OrgAdmin for administrative tasks (Product.Project.Organization.UserRoles.User where Role === OrgAdmin) - * - AppBuilder for project owner tasks (Product.Project.Owner) - * - Author for author tasks (Product.Project.Authors) - */ - private async updateUserTasks(comment?: string) { - // Delete all tasks for a product - await DatabaseWrites.userTasks.deleteMany({ - where: { - ProductId: this.productId - } - }); - - const product = await prisma.products.findUnique({ - where: { - Id: this.productId - }, - select: { - Project: { - select: { - Organization: { - select: { - UserRoles: { - where: { - RoleId: RoleId.OrgAdmin - }, - select: { - UserId: true - } - } - } - }, - OwnerId: true, - Authors: { - select: { - UserId: true - } - } - } - } - } - }); - - const uids = this.availableTransitions() - .map((t) => (t[0].meta as WorkflowTransitionMeta)?.user) - .filter((u) => u !== undefined) - .map((r) => { - switch (r) { - case RoleId.OrgAdmin: - return product.Project.Organization.UserRoles.map((u) => u.UserId); - case RoleId.AppBuilder: - return [product.Project.OwnerId]; - case RoleId.Author: - return product.Project.Authors.map((a) => a.UserId); - default: - return []; - } - }) - .reduce((p, c) => p.concat(c), []) - .filter((u, i, a) => a.indexOf(u) === i); - - const timestamp = new Date(); - - return DatabaseWrites.userTasks.createMany({ - data: uids.map((u) => ({ - UserId: u, - ProductId: this.productId, - ActivityName: this.stateName(this.currentState), - Status: this.stateName(this.currentState), - Comment: comment ?? null, - DateCreated: timestamp, - DateUpdated: timestamp - })) - }); - } - /** Create ProductTransitions record object */ - private transitionFromState(state: XStateNode) { - const t = this.filterTransitions(state.on)[0][0]; + private static transitionFromState( + state: XStateNode, + input: WorkflowInput, + users: Map + ): Prisma.ProductTransitionsCreateManyInput { + const t = Workflow.filterTransitions(state.on, input)[0][0]; + return { - ProductId: this.productId, + ProductId: input.productId, + AllowedUserNames: + t.meta.type === ActionType.User + ? Array.from( + new Set( + Array.from(users.entries()) + .filter(([role, users]) => t.meta.user === role) + .map(([role, users]) => users) + .reduce((p, c) => p.concat(c), []) + ) + ).join() + : null, TransitionType: ProductTransitionType.Activity, - InitialState: this.stateName(state), - DestinationState: this.targetStringFromEvent(t), + InitialState: Workflow.stateName(state), + DestinationState: Workflow.targetStringFromEvent(t), Command: t.meta.type !== ActionType.Auto ? t.eventType : null }; } - /** Create ProductTransitions entries for new product following the "happy" path */ - private async populateTransitions() { - // TODO: AllowedUserNames - return DatabaseWrites.productTransitions.createManyAndReturn({ - data: [ - { - ProductId: this.productId, - DateTransition: new Date(), - TransitionType: ProductTransitionType.StartWorkflow + public static async transitionEntriesFromState( + stateName: string, + input: WorkflowInput + ): Promise { + const projectId = ( + await prisma.products.findUnique({ + where: { + Id: input.productId + }, + select: { + ProjectId: true } - ].concat( - Object.entries(DefaultWorkflow.states).reduce( - (p, [k, v], i) => - p.concat( - this.filterMeta(v.meta) && - (i === 1 || - (i > 1 && p[p.length - 1]?.DestinationState === k && v.type !== 'final')) - ? [this.transitionFromState(v)] - : [] - ), - [] - ) + }) + ).ProjectId; + const start = + stateName === 'Start' + ? 1 + : Object.entries(DefaultWorkflow.states).findIndex( + ([k, v]) => v.id === Workflow.stateIdFromName(stateName) + ); + const uidsByRole = await allUsersByRole(projectId); + const users = new Map(); + ( + await Promise.all( + Array.from(uidsByRole.entries()).map(async ([role, uids]) => ({ + role: role, + users: await prisma.users.findMany({ + where: { + Id: { in: uids } + }, + select: { + Name: true + } + }) + })) ) + ).forEach((r) => { + users.set( + r.role, + r.users.map((u) => u.Name) + ); }); + return Object.entries(DefaultWorkflow.states).reduce( + (p, [k, v], i) => + p.concat( + Workflow.filterMeta(input, v.meta) && + (i === start || + (i > start && p[p.length - 1]?.DestinationState === k && v.type !== 'final')) + ? [Workflow.transitionFromState(v, input, users)] + : [] + ), + [] + ); } /** @@ -421,11 +434,15 @@ export class Workflow { } } - private stateName(s: XStateNode): string { + private static stateName(s: XStateNode): string { return s.id.replace(DefaultWorkflow.id + '.', ''); } - private targetStringFromEvent(e: TransitionDefinition): string { + private static stateIdFromName(name: string): string { + return DefaultWorkflow.id + '.' + name; + } + + private static targetStringFromEvent(e: TransitionDefinition): string { return ( e .toJSON() diff --git a/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts b/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts index 9e1078f78..8367db2a1 100644 --- a/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts +++ b/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts @@ -22,9 +22,9 @@ export class ScriptoriaWorker extends BullWorker { switch (job.data.type) { case BullMQ.ScriptoriaJobType.Test: return new Executor.Test().execute(job as Job); - case BullMQ.ScriptoriaJobType.ReassignUserTasks: - return new Executor.ReassignUserTasks().execute( - job as Job + case BullMQ.ScriptoriaJobType.ModifyUserTasks: + return new Executor.ModifyUserTasks().execute( + job as Job ); case BullMQ.ScriptoriaJobType.CreateProduct: return new Executor.CreateProduct().execute( diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts index f2085768e..82c500e26 100644 --- a/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts @@ -1,5 +1,5 @@ export { Test } from './test.js'; -export { ReassignUserTasks } from './reassignUserTasks.js'; +export { ModifyUserTasks } from './userTasks.js'; export { EmailReviewers } from './reviewers.js'; export { CreateProduct, diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/project.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/project.ts index 286d0f8e0..3838d00ce 100644 --- a/source/SIL.AppBuilder.Portal/node-server/job-executors/project.ts +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/project.ts @@ -3,14 +3,12 @@ import { prisma, DatabaseWrites, BuildEngine, - Workflow, scriptoriaQueue } from 'sil.appbuilder.portal.common'; import { Job } from 'bullmq'; import { ScriptoriaJobExecutor } from './base.js'; // TODO: What would be a meaningful return? -// TODO: Figure out why this causes errors in BuildEngine but S1 does not export class CreateProject extends ScriptoriaJobExecutor { async execute(job: Job): Promise { const projectData = await prisma.projects.findUnique({ diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/reassignUserTasks.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/reassignUserTasks.ts deleted file mode 100644 index 4f45602aa..000000000 --- a/source/SIL.AppBuilder.Portal/node-server/job-executors/reassignUserTasks.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { BullMQ, prisma, DatabaseWrites } from 'sil.appbuilder.portal.common'; -import { RoleId } from 'sil.appbuilder.portal.common/prisma'; -import { Job } from 'bullmq'; -import { ScriptoriaJobExecutor } from './base.js'; - -export class ReassignUserTasks extends ScriptoriaJobExecutor { - async execute(job: Job): Promise { - // TODO: Noop - // Should - // Clear preexecuteentries (product transition steps) - // Remove relevant user tasks - // Create new user tasks (send notifications) - // Recreate preexecute entries - const products = await prisma.products.findMany({ - where: { - ProjectId: job.data.projectId - }, - include: { - ProductTransitions: true - } - }); - for (const product of products) { - // Clear PreExecuteEntries - await DatabaseWrites.productTransitions.deleteMany({ - where: { - WorkflowUserId: null, - ProductId: product.Id, - DateTransition: null - } - }); - // Clear existing UserTasks - await DatabaseWrites.userTasks.deleteMany({ - where: { - ProductId: product.Id - } - }); - // Create tasks for all users that could perform this activity - // TODO: this comes from dwkit GetAllActorsFor(Direct|Reverse)CommandTransitions - const organizationId = ( - await prisma.projects.findUnique({ - where: { - Id: job.data.projectId - }, - include: { - Organization: true - } - }) - ).OrganizationId; - // All users that own the project or are org admins - const allUsersWithAction = await prisma.users.findMany({ - where: { - OR: [ - { - UserRoles: { - some: { - OrganizationId: organizationId, - RoleId: RoleId.OrgAdmin - } - } - }, - { - Projects: { - some: { - Id: job.data.projectId - } - } - } - ] - } - }); - // TODO: DWKit: Need ActivityName and Status from dwkit implementation - const createdTasks = allUsersWithAction.map((user) => ({ - UserId: user.Id, - ProductId: product.Id, - ActivityName: null, - Status: null - })); - await DatabaseWrites.userTasks.createMany({ - data: createdTasks - }); - for (const task of createdTasks) { - // Send notification for the new task - // TODO - // sendNotification(task); - } - // TODO: DWKit: CreatePreExecuteEntries - } - - return ( - await prisma.userTasks.findMany({ - where: { - Product: { - ProjectId: job.data.projectId - } - } - }) - ).length; - } -} diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/userTasks.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/userTasks.ts new file mode 100644 index 000000000..77a500ba2 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/userTasks.ts @@ -0,0 +1,165 @@ +import { BullMQ, prisma, DatabaseWrites, Workflow } from 'sil.appbuilder.portal.common'; +import { RoleId } from 'sil.appbuilder.portal.common/prisma'; +import { Job } from 'bullmq'; +import { ScriptoriaJobExecutor } from './base.js'; +import { Prisma } from '@prisma/client'; +import { ActionType } from 'sil.appbuilder.portal.common/workflow'; + +export class ModifyUserTasks extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + const products = await prisma.products.findMany({ + where: { + Id: job.data.scope === 'Product' ? job.data.productId : undefined, + ProjectId: job.data.scope === 'Project' ? job.data.projectId : undefined + }, + select: { + Id: true, + ProjectId: true, + ProductTransitions: true + } + }); + job.updateProgress(10); + const projectId = job.data.scope === 'Project' ? job.data.projectId : products[0].ProjectId; + + const productIds = products.map((p) => p.Id); + + let createdTasks: Prisma.UserTasksCreateManyInput[] = []; + + // Clear PreExecuteEntries + await DatabaseWrites.productTransitions.deleteMany({ + where: { + WorkflowUserId: null, + ProductId: { in: productIds }, + DateTransition: null + } + }); + + job.updateProgress(20); + + if (job.data.operation.type === BullMQ.UserTaskOp.Reassign) { + const from = job.data.operation.userMapping.map((u) => u.from); + const to = job.data.operation.userMapping.map((u) => u.to); + + const timestamp = new Date(); + + await Promise.all( + from.map((u, i) => + DatabaseWrites.userTasks.updateMany({ + where: { + UserId: u + }, + data: { + UserId: to[i], + DateUpdated: timestamp + } + }) + ) + ); + job.updateProgress(40); + for (let i = 0; i < products.length; i++) { + const snap = await Workflow.getSnapshot(products[i].Id); + job.updateProgress(40 + ((i + 0.2) * 40) / products.length); + await DatabaseWrites.productTransitions.createMany({ + data: await Workflow.transitionEntriesFromState(snap.value, snap.context) + }); + job.updateProgress(40 + ((i + 1) * 40) / products.length); + } + job.updateProgress(80); + // Just in case the user had already existing tasks before the reassignment + createdTasks = await prisma.userTasks.findMany({ + where: { + UserId: { in: to }, + DateUpdated: { + gte: timestamp + } + } + }); + job.updateProgress(90); + } else { + job.updateProgress(25); + const allUsers = await DatabaseWrites.utility.allUsersByRole(projectId); + job.updateProgress(30); + if (job.data.operation.type !== BullMQ.UserTaskOp.Create) { + // Clear existing UserTasks + await DatabaseWrites.userTasks.deleteMany({ + where: { + ProductId: { in: productIds }, + UserId: + job.data.operation.by === 'All' || + job.data.operation.type === BullMQ.UserTaskOp.Update + ? undefined + : { + in: + job.data.operation.by === 'UserId' + ? job.data.operation.users + : Array.from( + new Set( + Array.from(allUsers.entries()) + .filter( + ([role, uids]) => + job.data.operation.by === 'Role' && + job.data.operation.roles.includes(role) + ) + .map(([role, uids]) => uids) + .reduce((p, c) => p.concat(c), []) + ) + ) + } + } + }); + if (job.data.operation.type === BullMQ.UserTaskOp.Delete) { + job.updateProgress(90); + } else { + job.updateProgress(40); + } + } + if (job.data.operation.type !== BullMQ.UserTaskOp.Delete) { + for (let i = 0; i < products.length; i++) { + const product = products[i]; + // Create tasks for all users that could perform this activity + const snap = await Workflow.getSnapshot(product.Id); + const roles = ( + Workflow.availableTransitionsFromName(snap.value, snap.context) + .filter((t) => t[0].meta.type === ActionType.User) + .map((t) => t[0].meta.user) as RoleId[] + ).filter((r) => job.data.operation.by !== 'Role' || job.data.operation.roles.includes(r)); + job.updateProgress(40 + ((i + 0.33) * 40) / products.length); + createdTasks = Array.from( + new Set( + Array.from(allUsers.entries()) + .filter(([role, uids]) => roles.includes(role)) + .map(([role, uids]) => uids) + .reduce((p, c) => p.concat(c), []) + ) + ) + .filter( + (u) => job.data.operation.by !== 'UserId' || job.data.operation.users.includes(u) + ) + .map((user) => ({ + UserId: user, + ProductId: product.Id, + ActivityName: snap.value, + Status: snap.value, + Comment: job.data.comment + })); + await DatabaseWrites.userTasks.createMany({ + data: createdTasks + }); + job.updateProgress(40 + ((i + 0.67) * 40) / products.length); + await DatabaseWrites.productTransitions.createMany({ + data: await Workflow.transitionEntriesFromState(snap.value, snap.context) + }); + job.updateProgress(40 + ((i + 1) * 40) / products.length); + } + job.updateProgress(80); + } + } + + for (const task of createdTasks) { + // TODO: Send notification for the new task + // sendNotification(task); + } + job.updateProgress(100); + return createdTasks.length; + } +} diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts index 6d25e49e5..f54a72c58 100644 --- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts +++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts @@ -296,7 +296,7 @@ export const actions = { select: { Workflow: { select: { - // TODO: RequiredAdminLevel and ProductType should be directly in the database instead of calling a helper function + // TODO: UserRoleFeatures and ProductType should be directly in the database instead of calling a helper function Id: true, Type: true }