diff --git a/source/SIL.AppBuilder.Portal/common/public/workflow.ts b/source/SIL.AppBuilder.Portal/common/public/workflow.ts index d5bb1388a..c67e1746f 100644 --- a/source/SIL.AppBuilder.Portal/common/public/workflow.ts +++ b/source/SIL.AppBuilder.Portal/common/public/workflow.ts @@ -1,7 +1,8 @@ import type { AnyEventObject, StateMachineDefinition, - TransitionDefinition + TransitionDefinition, + StateNode as XStateNode } from 'xstate'; export type WorkflowContext = { @@ -27,6 +28,10 @@ export type StateNode = { final: boolean; }; +export function stateName(s: XStateNode, machineId: string) { + return s.id.replace(machineId + '.', ''); +} + export function targetStringFromEvent( e: TransitionDefinition[], machineId: string @@ -69,4 +74,4 @@ export function transform(machine: StateMachineDefinition): }; }); return a; -} \ No newline at end of file +} diff --git a/source/SIL.AppBuilder.Portal/common/workflow/db.ts b/source/SIL.AppBuilder.Portal/common/workflow/db.ts index 4c0e5ca72..83c88fb92 100644 --- a/source/SIL.AppBuilder.Portal/common/workflow/db.ts +++ b/source/SIL.AppBuilder.Portal/common/workflow/db.ts @@ -1,8 +1,8 @@ import DatabaseWrites from '../databaseProxy/index.js'; import { prisma } from '../index.js'; -import { WorkflowContext } from '../public/workflow.js'; -import { AnyStateMachine } from 'xstate'; -import { RoleId } from '../public/prisma.js'; +import { WorkflowContext, targetStringFromEvent, stateName } from '../public/workflow.js'; +import { AnyStateMachine, StateNode } from 'xstate'; +import { RoleId, ProductTransitionType } from '../public/prisma.js'; export type Snapshot = { value: string; @@ -119,3 +119,115 @@ export async function updateUserTasks( })) }); } + +function transitionFromState(state: StateNode, machineId: string, productId: string) { + console.log(state); + return { + ProductId: productId, + TransitionType: ProductTransitionType.Activity, + InitialState: stateName(state, machineId), + DestinationState: targetStringFromEvent(Object.values(state.on)[0], machineId), + Command: + Object.keys(state.on)[0].split(':')[1] !== 'Auto' + ? Object.keys(state.on)[0].split(':')[0] + : null + }; +} + +async function populateTransitions(machine: AnyStateMachine, productId: string) { + return DatabaseWrites.productTransitions.createManyAndReturn({ + data: [ + { + ProductId: productId, + DateTransition: new Date(), + TransitionType: ProductTransitionType.StartWorkflow + } + ].concat( + Object.entries(machine.states).reduce( + (p, [k, v], i) => + p.concat( + i === 1 || (i > 1 && p[p.length - 1]?.DestinationState === k && v.type !== 'final') + ? [transitionFromState(v, machine.id, productId)] + : [] + ), + [] + ) + ) + }); +} + +/** + * Get all product transitions for a product. + * If there are none, create new ones based on main sequence (i.e. no Author steps) + * If sequence matching params exists, but no timestamp, update + * Otherwise, create. + */ +export async function updateProductTransitions( + machine: AnyStateMachine, + productId: string, + userId: number | null, + initialState: string, + destinationState: string, + command?: string, + comment?: string +) { + const transitions = await prisma.productTransitions.count({ + where: { + ProductId: productId + } + }); + if (transitions <= 0) { + await populateTransitions(machine, productId); + } + const transition = await prisma.productTransitions.findFirst({ + where: { + ProductId: productId, + InitialState: initialState, + DestinationState: destinationState, + DateTransition: null + }, + select: { + Id: true + } + }); + + const user = userId + ? await prisma.users.findUnique({ + where: { + Id: userId + }, + select: { + Name: true, + WorkflowUserId: true + } + }) + : null; + + if (transition) { + DatabaseWrites.productTransitions.update({ + where: { + Id: transition.Id + }, + data: { + WorkflowUserId: user?.WorkflowUserId ?? null, + AllowedUserNames: user?.Name ?? null, + Command: command ?? null, + DateTransition: new Date(), + Comment: comment ?? null + } + }); + } else { + await DatabaseWrites.productTransitions.create({ + data: { + ProductId: productId, + WorkflowUserId: user?.WorkflowUserId ?? null, + AllowedUserNames: user?.Name ?? null, + InitialState: initialState, + DestinationState: destinationState, + Command: command ?? null, + DateTransition: new Date(), + Comment: comment ?? null + } + }); + } +} diff --git a/source/SIL.AppBuilder.Portal/common/workflow/no-admin-s3.ts b/source/SIL.AppBuilder.Portal/common/workflow/no-admin-s3.ts index 0c1eed58a..4c7876941 100644 --- a/source/SIL.AppBuilder.Portal/common/workflow/no-admin-s3.ts +++ b/source/SIL.AppBuilder.Portal/common/workflow/no-admin-s3.ts @@ -1,7 +1,7 @@ import { setup, assign } from 'xstate'; import DatabaseWrites from '../databaseProxy/index.js'; import { WorkflowContext, WorkflowInput } from '../public/workflow.js'; -import { createSnapshot, updateUserTasks } from './db.js'; +import { createSnapshot, updateUserTasks, updateProductTransitions } from './db.js'; import { RoleId } from '../public/prisma.js'; //later: update snapshot on state exits (define a function to do it), store instance id in context @@ -71,8 +71,8 @@ export const NoAdminS3 = setup({ target: 'Verify and Publish' }, { - guard: ({ context }) => context.start === 'Publish Product', - target: 'Publish Product' + guard: ({ context }) => context.start === 'Product Publish', + target: 'Product Publish' }, { guard: ({ context }) => context.start === 'Published', @@ -101,6 +101,17 @@ export const NoAdminS3 = setup({ ], on: { 'Product Created:Auto': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Product Creation', + 'App Builder Configuration', + null, + event.comment + ); + }, target: 'App Builder Configuration' } } @@ -113,16 +124,41 @@ export const NoAdminS3 = setup({ }), ({ context, event }) => { createSnapshot('App Builder Configuration', context); - updateUserTasks(context.productId, [RoleId.AppBuilder], 'App Builder Configuration', event.comment); + updateUserTasks( + context.productId, + [RoleId.AppBuilder], + 'App Builder Configuration', + event.comment + ); } ], on: { 'Continue:Owner': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'App Builder Configuration', + 'Product Build', + 'Continue', + event.comment + ); + }, target: 'Product Build' }, 'Transfer to Authors:Owner': { - actions: () => { - console.log('Transferring to Authors') + actions: ({ context, event }) => { + console.log('Transferring to Authors'); + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'App Builder Configuration', + 'Author Configuration', + 'Transfer to Authors', + event.comment + ); }, //later: guard project has authors target: 'Author Configuration' @@ -137,14 +173,41 @@ export const NoAdminS3 = setup({ }), ({ context, event }) => { createSnapshot('Author Configuration', context); - updateUserTasks(context.productId, [RoleId.AppBuilder, RoleId.Author], 'Author Configuration', event.comment); - }, + updateUserTasks( + context.productId, + [RoleId.AppBuilder, RoleId.Author], + 'Author Configuration', + event.comment + ); + } ], on: { 'Continue:Author': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Author Configuration', + 'App Builder Configuration', + 'Continue', + event.comment + ); + }, target: 'App Builder Configuration' }, 'Take Back:Owner': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Author Configuration', + 'App Builder Configuration', + 'Take Back', + event.comment + ); + }, target: 'App Builder Configuration' } } @@ -157,15 +220,42 @@ export const NoAdminS3 = setup({ }), ({ context, event }) => { createSnapshot('Synchronize Data', context); - updateUserTasks(context.productId, [RoleId.AppBuilder], 'Synchronize Data', event.comment); + updateUserTasks( + context.productId, + [RoleId.AppBuilder], + 'Synchronize Data', + event.comment + ); } ], on: { 'Continue:Owner': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Synchronize Data', + 'Product Build', + 'Continue', + event.comment + ); + }, target: 'Product Build' }, 'Transfer to Authors:Owner': { //later: guard project has authors + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Synchronize Data', + 'Author Download', + 'Transfer to Authors', + event.comment + ); + }, target: 'Author Download' } } @@ -178,14 +268,41 @@ export const NoAdminS3 = setup({ }), ({ context, event }) => { createSnapshot('Author Download', context); - updateUserTasks(context.productId, [RoleId.AppBuilder, RoleId.Author], 'Author Download', event.comment); + updateUserTasks( + context.productId, + [RoleId.AppBuilder, RoleId.Author], + 'Author Download', + event.comment + ); } ], on: { 'Continue:Author': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Author Download', + 'Author Upload', + 'Continue', + event.comment + ); + }, target: 'Author Upload' }, 'Take Back:Owner': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Author Download', + 'Synchronize Data', + 'Take Back', + event.comment + ); + }, target: 'Synchronize Data' } } @@ -198,14 +315,41 @@ export const NoAdminS3 = setup({ }), ({ context, event }) => { createSnapshot('Author Upload', context); - updateUserTasks(context.productId, [RoleId.AppBuilder, RoleId.Author], 'Author Upload', event.comment); + updateUserTasks( + context.productId, + [RoleId.AppBuilder, RoleId.Author], + 'Author Upload', + event.comment + ); } ], on: { 'Continue:Author': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Author Upload', + 'Synchronize Data', + 'Continue', + event.comment + ); + }, target: 'Synchronize Data' }, 'Take Back:Owner': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Author Upload', + 'Synchronize Data', + 'Take Back', + event.comment + ); + }, target: 'Synchronize Data' } } @@ -226,9 +370,31 @@ export const NoAdminS3 = setup({ ], on: { 'Build Successful:Auto': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Product Build', + 'Verify and Publish', + null, + event.comment + ); + }, target: 'Verify and Publish' }, 'Build Failed:Auto': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Product Build', + 'Synchronize Data', + null, + event.comment + ); + }, target: 'Synchronize Data' } } @@ -243,7 +409,12 @@ export const NoAdminS3 = setup({ }), ({ context, event }) => { createSnapshot('Verify and Publish', context); - updateUserTasks(context.productId, [RoleId.AppBuilder], 'Verify and Publish', event.comment); + updateUserTasks( + context.productId, + [RoleId.AppBuilder], + 'Verify and Publish', + event.comment + ); } ], exit: assign({ @@ -251,14 +422,47 @@ export const NoAdminS3 = setup({ includeArtifacts: false }), on: { + 'Approve:Owner': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Verify and Publish', + 'Publish Product', + 'Approve', + event.comment + ); + }, + target: 'Product Publish' + }, 'Reject:Owner': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Verify and Publish', + 'Synchronize Data', + 'Reject', + event.comment + ); + }, target: 'Synchronize Data' }, - 'Approve:Owner': { - target: 'Publish Product' - }, 'Email Reviewers:Owner': { //later: guard project has reviewers + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Verify and Publish', + 'Email Reviewers', + 'Email Reviewers', + event.comment + ); + }, target: 'Email Reviewers' } } @@ -276,11 +480,22 @@ export const NoAdminS3 = setup({ ], on: { 'Default:Auto': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Email Reviewers', + 'Verify and Publish', + null, + event.comment + ); + }, target: 'Verify and Publish' } } }, - 'Publish Product': { + 'Product Publish': { entry: [ assign({ instructions: 'waiting' }), ({ context }) => { @@ -293,9 +508,31 @@ export const NoAdminS3 = setup({ ], on: { 'Publish Completed:Auto': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Product Publish', + 'Published', + null, + event.comment + ); + }, target: 'Published' }, 'Publish Failed:Auto': { + actions: ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + event.userId, + 'Product Publish', + 'Synchronize Data', + null, + event.comment + ); + }, target: 'Synchronize Data' } } @@ -319,7 +556,18 @@ export const NoAdminS3 = setup({ actions: [ assign({ start: ({ event }) => event.target - }) + }), + ({ context, event }) => { + updateProductTransitions( + NoAdminS3, + context.productId, + null, + event.previous, + event.target, + null, + event.comment + ); + } ], target: '.Start' } diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/workflows/[product_id]/+page.server.ts b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/workflows/[product_id]/+page.server.ts index 8e44416de..236607591 100644 --- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/workflows/[product_id]/+page.server.ts +++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/workflows/[product_id]/+page.server.ts @@ -75,7 +75,7 @@ export const actions = { actor.start(); - actor.send({ type: 'Jump To', target: form.data.state }); + actor.send({ type: 'Jump To', target: form.data.state, previous: actor.getSnapshot().value }); return { form, ok: true }; } diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.server.ts b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.server.ts index 69094c2e1..677b8c39c 100644 --- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.server.ts +++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.server.ts @@ -131,19 +131,23 @@ export const load = (async ({ params, url, locals }) => { const fields = snap.context.includeFields; return { - actions: Object.keys(NoAdminS3.getStateNodeById(`${NoAdminS3.id}.${snap.value}`).on).filter((a) => { - if (session?.user.userId === undefined) return false; - switch(a.split(":")[1]) { - case 'Owner': - return session.user.userId === product?.Project.Owner.Id; - case 'Author': - return product?.Project.Authors.map((a) => a.UserId).includes(session.user.userId); - case 'Admin': - return product?.Project.Organization.UserRoles.map((u) => u.UserId).includes(session.user.userId); - default: - return false; - } - }).map((a) => a.split(":")), + actions: Object.keys(NoAdminS3.getStateNodeById(`${NoAdminS3.id}.${snap.value}`).on) + .filter((a) => { + if (session?.user.userId === undefined) return false; + switch (a.split(':')[1]) { + case 'Owner': + return session.user.userId === product?.Project.Owner.Id; + case 'Author': + return product?.Project.Authors.map((a) => a.UserId).includes(session.user.userId); + case 'Admin': + return product?.Project.Organization.UserRoles.map((u) => u.UserId).includes( + session.user.userId + ); + default: + return false; + } + }) + .map((a) => a.split(':')), taskTitle: snap.value, instructions: snap.context.instructions, //filter fields/files/reviewers based on task once workflows are implemented @@ -173,8 +177,9 @@ export const load = (async ({ params, url, locals }) => { }) satisfies PageServerLoad; export const actions = { - default: async ({ request, params }) => { + default: async ({ request, params, locals }) => { // TODO: permission check + const session = await locals.auth(); const form = await superValidate(request, valibot(sendActionSchema)); if (!form.valid) return fail(400, { form, ok: false }); @@ -189,8 +194,10 @@ export const actions = { //double check that state matches current snapshot if (form.data.state === actor.getSnapshot().value) { - const action = Object.keys(NoAdminS3.getStateNodeById(`${NoAdminS3.id}.${actor.getSnapshot().value}`).on).filter((a) => a.split(":")[0] === form.data.action); - actor.send({ type: action[0], comment: form.data.comment }); + const action = Object.keys( + NoAdminS3.getStateNodeById(`${NoAdminS3.id}.${actor.getSnapshot().value}`).on + ).filter((a) => a.split(':')[0] === form.data.action); + actor.send({ type: action[0], comment: form.data.comment, userId: session?.user.userId }); } redirect(302, '/tasks');