diff --git a/source/SIL.AppBuilder.Portal/common/BullJobTypes.ts b/source/SIL.AppBuilder.Portal/common/BullJobTypes.ts index 7f6906c57..5608fc483 100644 --- a/source/SIL.AppBuilder.Portal/common/BullJobTypes.ts +++ b/source/SIL.AppBuilder.Portal/common/BullJobTypes.ts @@ -1,16 +1,167 @@ +import { Channels } from './build-engine-api/types.js'; +import { RoleId } from './public/prisma.js'; + export enum ScriptoriaJobType { + // Build Tasks + Build_Product = 'Build Product', + Build_Check = 'Check Product Build', + // Notification Tasks + Notify_Reviewers = 'Notify Reviewers', + // Product Tasks + Product_Create = 'Create Product', + Product_Delete = 'Delete Product', + // Project Tasks + Project_Create = 'Create Project', + Project_Check = 'Check Project Creation', + // Publishing Tasks + Publish_Product = 'Publish Product', + Publish_Check = 'Check Product Publish', + // System Tasks + System_CheckStatuses = 'Check System Statuses', + // Test Test = 'Test', - ReassignUserTasks = 'ReassignUserTasks' + // Other Tasks (for now) + UserTasks_Modify = 'Modify UserTasks' } -export interface TestJob { +export namespace Build { + export interface Product { + type: ScriptoriaJobType.Build_Product; + productId: string; + targets?: string; + environment: { [key: string]: string }; + } + + export interface Check { + type: ScriptoriaJobType.Build_Check; + organizationId: number; + productId: string; + jobId: number; + buildId: number; + productBuildId: number; + } +} + +export namespace Notify { + export interface Reviewers { + type: ScriptoriaJobType.Notify_Reviewers; + productId: string; + } +} + +export namespace Product { + export interface Create { + type: ScriptoriaJobType.Product_Create; + productId: string; + } + export interface Delete { + type: ScriptoriaJobType.Product_Delete; + organizationId: number; + workflowJobId: number; + } +} + +export namespace Project { + export interface Create { + type: ScriptoriaJobType.Project_Create; + projectId: number; + } + + export interface Check { + type: ScriptoriaJobType.Project_Check; + workflowProjectId: number; + organizationId: number; + projectId: number; + } +} + +export namespace UserTasks { + export enum OpType { + Delete = 'Delete', + Update = 'Update', + Create = 'Create', + Reassign = 'Reassign' + } + + type Config = + | ({ + type: OpType.Delete | OpType.Create | OpType.Update; + } & ( + | { by: 'All' } + | { by: 'Role'; roles: RoleId[] } + | { + by: 'UserId'; + users: number[]; + } + )) + | { + type: OpType.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 Modify = ( + | { + scope: 'Project'; + projectId: number; + } + | { + scope: 'Product'; + productId: string; + } + ) & { + type: ScriptoriaJobType.UserTasks_Modify; + comment?: string; // just ignore comment for Delete and Reassign + operation: Config; + }; +} + +export namespace Publish { + export interface Product { + type: ScriptoriaJobType.Publish_Product; + productId: string; + channel: Channels; + targets: string; + environment: { [key: string]: any }; + } + + export interface Check { + type: ScriptoriaJobType.Publish_Check; + organizationId: number; + productId: string; + jobId: number; + buildId: number; + releaseId: number; + publicationId: number; + } +} + +export namespace System { + export interface CheckStatuses { + type: ScriptoriaJobType.System_CheckStatuses; + } +} + +export interface Test { type: ScriptoriaJobType.Test; time: number; } -export interface SyncUserTasksJob { - type: ScriptoriaJobType.ReassignUserTasks; - projectId: number; -} +export type ScriptoriaJob = JobTypeMap[keyof JobTypeMap]; -export type ScriptoriaJob = TestJob | SyncUserTasksJob; +export type JobTypeMap = { + [ScriptoriaJobType.Build_Product]: Build.Product; + [ScriptoriaJobType.Build_Check]: Build.Check; + [ScriptoriaJobType.Notify_Reviewers]: Notify.Reviewers; + [ScriptoriaJobType.Product_Create]: Product.Create; + [ScriptoriaJobType.Product_Delete]: Product.Delete; + [ScriptoriaJobType.Project_Create]: Project.Create; + [ScriptoriaJobType.Project_Check]: Project.Check; + [ScriptoriaJobType.Publish_Product]: Publish.Product; + [ScriptoriaJobType.Publish_Check]: Publish.Check; + [ScriptoriaJobType.UserTasks_Modify]: UserTasks.Modify; + [ScriptoriaJobType.System_CheckStatuses]: System.CheckStatuses; + [ScriptoriaJobType.Test]: Test; + // Add more mappings here as needed +}; diff --git a/source/SIL.AppBuilder.Portal/common/build-engine-api/index.ts b/source/SIL.AppBuilder.Portal/common/build-engine-api/index.ts new file mode 100644 index 000000000..4bc332396 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/common/build-engine-api/index.ts @@ -0,0 +1,2 @@ +export * as Types from './types.js'; +export * as Requests from './requests.js'; diff --git a/source/SIL.AppBuilder.Portal/common/build-engine-api/requests.ts b/source/SIL.AppBuilder.Portal/common/build-engine-api/requests.ts new file mode 100644 index 000000000..198f3a9be --- /dev/null +++ b/source/SIL.AppBuilder.Portal/common/build-engine-api/requests.ts @@ -0,0 +1,223 @@ +import prisma from '../prisma.js'; +import * as Types from './types.js'; + +export async function request( + resource: string, + auth: Types.Auth, + method: string = 'GET', + body?: any +) { + try { + const { url, token } = auth.type === 'query' ? await getURLandToken(auth.organizationId) : auth; + return await fetch(`${url}/${resource}`, { + method: method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'Content-Type': body ? 'application/json' : undefined + }, + body: body ? JSON.stringify(body) : undefined + }); + } catch (e) { + return new Response( + JSON.stringify({ + responseType: 'error', + name: '', + status: 500, + code: 500, + message: typeof e === 'string' ? e.toUpperCase() : e instanceof Error ? e.message : e, + type: '' + } as Types.ErrorResponse), + { + status: 500, + statusText: 'Internal Server Error' + } + ); + } +} +export async function getURLandToken(organizationId: number) { + const org = await prisma.organizations.findUnique({ + where: { + Id: organizationId + }, + select: { + BuildEngineUrl: true, + BuildEngineApiAccessToken: true, + UseDefaultBuildEngine: true + } + }); + + return org.UseDefaultBuildEngine + ? { + url: process.env.DEFAULT_BUILDENGINE_URL, + token: process.env.DEFAULT_BUILDENGINE_API_ACCESS_TOKEN + } + : { + url: org.BuildEngineUrl, + token: org.BuildEngineApiAccessToken + }; +} + +export async function systemCheck(auth: Types.Auth) { + const res = await request('system/check', auth); + return res.ok + ? ({ status: res.status} as Types.StatusResponse) + : ((await res.json()) as Types.ErrorResponse); +} + +export async function createProject( + auth: Types.Auth, + project: Types.ProjectConfig +): Promise { + const res = await request('project', auth, 'POST', project); + return res.ok + ? ((await res.json()) as Types.ProjectResponse) + : ((await res.json()) as Types.ErrorResponse); +} +export async function getProjects( + auth: Types.Auth +): Promise { + const res = await request('project', auth); + return res.ok + ? ((await res.json()) as Types.ProjectResponse[]) + : ((await res.json()) as Types.ErrorResponse); +} +export async function getProject( + auth: Types.Auth, + projectId: number +): Promise { + const res = await request(`project/${projectId}`, auth); + return res.ok + ? ((await res.json()) as Types.ProjectResponse) + : ((await res.json()) as Types.ErrorResponse); +} +export async function deleteProject( + auth: Types.Auth, + projectId: number +): Promise { + const res = await request(`project/${projectId}`, auth, 'DELETE'); + return res.ok + ? { responseType: 'delete', status: res.status } + : ((await res.json()) as Types.ErrorResponse); +} + +export async function getProjectAccessToken( + auth: Types.Auth, + projectId: number, + token: Types.TokenConfig +): Promise { + const res = await request(`project/${projectId}/token`, auth, 'POST', token); + return res.ok + ? ((await res.json()) as Types.TokenResponse) + : ((await res.json()) as Types.ErrorResponse); +} + +export async function createJob( + auth: Types.Auth, + job: Types.JobConfig +): Promise { + const res = await request('job', auth, 'POST', job); + return res.ok + ? ((await res.json()) as Types.JobResponse) + : ((await res.json()) as Types.ErrorResponse); +} +export async function getJobs( + auth: Types.Auth +): Promise { + const res = await request('job', auth); + return res.ok + ? ((await res.json()) as Types.JobResponse[]) + : ((await res.json()) as Types.ErrorResponse); +} +export async function getJob( + auth: Types.Auth, + jobId: number +): Promise { + const res = await request(`job/${jobId}`, auth); + return res.ok + ? ((await res.json()) as Types.JobResponse) + : ((await res.json()) as Types.ErrorResponse); +} +export async function deleteJob( + auth: Types.Auth, + jobId: number +): Promise { + const res = await request(`job/${jobId}`, auth, 'DELETE'); + return res.ok + ? { responseType: 'delete', status: res.status } + : ((await res.json()) as Types.ErrorResponse); +} + +export async function createBuild( + auth: Types.Auth, + jobId: number, + build: Types.BuildConfig +): Promise { + const res = await request(`job/${jobId}/build`, auth, 'POST', build); + return res.ok + ? ((await res.json()) as Types.BuildResponse) + : ((await res.json()) as Types.ErrorResponse); +} +export async function getBuild( + auth: Types.Auth, + jobId: number, + buildId: number +): Promise { + const res = await request(`job/${jobId}/build/${buildId}`, auth); + return res.ok + ? ((await res.json()) as Types.BuildResponse) + : ((await res.json()) as Types.ErrorResponse); +} +export async function getBuilds( + auth: Types.Auth, + jobId: number +): Promise { + const res = await request(`job/${jobId}/build`, auth); + return res.ok + ? ((await res.json()) as Types.BuildResponse[]) + : ((await res.json()) as Types.ErrorResponse); +} +export async function deleteBuild( + auth: Types.Auth, + jobId: number, + buildId: number +): Promise { + const res = await request(`job/${jobId}/build/${buildId}`, auth, 'DELETE'); + return res.ok + ? { responseType: 'delete', status: res.status } + : ((await res.json()) as Types.ErrorResponse); +} + +export async function createRelease( + auth: Types.Auth, + jobId: number, + buildId: number, + release: Types.ReleaseConfig +): Promise { + const res = await request(`job/${jobId}/build/${buildId}`, auth, 'PUT', release); + return res.ok + ? ((await res.json()) as Types.ReleaseResponse) + : ((await res.json()) as Types.ErrorResponse); +} +export async function getRelease( + auth: Types.Auth, + jobId: number, + buildId: number, + releaseId: number +): Promise { + const res = await request(`job/${jobId}/build/${buildId}/release/${releaseId}`, auth); + return res.ok + ? ((await res.json()) as Types.ReleaseResponse) + : ((await res.json()) as Types.ErrorResponse); +} +export async function deleteRelease( + auth: Types.Auth, + jobId: number, + buildId: number, + releaseId: number +): Promise { + const res = await request(`job/${jobId}/build/${buildId}/release/${releaseId}`, auth, 'DELETE'); + return res.ok + ? { responseType: 'delete', status: res.status } + : ((await res.json()) as Types.ErrorResponse); +} diff --git a/source/SIL.AppBuilder.Portal/common/build-engine-api/types.ts b/source/SIL.AppBuilder.Portal/common/build-engine-api/types.ts new file mode 100644 index 000000000..f035a0c75 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/common/build-engine-api/types.ts @@ -0,0 +1,131 @@ +export type Auth = { + type: 'query'; + organizationId: number; +} | { + type: 'provided'; + url: string; + token: string; +} + +export type Response = + | ErrorResponse + | ProjectResponse + | TokenResponse + | BuildResponse + | ReleaseResponse + | StatusResponse; + +export type ErrorResponse = { + responseType: 'error'; + name: string; + message: string; + code: number; + status: number; + type: string; +}; + +export type StatusResponse = { + responseType: 'status'; + status: number; +} + +export type DeleteResponse = { + responseType: 'delete'; + status: number; +}; + +type SuccessResponse = { + responseType: 'project' | 'token' | 'job' | 'build' | 'release'; + id: number; + created: Date; + updated: Date; + _links: { + self?: { + href: string; + }; + job?: { + href: string; + }; + }; +}; + +type CommonStatus = 'initialized' | 'accepted' | 'completed'; + +export type ProjectConfig = { + app_id: string; + project_name: string; + language_code: string; + storage_type: string; +}; +export type ProjectResponse = SuccessResponse & + ProjectConfig & { + responseType: 'project'; + status: CommonStatus | 'delete' | 'deleting'; + result: 'SUCCESS' | 'FAILURE' | null; + error: string | null; + url: string; + publishing_key: string; + user_id: string; + group_id: string; + }; + +export type TokenConfig = { + name: string; +}; +export type TokenResponse = { + responseType: 'token'; + session_token: string; + secret_access_key: string; + access_key_id: string; + expiration: string; + region: string; +}; + +export type JobConfig = { + request_id: string; + git_url: string; + app_id: string; + publisher_id: string; +}; +export type JobResponse = SuccessResponse & + JobConfig & { + responseType: 'job'; + }; + +type BuildOrReleaseStatus = 'active' | 'expired' | 'postprocessing'; + +type BuildCommon = { + targets: string; +}; +export type BuildConfig = BuildCommon & { + environment: { [key: string]: string }; +}; +export type BuildResponse = SuccessResponse & + BuildCommon & { + responseType: 'build'; + job_id: number; + status: CommonStatus | BuildOrReleaseStatus; + result: 'SUCCESS' | 'FAILURE' | 'ABORTED' | null; + error: string | null; + artifacts: { [key: string]: string }; + }; + +export type Channels = 'production' | 'beta' | 'alpha'; + +type ReleaseCommon = { + channel: Channels; + targets: string; +}; +export type ReleaseConfig = ReleaseCommon & { + environment: { [key: string]: string }; +}; +export type ReleaseResponse = SuccessResponse & + ReleaseCommon & { + responseType: 'release'; + buildId: number; + status: CommonStatus | BuildOrReleaseStatus; + result: 'SUCCESS' | 'FAILURE' | 'EXCEPTION' | null; + error: string | null; + console_text: string; + artifacts: { [key: string]: string }; + }; diff --git a/source/SIL.AppBuilder.Portal/common/bullmq.ts b/source/SIL.AppBuilder.Portal/common/bullmq.ts index bfb53f987..aed41b369 100644 --- a/source/SIL.AppBuilder.Portal/common/bullmq.ts +++ b/source/SIL.AppBuilder.Portal/common/bullmq.ts @@ -1,7 +1,15 @@ import { Queue } from 'bullmq'; import type { ScriptoriaJob } from './BullJobTypes.js'; -export const scriptoriaQueue = new Queue('scriptoria', { +export type QueueName = 'scriptoria' | 'default recurring'; + +export const scriptoria = new Queue('scriptoria' as QueueName, { + connection: { + host: process.env.NODE_ENV === 'development' ? 'localhost' : 'redis' + } +}); + +export const default_recurring = new Queue('default recurring' as QueueName, { connection: { host: process.env.NODE_ENV === 'development' ? 'localhost' : 'redis' } diff --git a/source/SIL.AppBuilder.Portal/common/databaseProxy/Products.ts b/source/SIL.AppBuilder.Portal/common/databaseProxy/Products.ts index a0111e1a1..0480d7d01 100644 --- a/source/SIL.AppBuilder.Portal/common/databaseProxy/Products.ts +++ b/source/SIL.AppBuilder.Portal/common/databaseProxy/Products.ts @@ -1,6 +1,7 @@ import type { Prisma } from '@prisma/client'; import prisma from '../prisma.js'; import { RequirePrimitive } from './utility.js'; +import { BullMQ, queues } from '../index.js'; export async function create( productData: RequirePrimitive @@ -43,12 +44,8 @@ export async function update( const productDefinitionId = productData.ProductDefinitionId ?? existing!.ProductDefinitionId; const storeId = productData.StoreId ?? existing!.StoreId; const storeLanguageId = productData.StoreLanguageId ?? existing!.StoreLanguageId; - if (!(await validateProductBase( - projectId, - productDefinitionId, - storeId, - storeLanguageId - ))) return false; + if (!(await validateProductBase(projectId, productDefinitionId, storeId, storeLanguageId))) + return false; // No additional verification steps @@ -66,8 +63,36 @@ export async function update( return true; } -function deleteProduct(productId: string) { +async function deleteProduct(productId: string) { // Delete all userTasks for this product, and delete the product + const product = await prisma.products.findUnique({ + where: { + Id: productId + }, + select: { + Project: { + select: { + OrganizationId: true + } + }, + WorkflowJobId: true + } + }); + queues.scriptoria.add( + `Delete Product #${productId} from BuildEngine`, + { + type: BullMQ.ScriptoriaJobType.Product_Delete, + organizationId: product.Project.OrganizationId, + workflowJobId: product.WorkflowJobId + }, + { + attempts: 5, + backoff: { + type: 'exponential', + delay: 5000 // 5 seconds + } + } + ); return prisma.$transaction([ prisma.workflowInstances.delete({ where: { @@ -85,7 +110,6 @@ function deleteProduct(productId: string) { } }) ]); - // TODO: delete from BuildEngine } export { deleteProduct as delete }; @@ -140,15 +164,17 @@ async function validateProductBase( // Store type must match Workflow store type Id: true, // StoreLanguage must be allowed by Store, if the StoreLanguage is defined - StoreLanguages: storeLanguageId === undefined || storeLanguageId === null ? - undefined : { - where: { - Id: storeLanguageId - }, - select: { - Id: true - } - } + StoreLanguages: + storeLanguageId === undefined || storeLanguageId === null + ? undefined + : { + where: { + Id: storeLanguageId + }, + select: { + Id: true + } + } } } } @@ -175,7 +201,8 @@ async function validateProductBase( // 2. The project has a WorkflowProjectUrl // handled by query // 4. The language is allowed by the store - (storeLanguageId ?? project.Organization.OrganizationStores[0].Store.StoreType.StoreLanguages.length > 0) && + (storeLanguageId ?? + project.Organization.OrganizationStores[0].Store.StoreType.StoreLanguages.length > 0) && // 5. The product type is allowed by the organization project.Organization.OrganizationProductDefinitions.length > 0 ); diff --git a/source/SIL.AppBuilder.Portal/common/databaseProxy/Projects.ts b/source/SIL.AppBuilder.Portal/common/databaseProxy/Projects.ts index 30794c95e..2a2444cc4 100644 --- a/source/SIL.AppBuilder.Portal/common/databaseProxy/Projects.ts +++ b/source/SIL.AppBuilder.Portal/common/databaseProxy/Projects.ts @@ -1,6 +1,9 @@ import type { Prisma } from '@prisma/client'; -import { ScriptoriaJobType } from '../BullJobTypes.js'; -import { scriptoriaQueue } from '../bullmq.js'; +import { + ScriptoriaJobType, + UserTasks +} from '../BullJobTypes.js'; +import { scriptoria } from '../bullmq.js'; import prisma from '../prisma.js'; import type { RequirePrimitive } from './utility.js'; @@ -19,19 +22,25 @@ 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 try { - await prisma.projects.create({ + const res = await prisma.projects.create({ data: projectData }); + return res.Id; } catch (e) { return false; } - return true; } export async function update( @@ -61,11 +70,17 @@ export async function update( data: projectData }); // 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 + scriptoria.add(`Reassign tasks for Project #${id} (New Owner)`, { + type: ScriptoriaJobType.UserTasks_Modify, + scope: 'Project', + projectId: id, + operation: { + type: UserTasks.OpType.Reassign, + userMapping: [ + { from: existing.OwnerId, to: ownerId } + ] + } }); } } catch (e) { diff --git a/source/SIL.AppBuilder.Portal/common/index.ts b/source/SIL.AppBuilder.Portal/common/index.ts index 0ad9a043c..eea332f8b 100644 --- a/source/SIL.AppBuilder.Portal/common/index.ts +++ b/source/SIL.AppBuilder.Portal/common/index.ts @@ -1,5 +1,6 @@ export * as BullMQ from './BullJobTypes.js'; -export { scriptoriaQueue } from './bullmq.js'; +export * as queues from './bullmq.js'; export { default as DatabaseWrites } from './databaseProxy/index.js'; export { readonlyPrisma as prisma } from './prisma.js'; export { Workflow } from './workflow/index.js'; +export * as BuildEngine from './build-engine-api/index.js'; diff --git a/source/SIL.AppBuilder.Portal/common/prisma/schema.prisma b/source/SIL.AppBuilder.Portal/common/prisma/schema.prisma index 787760941..5b06a6a21 100644 --- a/source/SIL.AppBuilder.Portal/common/prisma/schema.prisma +++ b/source/SIL.AppBuilder.Portal/common/prisma/schema.prisma @@ -179,8 +179,8 @@ model ProductArtifacts { Url String? FileSize BigInt? ContentType String? - DateCreated DateTime? @db.Timestamp - DateUpdated DateTime? @db.Timestamp + DateCreated DateTime? @db.Timestamp // TODO: Can we add `@default(now())` to all instances? + DateUpdated DateTime? @db.Timestamp // TODO: Can we add `@updatedAt` to all instances? ProductBuild ProductBuilds @relation(fields: [ProductBuildId], references: [Id], onDelete: Cascade, onUpdate: NoAction, map: "FK_ProductArtifacts_ProductBuilds_ProductBuildId") Product Products @relation(fields: [ProductId], references: [Id], onDelete: Cascade, onUpdate: NoAction, map: "FK_ProductArtifacts_Products_ProductId") @@ -203,6 +203,7 @@ model ProductBuilds { @@index([ProductId], map: "IX_ProductBuilds_ProductId") } +// TODO: Can we please rework/consolidate the ProductDefinitions and WorkflowDefinitions tables and add some useful information for S2? I can make it work for now, but we should eventually consider how to rework some of the tables as we move away from DWKit and its associated idiosyncracies. model ProductDefinitions { Id Int @id(map: "PK_ProductDefinitions") @default(autoincrement()) Name String? diff --git a/source/SIL.AppBuilder.Portal/common/workflow/default-workflow.ts b/source/SIL.AppBuilder.Portal/common/workflow/default-workflow.ts index 988061284..92a99e164 100644 --- a/source/SIL.AppBuilder.Portal/common/workflow/default-workflow.ts +++ b/source/SIL.AppBuilder.Portal/common/workflow/default-workflow.ts @@ -11,6 +11,8 @@ import { WorkflowEvent } from '../public/workflow.js'; import { RoleId } from '../public/prisma.js'; +import { queues } from '../index.js'; +import { ScriptoriaJobType } from '../BullJobTypes.js'; /** * IMPORTANT: READ THIS BEFORE EDITING A STATE MACHINE! @@ -305,9 +307,18 @@ export const DefaultWorkflow = setup({ 'Product Creation': { entry: [ assign({ instructions: 'waiting' }), - () => { - // TODO: hook into build engine - console.log('Creating Product'); + ({ context }) => { + queues.scriptoria.add(`Create Product #${context.productId}`, { + type: ScriptoriaJobType.Product_Create, + productId: context.productId + }, + { + attempts: 5, + backoff: { + type: 'exponential', + delay: 5000 // 5 seconds + } + }); } ], on: { @@ -460,9 +471,20 @@ export const DefaultWorkflow = setup({ assign({ instructions: 'waiting' }), - () => { - // TODO: hook into build engine - console.log('Building Product'); + ({ context }) => { + queues.scriptoria.add(`Build Product #${context.productId}`, { + type: ScriptoriaJobType.Build_Product, + productId: context.productId, + // TODO: assign targets + environment: context.environment + }, + { + attempts: 5, + backoff: { + type: 'exponential', + delay: 5000 // 5 seconds + } + }); } ], on: { @@ -666,9 +688,11 @@ export const DefaultWorkflow = setup({ user: RoleId.AppBuilder }, guard: { type: 'hasReviewers' }, - actions: () => { - // TODO: connect to backend to email reviewers - console.log('Emailing Reviewers'); + actions: ({ context }) => { + queues.scriptoria.add(`Email Reviewers (Product: ${context.productId})`, { + type: ScriptoriaJobType.Notify_Reviewers, + productId: context.productId + }); } } } @@ -676,9 +700,22 @@ export const DefaultWorkflow = setup({ 'Product Publish': { entry: [ assign({ instructions: 'waiting' }), - () => { - // TODO: hook into build engine - console.log('Publishing Product'); + ({ context }) => { + queues.scriptoria.add(`Publish Product #${context.productId}`, { + type: ScriptoriaJobType.Publish_Product, + productId: context.productId, + // TODO: How should these values be determined? + channel: 'alpha', + targets: 'google-play', + environment: context.environment + }, + { + attempts: 5, + backoff: { + type: 'exponential', + delay: 5000 // 5 seconds + } + }); } ], on: { @@ -766,7 +803,7 @@ export const DefaultWorkflow = setup({ Jump: { actions: [ assign({ - start: ({ context, event }) => event.target + start: ({ event }) => event.target }) ], target: '.Start' diff --git a/source/SIL.AppBuilder.Portal/common/workflow/index.ts b/source/SIL.AppBuilder.Portal/common/workflow/index.ts index 32fc39b47..ada746814 100644 --- a/source/SIL.AppBuilder.Portal/common/workflow/index.ts +++ b/source/SIL.AppBuilder.Portal/common/workflow/index.ts @@ -23,6 +23,8 @@ import prisma from '../prisma.js'; import { RoleId, ProductTransitionType, WorkflowType } from '../public/prisma.js'; import { allUsersByRole } from '../databaseProxy/UserRoles.js'; import { Prisma } from '@prisma/client'; +import { scriptoria } from '../bullmq.js'; +import { BullMQ } from '../index.js'; /** * Wraps a workflow instance and provides methods to interact. @@ -58,8 +60,22 @@ export class Workflow { }); flow.flow.start(); - flow.populateTransitions(); - flow.updateUserTasks(); + DatabaseWrites.productTransitions.create({ + data: { + ProductId: productId, + DateTransition: new Date(), + TransitionType: ProductTransitionType.StartWorkflow + } + }); + scriptoria.add(`Create UserTasks for Product #${productId}`, { + type: BullMQ.ScriptoriaJobType.UserTasks_Modify, + scope: 'Product', + productId: productId, + operation: { + type: BullMQ.UserTasks.OpType.Create, + by: 'All' + } + }); return flow; } @@ -209,7 +225,21 @@ export class Workflow { await this.createSnapshot(snap.context); if (old && Workflow.stateName(old) !== snap.value) { - await this.updateUserTasks(event.event.comment || undefined); + await DatabaseWrites.userTasks.deleteMany({ + where: { + ProductId: this.productId + } + }); + scriptoria.add(`Update UserTasks for Product #${this.productId}`, { + type: BullMQ.ScriptoriaJobType.UserTasks_Modify, + scope: 'Product', + productId: this.productId, + comment: event.event.comment || undefined, + operation: { + type: BullMQ.UserTasks.OpType.Update, + by: 'All' + } + }); } } @@ -262,84 +292,6 @@ export class Workflow { ); } - /** - * 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 = Workflow.availableTransitionsFromNode(this.currentState, this.config) - .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: Workflow.stateName(this.currentState), - Status: Workflow.stateName(this.currentState), - Comment: comment ?? null, - DateCreated: timestamp, - DateUpdated: timestamp - })) - }); - } - /** Create ProductTransitions record object */ private static transitionFromState( state: XStateNode, @@ -369,19 +321,6 @@ export class Workflow { }; } - /** Create ProductTransitions entries for new product following the "happy" path */ - private async populateTransitions() { - // TODO: AllowedUserNames - return DatabaseWrites.productTransitions.createManyAndReturn({ - data: await Workflow.transitionEntriesFromState('Start', { - productId: this.productId, - hasAuthors: false, - hasReviewers: false, - ...this.config - }) - }); - } - public static async transitionEntriesFromState( stateName: string, input: WorkflowInput @@ -438,10 +377,7 @@ export class Workflow { } /** - * 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. + * Update or create product transition */ private async updateProductTransitions( userId: number | null, diff --git a/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts b/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts index 074abd29b..26b9f0b14 100644 --- a/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts +++ b/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts @@ -1,6 +1,6 @@ import { Job, Worker } from 'bullmq'; -import { BullMQ, DatabaseWrites, prisma } from 'sil.appbuilder.portal.common'; -import { RoleId } from 'sil.appbuilder.portal.common/prisma'; +import { BullMQ, queues } from 'sil.appbuilder.portal.common'; +import * as Executor from './job-executors/index.js'; export abstract class BullWorker { public worker: Worker; @@ -14,111 +14,56 @@ export abstract class BullWorker { abstract run(job: Job): Promise; } +type JobCast = Job; + export class ScriptoriaWorker extends BullWorker { - constructor() { - super('scriptoria'); + constructor(queue: queues.QueueName) { + super(queue); } async run(job: Job): Promise { switch (job.data.type) { - case BullMQ.ScriptoriaJobType.Test: { - job.updateProgress(50); - const time = job.data.time; - await new Promise((r) => setTimeout(r, 1000 * time)); - job.updateProgress(100); - return 0; + case BullMQ.ScriptoriaJobType.Build_Product: + return new Executor.Build.Product().execute(job as JobCast); + case BullMQ.ScriptoriaJobType.Build_Check: + return new Executor.Build.Check().execute(job as JobCast); + case BullMQ.ScriptoriaJobType.Notify_Reviewers: + return new Executor.Notify.Reviewers().execute(job as JobCast); + case BullMQ.ScriptoriaJobType.Product_Create: + return new Executor.Product.Create().execute(job as JobCast); + case BullMQ.ScriptoriaJobType.Product_Delete: + return new Executor.Product.Delete().execute(job as JobCast); + case BullMQ.ScriptoriaJobType.Project_Create: + return new Executor.Project.Create().execute(job as JobCast); + case BullMQ.ScriptoriaJobType.Project_Check: + return new Executor.Project.Check().execute(job as JobCast); + case BullMQ.ScriptoriaJobType.Publish_Product: + return new Executor.Publish.Product().execute(job as JobCast); + case BullMQ.ScriptoriaJobType.Publish_Check: + return new Executor.Publish.Check().execute(job as JobCast); + case BullMQ.ScriptoriaJobType.System_CheckStatuses: + return new Executor.System.CheckStatuses().execute( + job as JobCast + ); + case BullMQ.ScriptoriaJobType.Test: + return new Executor.Test().execute(job as JobCast); + case BullMQ.ScriptoriaJobType.UserTasks_Modify: + return new Executor.UserTasks.Modify().execute(job as JobCast); } - case BullMQ.ScriptoriaJobType.ReassignUserTasks: { - // 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; - } +export function addDefaultRecurringJobs() { + // Recurring job to check the availability of BuildEngine + queues.default_recurring.add( + 'Check System Statuses (Recurring)', + { + type: BullMQ.ScriptoriaJobType.System_CheckStatuses + }, + { + repeat: { + pattern: '*/5 * * * *', // every 5 minutes + key: 'defaultCheckSystemStatuses' + } } - } + ); } diff --git a/source/SIL.AppBuilder.Portal/node-server/dev.ts b/source/SIL.AppBuilder.Portal/node-server/dev.ts index 3c883daff..9ff9e6cdf 100644 --- a/source/SIL.AppBuilder.Portal/node-server/dev.ts +++ b/source/SIL.AppBuilder.Portal/node-server/dev.ts @@ -2,20 +2,23 @@ import { createBullBoard } from '@bull-board/api'; import { BullAdapter } from '@bull-board/api/bullAdapter.js'; import { ExpressAdapter } from '@bull-board/express'; import express from 'express'; -import { ScriptoriaWorker } from './BullWorker.js'; +import { ScriptoriaWorker, addDefaultRecurringJobs } from './BullWorker.js'; process.env.NODE_ENV = 'development'; const app = express(); -import { scriptoriaQueue } from 'sil.appbuilder.portal.common'; +import { BullMQ, queues } from 'sil.appbuilder.portal.common'; const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath('/'); createBullBoard({ - queues: [new BullAdapter(scriptoriaQueue)], + queues: [new BullAdapter(queues.scriptoria), new BullAdapter(queues.default_recurring)], serverAdapter }); app.use(serverAdapter.getRouter()); app.listen(3000, () => console.log('Dev server started')); -new ScriptoriaWorker(); +addDefaultRecurringJobs(); + +new ScriptoriaWorker('scriptoria'); +new ScriptoriaWorker('default recurring'); diff --git a/source/SIL.AppBuilder.Portal/node-server/index.ts b/source/SIL.AppBuilder.Portal/node-server/index.ts index 7e4869b5e..0707a9c65 100644 --- a/source/SIL.AppBuilder.Portal/node-server/index.ts +++ b/source/SIL.AppBuilder.Portal/node-server/index.ts @@ -7,7 +7,7 @@ import express, { type NextFunction, type Request, type Response } from 'express import path from 'path'; import { prisma } from 'sil.appbuilder.portal.common'; import { fileURLToPath } from 'url'; -import { ScriptoriaWorker } from './BullWorker.js'; +import { ScriptoriaWorker, addDefaultRecurringJobs } from './BullWorker.js'; // Do not import any functional code from the sveltekit codebase // unless you are positive you know what you are doing @@ -72,16 +72,17 @@ app.get('/healthcheck', (req, res) => { }); // BullMQ variables -import { scriptoriaQueue } from 'sil.appbuilder.portal.common'; +import { BullMQ, queues } from 'sil.appbuilder.portal.common'; // Running on svelte process right now. Consider putting on new thread // Fine like this if majority of job time is waiting for network requests // If there is much processing it should be moved to another thread -new ScriptoriaWorker(); +new ScriptoriaWorker('scriptoria'); +new ScriptoriaWorker('default recurring'); const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath('/admin/jobs'); createBullBoard({ - queues: [new BullAdapter(scriptoriaQueue)], + queues: [new BullAdapter(queues.scriptoria), new BullAdapter(queues.default_recurring)], serverAdapter }); @@ -96,3 +97,5 @@ const handler = await import('./build/handler.js'); app.use(handler.handler); app.listen(3000, () => console.log('Server started!')); + +addDefaultRecurringJobs(); diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/base.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/base.ts new file mode 100644 index 000000000..91e40e816 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/base.ts @@ -0,0 +1,7 @@ +import { BullMQ } from 'sil.appbuilder.portal.common'; +import { Job } from 'bullmq'; + +export abstract class ScriptoriaJobExecutor { + constructor() {} + abstract execute(job: Job): Promise; +} diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/build.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/build.ts new file mode 100644 index 000000000..50b733dcf --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/build.ts @@ -0,0 +1,209 @@ +import { + BullMQ, + prisma, + DatabaseWrites, + BuildEngine, + Workflow, + queues +} from 'sil.appbuilder.portal.common'; +import { Job } from 'bullmq'; +import { ScriptoriaJobExecutor } from './base.js'; + +export class Product extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + const productData = await prisma.products.findUnique({ + where: { + Id: job.data.productId + }, + select: { + Project: { + select: { + OrganizationId: true + } + }, + WorkflowJobId: true + } + }); + if (!productData) { + throw new Error(`Product #${job.data.productId} does not exist!`); + } + job.updateProgress(25); + const response = await BuildEngine.Requests.createBuild( + { type: 'query', organizationId: productData.Project.OrganizationId }, + productData.WorkflowJobId, + { + targets: job.data.targets ?? 'apk play-listing', + environment: job.data.environment + } + ); + job.updateProgress(50); + if (response.responseType === 'error') { + const flow = await Workflow.restore(job.data.productId); + flow.send({ type: 'Build Failed', userId: null, comment: response.message }); + job.updateProgress(100); + return 0; + } else { + await DatabaseWrites.products.update(job.data.productId, { + WorkflowBuildId: response.id, + DateUpdated: new Date() + }); + job.updateProgress(65); + + const timestamp = new Date(); + const productBuild = await DatabaseWrites.productBuilds.create({ + data: { + ProductId: job.data.productId, + BuildId: response.id, + DateCreated: timestamp, + DateUpdated: timestamp + } + }); + + job.updateProgress(85); + + await queues.scriptoria.add( + `Check status of Build #${response.id}`, + { + type: BullMQ.ScriptoriaJobType.Build_Check, + productId: job.data.productId, + organizationId: productData.Project.OrganizationId, + jobId: productData.WorkflowJobId, + buildId: response.id, + productBuildId: productBuild.Id + }, + { + repeat: { + pattern: '*/1 * * * *' // every minute + } + } + ); + + job.updateProgress(100); + + return response.id; + } + } +} + +export class Check extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + const response = await BuildEngine.Requests.getBuild( + { type: 'query', organizationId: job.data.organizationId }, + job.data.jobId, + job.data.buildId + ); + job.updateProgress(50); + if (response.responseType === 'error') { + throw new Error(response.message); + } else { + // TODO: what does the 'expired' status mean? + if (response.status === 'completed' || response.status === 'expired') { + await queues.scriptoria.removeRepeatableByKey(job.repeatJobKey!); + if (response.error) { + job.log(response.error); + } + let latestArtifactDate = new Date(0); + await DatabaseWrites.productArtifacts.createMany({ + data: await Promise.all( + Object.entries(response.artifacts).map(async ([type, url]) => { + const res = await fetch(url, { method: 'HEAD' }); + const timestamp = new Date(); + const lastModified = new Date(res.headers.get('Last-Modified')); + if (lastModified > latestArtifactDate) { + latestArtifactDate = lastModified; + } + + // On version.json, update the ProductBuild.Version + if (type === 'version' && res.headers.get('Content-Type') === 'application/json') { + const version = JSON.parse(await fetch(url).then((r) => r.text())); + if (version['version']) { + await DatabaseWrites.productBuilds.update({ + where: { + Id: job.data.productBuildId + }, + data: { + Version: version['version'], + DateUpdated: new Date() + } + }); + if (response.result === 'SUCCESS') { + await DatabaseWrites.products.update(job.data.productId, { + VersionBuilt: version['version'], + DateUpdated: new Date() + }); + } + } + } + + // On play-listing-manifest.json, update the Project.DefaultLanguage + if ( + type == 'play-listing-manifest' && + res.headers.get('Content-Type') === 'application/json' + ) { + const manifest = JSON.parse(await fetch(url).then((r) => r.text())); + if (manifest['default-language']) { + const lang = await prisma.storeLanguages.findFirst({ + where: { + Name: manifest['default-language'] + }, + select: { + Id: true + } + }); + if (lang !== null) { + await DatabaseWrites.products.update(job.data.productId, { + StoreLanguageId: lang.Id, + DateUpdated: new Date() + }); + } + } + } + + return { + ProductId: job.data.productId, + ProductBuildId: job.data.productBuildId, + ArtifactType: type, + Url: url, + ContentType: res.headers.get('Content-Type'), + FileSize: + res.headers.get('Content-Type') !== 'text/html' + ? parseInt(res.headers.get('Content-Length')) + : undefined, + DateCreated: timestamp, + DateUpdated: timestamp + }; + }) + ) + }); + await DatabaseWrites.products.update(job.data.productId, { + DateBuilt: latestArtifactDate + }); + job.updateProgress(80); + await DatabaseWrites.productBuilds.update({ + where: { + Id: job.data.productBuildId + }, + data: { + DateUpdated: new Date(), + Success: response.result === 'SUCCESS' + } + }); + job.updateProgress(90); + const flow = await Workflow.restore(job.data.productId); + if (response.result === 'SUCCESS') { + flow.send({ type: 'Build Successful', userId: null }); + } else { + flow.send({ + type: 'Build Failed', + userId: null, + comment: `system.build-failed,${response.artifacts['consoleText'] ?? ''}` + }); + } + job.updateProgress(100); + return response.id; + } + job.updateProgress(100); + return 0; + } + } +} diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts new file mode 100644 index 000000000..19a01ab66 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts @@ -0,0 +1,8 @@ +export { Test } from './test.js'; +export * as UserTasks from './userTasks.js'; +export * as Notify from './notify.js'; +export * as Product from './product.js'; +export * as Build from './build.js'; +export * as Publish from './publish.js'; +export * as System from './system.js'; +export * as Project from './project.js'; diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/notify.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/notify.ts new file mode 100644 index 000000000..13ede393e --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/notify.ts @@ -0,0 +1,10 @@ +import { BullMQ } from 'sil.appbuilder.portal.common'; +import { Job } from 'bullmq'; +import { ScriptoriaJobExecutor } from './base.js'; + +export class Reviewers extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + // TODO: send emails (there is currently no integrated service with which to do so) + return 0; + } +} diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/product.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/product.ts new file mode 100644 index 000000000..f77b08824 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/product.ts @@ -0,0 +1,80 @@ +import { + BullMQ, + prisma, + DatabaseWrites, + BuildEngine, + Workflow +} from 'sil.appbuilder.portal.common'; +import { Job } from 'bullmq'; +import { ScriptoriaJobExecutor } from './base.js'; + +export class Create extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + const productData = await prisma.products.findUnique({ + where: { + Id: job.data.productId + }, + select: { + Project: { + select: { + ApplicationType: { + select: { + Name: true + } + }, + WorkflowProjectUrl: true, + OrganizationId: true + } + }, + Store: { + select: { + Name: true + } + } + } + }); + job.updateProgress(25); + const response = await BuildEngine.Requests.createJob( + { type: 'query', organizationId: productData.Project.OrganizationId }, + { + request_id: job.data.productId, + git_url: productData.Project.WorkflowProjectUrl, + app_id: productData.Project.ApplicationType.Name, + publisher_id: productData.Store.Name + } + ); + job.updateProgress(50); + if (response.responseType === 'error') { + job.log(response.message); + throw new Error(response.message); + } else { + await DatabaseWrites.products.update(job.data.productId, { + WorkflowJobId: response.id, + DateUpdated: new Date().toString() + }); + job.updateProgress(75); + const flow = await Workflow.restore(job.data.productId); + + flow.send({ type: 'Product Created', userId: null }); + job.updateProgress(100); + return response.id; + } + } +} + +export class Delete extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + const response = await BuildEngine.Requests.deleteJob( + { type: 'query', organizationId: job.data.organizationId }, + job.data.workflowJobId + ); + job.updateProgress(50); + if (response.responseType === 'error') { + job.log(response.message); + throw new Error(response.message); + } else { + job.updateProgress(100); + return response.status; + } + } +} diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/project.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/project.ts new file mode 100644 index 000000000..284acc924 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/project.ts @@ -0,0 +1,97 @@ +import { BullMQ, prisma, DatabaseWrites, BuildEngine, queues } from 'sil.appbuilder.portal.common'; +import { Job } from 'bullmq'; +import { ScriptoriaJobExecutor } from './base.js'; + +export class Create extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + const projectData = await prisma.projects.findUnique({ + where: { + Id: job.data.projectId + }, + select: { + OrganizationId: true, + ApplicationType: { + select: { + Name: true + } + }, + Name: true, + Language: true + } + }); + job.updateProgress(25); + const response = await BuildEngine.Requests.createProject( + { type: 'query', organizationId: projectData.OrganizationId }, + { + app_id: projectData.ApplicationType.Name, + project_name: projectData.Name, + language_code: projectData.Language, + storage_type: 's3' + } + ); + job.updateProgress(50); + if (response.responseType === 'error') { + job.log(response.message); + throw new Error(`Creation of Project #${job.data.projectId} failed!`); + } else { + await DatabaseWrites.projects.update(job.data.projectId, { + WorkflowProjectId: response.id, + WorkflowAppProjectUrl: `${process.env.UI_URL ?? 'http://localhost:5173'}/projects/${ + job.data.projectId + }`, + DateUpdated: new Date() + }); + job.updateProgress(75); + + await queues.scriptoria.add( + `Check status of Project #${response.id}`, + { + type: BullMQ.ScriptoriaJobType.Project_Check, + workflowProjectId: response.id, + organizationId: projectData.OrganizationId, + projectId: job.data.projectId + }, + { + repeat: { + pattern: '*/1 * * * *' // every minute + } + } + ); + + job.updateProgress(100); + + return response.id; + } + } +} + +export class Check extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + const response = await BuildEngine.Requests.getProject( + { type: 'query', organizationId: job.data.organizationId}, + job.data.workflowProjectId + ); + job.updateProgress(50); + if (response.responseType === 'error') { + job.log(response.message); + throw new Error(response.message); + } else { + if (response.status === 'completed') { + await queues.scriptoria.removeRepeatableByKey(job.repeatJobKey); + if (response.error) { + job.log(response.error); + } else { + await DatabaseWrites.projects.update(job.data.projectId, { + WorkflowProjectUrl: response.url, + DateUpdated: new Date() + }); + } + + job.updateProgress(100); + return response.id; + } + job.updateProgress(100); + return 0; + } + } +} diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/publish.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/publish.ts new file mode 100644 index 000000000..722b60ee0 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/publish.ts @@ -0,0 +1,171 @@ +import { + BullMQ, + prisma, + DatabaseWrites, + BuildEngine, + Workflow, + queues +} from 'sil.appbuilder.portal.common'; +import { Job } from 'bullmq'; +import { ScriptoriaJobExecutor } from './base.js'; + +export class Product extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + const productData = await prisma.products.findUnique({ + where: { + Id: job.data.productId + }, + select: { + Project: { + select: { + OrganizationId: true + } + }, + WorkflowJobId: true, + WorkflowBuildId: true + } + }); + job.updateProgress(25); + const response = await BuildEngine.Requests.createRelease( + { type: 'query', organizationId: productData.Project.OrganizationId }, + productData.WorkflowJobId, + productData.WorkflowBuildId, + { + channel: job.data.channel, + targets: job.data.targets, + environment: job.data.environment + } + ); + job.updateProgress(50); + if (response.responseType === 'error') { + const flow = await Workflow.restore(job.data.productId); + flow.send({ type: 'Publish Failed', userId: null, comment: response.message }); + job.updateProgress(100); + return 0; + } else { + await DatabaseWrites.products.update(job.data.productId, { + WorkflowPublishId: response.id, + DateUpdated: new Date() + }); + job.updateProgress(65); + + const timestamp = new Date(); + const pub = await DatabaseWrites.productPublications.create({ + data: { + ProductId: job.data.productId, + ProductBuildId: productData.WorkflowBuildId, + ReleaseId: response.id, + Channel: job.data.channel, + DateCreated: timestamp, + DateUpdated: timestamp + } + }); + + job.updateProgress(85); + + await queues.scriptoria.add( + `Check status of Publish #${response.id}`, + { + type: BullMQ.ScriptoriaJobType.Publish_Check, + productId: job.data.productId, + organizationId: productData.Project.OrganizationId, + jobId: productData.WorkflowJobId, + buildId: productData.WorkflowBuildId, + releaseId: response.id, + publicationId: pub.Id + }, + { + repeat: { + pattern: '*/1 * * * *' // every minute + } + } + ); + job.updateProgress(100); + + return response.id; + } + } +} + +export class Check extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + const response = await BuildEngine.Requests.getRelease( + { type: 'query', organizationId: job.data.organizationId }, + job.data.jobId, + job.data.buildId, + job.data.releaseId + ); + job.updateProgress(50); + if (response.responseType === 'error') { + throw new Error(response.message); + } else { + // TODO: what does the 'expired' status mean? + if (response.status === 'completed' || response.status === 'expired') { + await queues.scriptoria.removeRepeatableByKey(job.repeatJobKey); + if (response.error) { + job.log(response.error); + } + let packageName: string | undefined = undefined; + const flow = await Workflow.restore(job.data.productId); + if (response.result === 'SUCCESS') { + const publishUrlFile = response.artifacts['publishUrl']; + await DatabaseWrites.products.update(job.data.productId, { + DatePublished: new Date(), + PublishLink: publishUrlFile + ? (await fetch(publishUrlFile).then((r) => r.text()))?.trim() ?? undefined + : undefined, + DateUpdated: new Date() + }); + flow.send({ type: 'Publish Successful', userId: null }); + const packageFile = await prisma.productPublications.findUnique({ + where: { + Id: job.data.publicationId + }, + select: { + ProductBuild: { + select: { + ProductArtifacts: { + where: { + ArtifactType: 'package_name' + }, + select: { + Url: true + }, + take: 1 + } + } + } + } + }); + if (packageFile?.ProductBuild.ProductArtifacts[0]) { + packageName = await fetch(packageFile.ProductBuild.ProductArtifacts[0].Url).then((r) => + r.text() + ); + } + } else { + flow.send({ + type: 'Publish Failed', + userId: null, + comment: `system.publish-failed,${response.artifacts['consoleText'] ?? ''}` + }); + } + job.updateProgress(80); + await DatabaseWrites.productPublications.update({ + where: { + Id: job.data.publicationId + }, + data: { + Success: response.result === 'SUCCESS', + LogUrl: response.console_text, + Package: packageName?.trim(), + DateUpdated: new Date() + } + }); + job.updateProgress(100); + return response.id; + } + job.updateProgress(100); + return 0; + } + } +} diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/system.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/system.ts new file mode 100644 index 000000000..3ecc7e5d1 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/system.ts @@ -0,0 +1,85 @@ +import { BullMQ, prisma, DatabaseWrites, BuildEngine } from 'sil.appbuilder.portal.common'; +import { Job } from 'bullmq'; +import { ScriptoriaJobExecutor } from './base.js'; + +export class CheckStatuses extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + const organizations = await prisma.organizations.findMany({ + where: { + OR: [ + { + UseDefaultBuildEngine: null + }, + { + UseDefaultBuildEngine: false + } + ] + }, + select: { + BuildEngineUrl: true, + BuildEngineApiAccessToken: true + } + }); + // Add defaults + if (process.env.DEFAULT_BUILDENGINE_URL && process.env.DEFAULT_BUILDENGINE_API_ACCESS_TOKEN) { + organizations.push({ + BuildEngineUrl: process.env.DEFAULT_BUILDENGINE_URL, + BuildEngineApiAccessToken: process.env.DEFAULT_BUILDENGINE_API_ACCESS_TOKEN + }); + } + job.updateProgress(10); + // remove statuses that do not correspond to organizations + await DatabaseWrites.systemStatuses.deleteMany({ + where: { + BuildEngineUrl: { + notIn: organizations.map((o) => o.BuildEngineUrl) + }, + BuildEngineApiAccessToken: { + notIn: organizations.map((o) => o.BuildEngineApiAccessToken) + } + } + }); + job.updateProgress(20); + const systems = await prisma.systemStatuses.findMany({ + select: { + BuildEngineUrl: true, + BuildEngineApiAccessToken: true + } + }); + const filteredOrgs = organizations.filter( + (o) => + !systems.find( + (s) => + s.BuildEngineUrl === o.BuildEngineUrl && + s.BuildEngineApiAccessToken === o.BuildEngineApiAccessToken + ) + ); + job.updateProgress(30); + await DatabaseWrites.systemStatuses.createMany({ + data: filteredOrgs.map((o) => ({ ...o, SystemAvailable: false, DateCreated: new Date() })) + }); + job.updateProgress(50); + await Promise.all( + ( + await prisma.systemStatuses.findMany() + ).map(async (s) => { + const res = await BuildEngine.Requests.systemCheck({ + type: 'provided', + url: s.BuildEngineUrl, + token: s.BuildEngineApiAccessToken + }); + await DatabaseWrites.systemStatuses.update({ + where: { + Id: s.Id + }, + data: { + SystemAvailable: res.status === 200, + DateUpdated: new Date() + } + }) + }) + ); + job.updateProgress(100); + return systems.length; + } +} diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/test.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/test.ts new file mode 100644 index 000000000..ed5c69242 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/test.ts @@ -0,0 +1,13 @@ +import { BullMQ } from 'sil.appbuilder.portal.common'; +import { Job } from 'bullmq'; +import { ScriptoriaJobExecutor } from './base.js'; + +export class Test extends ScriptoriaJobExecutor { + async execute(job: Job): Promise { + job.updateProgress(50); + const time = job.data.time; + await new Promise((r) => setTimeout(r, 1000 * time)); + job.updateProgress(100); + return 0; + } +} 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..e710b3829 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/userTasks.ts @@ -0,0 +1,168 @@ +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 Modify 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.UserTasks.OpType.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.userRoles.allUsersByRole(projectId); + job.updateProgress(30); + if (job.data.operation.type !== BullMQ.UserTasks.OpType.Create) { + // Clear existing UserTasks + await DatabaseWrites.userTasks.deleteMany({ + where: { + ProductId: { in: productIds }, + UserId: + job.data.operation.by === 'All' || + job.data.operation.type === BullMQ.UserTasks.OpType.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.UserTasks.OpType.Delete) { + job.updateProgress(90); + } else { + job.updateProgress(40); + } + } + if (job.data.operation.type !== BullMQ.UserTasks.OpType.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); + const timestamp = new Date(); + 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, + DateCreated: timestamp, + DateUpdated: timestamp + })); + 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/auth.ts b/source/SIL.AppBuilder.Portal/src/auth.ts index 6b16957f6..264f9efc5 100644 --- a/source/SIL.AppBuilder.Portal/src/auth.ts +++ b/source/SIL.AppBuilder.Portal/src/auth.ts @@ -10,6 +10,7 @@ declare module '@auth/sveltekit' { interface Session { user: { userId: number; + /** [organizationId, RoleId][]*/ roles: [number, number][]; } & DefaultSession['user']; } diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/settings/bullmq/+page.server.ts b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/settings/bullmq/+page.server.ts index 83f07e433..b887ae3b4 100644 --- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/settings/bullmq/+page.server.ts +++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/settings/bullmq/+page.server.ts @@ -1,5 +1,5 @@ import { error } from '@sveltejs/kit'; -import { BullMQ, scriptoriaQueue } from 'sil.appbuilder.portal.common'; +import { BullMQ, queues } from 'sil.appbuilder.portal.common'; import { RoleId } from 'sil.appbuilder.portal.common/prisma'; import { fail, superValidate } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; @@ -17,7 +17,7 @@ export const actions = { } const form = await superValidate(event.request, valibot(secondsSchema)); if (!form.valid) return fail(400, { ok: false }); - await scriptoriaQueue.add('Admin Test Task (No-op)', { + await queues.scriptoria.add('Admin Test Task (No-op)', { type: BullMQ.ScriptoriaJobType.Test, time: form.data.seconds }); 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 63e11c8e1..85cdb6778 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 @@ -1,6 +1,6 @@ import { idSchema } from '$lib/valibot'; import { error } from '@sveltejs/kit'; -import { DatabaseWrites, prisma } from 'sil.appbuilder.portal.common'; +import { BullMQ, DatabaseWrites, prisma, queues } from 'sil.appbuilder.portal.common'; import { RoleId, WorkflowType } from 'sil.appbuilder.portal.common/prisma'; import { fail, superValidate } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; @@ -31,43 +31,115 @@ const updateOwnerGroupSchema = v.object({ const addProductSchema = v.object({ productDefinitionId: idSchema, storeId: idSchema, - storeLanguageId: idSchema, - workflowJobId: idSchema, - workflowBuildId: idSchema, - workflowPublishId: idSchema + storeLanguageId: v.nullable(idSchema) }); // Are we sending too much data? +// Maybe? I pared it down a bit with `select` instead of `include` - Aidan export const load = (async ({ locals, params }) => { if (!verifyCanViewAndEdit((await locals.auth())!, parseInt(params.id))) return error(403); const project = await prisma.projects.findUnique({ where: { Id: parseInt(params.id) }, - include: { - ApplicationType: true, + select: { + Id: true, + Name: true, + Description: true, + WorkflowProjectUrl: true, + IsPublic: true, + AllowDownloads: true, + DateCreated: true, + Language: true, + ApplicationType: { + select: { + Description: true + } + }, + Organization: { + select: { + Id: true + } + }, Products: { - include: { + select: { + Id: true, + DateUpdated: true, + DatePublished: true, ProductDefinition: { - include: { - Workflow: true + select: { + Id: true, + Name: true + } + }, + // Probably don't need to optimize this. Unless it's a really large org, there probably won't be very many of these records for an individual product. In most cases, there will only be zero or one. The only times there will be more is if it's an admin task or an author task. + UserTasks: { + select: { + DateCreated: true, + UserId: true } }, - UserTasks: true, - Store: true + Store: { + select: { + Description: true + } + } + } + }, + Owner: { + select: { + Id: true, + Name: true + } + }, + Group: { + select: { + Id: true, + Abbreviation: true } }, - Owner: true, - Group: true, Authors: { - include: { - Users: true + select: { + Id: true, + Users: { + select: { + Id: true, + Name: true + } + } } }, - Reviewers: true + Reviewers: { + select: { + Id: true, + Name: true, + Email: true + } + } } }); if (!project) return error(400); + + const organization = await prisma.organizations.findUnique({ + where: { + Id: project.Organization.Id + }, + select: { + OrganizationStores: { + select: { + Store: { + select: { + Id: true, + Name: true, + Description: true, + StoreTypeId: true + } + } + } + } + } + }) + const transitions = await prisma.productTransitions.findMany({ where: { ProductId: { @@ -91,17 +163,43 @@ export const load = (async ({ locals, params }) => { where: { GroupMemberships: { some: { - GroupId: project?.GroupId + GroupId: project?.Group.Id } }, UserRoles: { some: { - OrganizationId: project?.OrganizationId, + OrganizationId: project?.Organization.Id, RoleId: RoleId.Author } } } }); + + const productDefinitions = (await prisma.organizationProductDefinitions.findMany({ + where: { + OrganizationId: project.Organization.Id, + ProductDefinition: { + ApplicationTypes: project.ApplicationType + } + }, + select: { + ProductDefinition: { + select: { + Id: true, + Name: true, + Description: true, + Workflow: { + select: { + StoreTypeId: true + } + } + } + } + } + })).map((pd) => pd.ProductDefinition); + + const projectProductDefinitionIds = project.Products.map((p) => p.ProductDefinition.Id); + const authorForm = await superValidate(valibot(addAuthorSchema)); const reviewerForm = await superValidate({ language: 'en-us' }, valibot(addReviewerSchema)); return { @@ -111,10 +209,10 @@ export const load = (async ({ locals, params }) => { ...product, Transitions: transitions.filter((t) => t.ProductId === product.Id), PreviousTransition: strippedTransitions.find( - (t) => (t[0] ?? t[1]).ProductId === product.Id + (t) => (t[0] ?? t[1])?.ProductId === product.Id )?.[0], ActiveTransition: strippedTransitions.find( - (t) => (t[0] ?? t[1]).ProductId === product.Id + (t) => (t[0] ?? t[1])?.ProductId === product.Id )?.[1] })) }, @@ -122,21 +220,24 @@ export const load = (async ({ locals, params }) => { where: { OrganizationMemberships: { some: { - OrganizationId: project.OrganizationId + OrganizationId: project.Organization.Id } } } }), possibleGroups: await prisma.groups.findMany({ where: { - OwnerId: project.OrganizationId + OwnerId: project.Organization.Id } }), authorsToAdd, authorForm, reviewerForm, deleteAuthorForm: await superValidate(valibot(deleteAuthorSchema)), - deleteReviewerForm: await superValidate(valibot(deleteReviewerSchema)) + deleteReviewerForm: await superValidate(valibot(deleteReviewerSchema)), + productsToAdd: productDefinitions.filter((pd) => !projectProductDefinitionIds.includes(pd.Id)), + addProductForm: await superValidate(valibot(addProductSchema)), + stores: organization?.OrganizationStores.map((os) => os.Store) ?? [] }; }) satisfies PageServerLoad; @@ -154,6 +255,20 @@ export const actions = { return fail(403); const form = await superValidate(event.request, valibot(deleteAuthorSchema)); if (!form.valid) return fail(400, { form, ok: false }); + // TODO: Will this result in the desired behavior if a user has multiple roles? + // What if, for some unfathomable reason, a user is both a project owner + // and an Author on the same project? As of right now, all tasks for that user + // in that project will be deleted, regardless of role. Should a user be prevented from having more than one role for a project? + await queues.scriptoria.add(`Remove UserTasks for Author #${form.data.id}`, { + type: BullMQ.ScriptoriaJobType.UserTasks_Modify, + scope: 'Project', + projectId: parseInt(event.params.id), + operation: { + type: BullMQ.UserTasks.OpType.Delete, + by: 'UserId', + users: [form.data.id] + } + }); await DatabaseWrites.authors.delete({ where: { Id: form.data.id } }); return { form, ok: true }; }, @@ -172,18 +287,18 @@ export const actions = { async addProduct(event) { if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id))) return fail(403); - // TODO: api and bulltask const form = await superValidate(event.request, valibot(addProductSchema)); if (!form.valid) return fail(400, { form, ok: false }); + console.log(JSON.stringify(form, null, 4)); // Appears that CanUpdate is not used TODO const productId = await DatabaseWrites.products.create({ ProjectId: parseInt(event.params.id), ProductDefinitionId: form.data.productDefinitionId, StoreId: form.data.storeId, - StoreLanguageId: form.data.storeLanguageId, - WorkflowJobId: form.data.workflowJobId, - WorkflowBuildId: form.data.workflowBuildId, - WorkflowPublishId: form.data.workflowPublishId + StoreLanguageId: form.data.storeLanguageId ?? undefined, + WorkflowJobId: 0, + WorkflowBuildId: 0, + WorkflowPublishId: 0 }); if (typeof productId === 'string') { @@ -194,7 +309,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 } diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.svelte b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.svelte index 6713b91fc..28ad9b11e 100644 --- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.svelte +++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.svelte @@ -35,6 +35,11 @@ } } ); + const { + form: addProductForm, + enhance: addProductEnhance, + submit: addProductSubmit + } = superForm(data.addProductForm); function openModal(id: string) { (window[('modal' + id) as any] as any).showModal(); } @@ -82,6 +87,10 @@ ownerSettingsForm.requestSubmit(); }, 2000); } + + let addProductModal: HTMLDialogElement | undefined; + let selectingStore: boolean = false; + let selectedProduct: number = 0;
@@ -136,9 +145,77 @@ {m.products_definition()}
- + +
+ + + + +
+ +
{#if !data.project?.Products.length} @@ -181,7 +258,7 @@ {m.project_productFiles()} - {#if data.session?.user.roles.find((role) => role[0] === data.project?.OrganizationId && role[1] === RoleId.OrgAdmin)} + {#if data.session?.user.roles.find((role) => role[0] === data.project?.Organization.Id && role[1] === RoleId.OrgAdmin)}
  • @@ -218,6 +295,7 @@ null ) })} + {m.tasks_forNames({ allowedNames: product.ActiveTransition?.AllowedUserNames ?? 'Scriptoria', activityName: product.ActiveTransition?.InitialState ?? '' @@ -286,7 +364,13 @@ {/if}
    - + {m.project_products_publications_console()} {:else} @@ -386,7 +470,7 @@ /> - {data.organizations.find((o) => data.project?.OrganizationId === o.Id)?.Name} + {data.organizations.find((o) => data.project?.Organization.Id === o.Id)?.Name}
  • @@ -413,7 +497,7 @@
    diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/new/[id=idNumber]/+page.server.ts b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/new/[id=idNumber]/+page.server.ts new file mode 100644 index 000000000..d080060d9 --- /dev/null +++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/new/[id=idNumber]/+page.server.ts @@ -0,0 +1,112 @@ +import { idSchema } from '$lib/valibot'; +import { error } from '@sveltejs/kit'; +import { DatabaseWrites, prisma } from 'sil.appbuilder.portal.common'; +import { fail, superValidate } from 'sveltekit-superforms'; +import { valibot } from 'sveltekit-superforms/adapters'; +import * as v from 'valibot'; +import type { Session } from '@auth/sveltekit'; +import type { Actions, PageServerLoad } from './$types'; +import { RoleId } from 'sil.appbuilder.portal.common/prisma'; +import { queues, BullMQ } from 'sil.appbuilder.portal.common'; + +const projectCreateSchema = v.object({ + name: v.pipe(v.string(), v.minLength(1)), + group: idSchema, + language: v.pipe(v.string(), v.minLength(1)), + type: idSchema, + description: v.nullable(v.string()), + public: v.boolean() +}); + +export const load = (async ({ locals, params }) => { + if (!verifyCanCreateProject((await locals.auth())!, parseInt(params.id))) return error(403); + + const organization = await prisma.organizations.findUnique({ + where: { + Id: parseInt(params.id) + }, + select: { + Groups: { + select: { + Id: true, + Name: true + } + }, + OrganizationProductDefinitions: { + select: { + ProductDefinition: { + select: { + ApplicationTypes: true + } + } + } + }, + PublicByDefault: true + } + }); + + const types = organization?.OrganizationProductDefinitions.map( + (opd) => opd.ProductDefinition.ApplicationTypes + ).reduce((p, c) => { + if (!p.some((e) => e.Id === c.Id)) { + p.push(c); + } + return p; + }, [] as { Id: number; Name: string | null; Description: string | null }[]); + + const form = await superValidate( + { + group: organization?.Groups[0]?.Id ?? undefined, + type: types?.[0].Id ?? undefined, + public: organization?.PublicByDefault ?? undefined + }, + valibot(projectCreateSchema) + ); + return { form, organization, types }; +}) satisfies PageServerLoad; + +export const actions: Actions = { + default: async function (event) { + const session = (await event.locals.auth())!; + if (!verifyCanCreateProject(session, parseInt(event.params.id))) return error(403); + + const form = await superValidate(event.request, valibot(projectCreateSchema)); + // TODO: Return/Display error messages + if (!form.valid) return fail(400, { form, ok: false }); + if (isNaN(parseInt(event.params.id))) return fail(400, { form, ok: false }); + const timestamp = (new Date()).toString(); + const project = await DatabaseWrites.projects.create({ + OrganizationId: parseInt(event.params.id), + Name: form.data.name, + GroupId: form.data.group, + OwnerId: session.user.userId, + Language: form.data.language, + TypeId: form.data.type, + Description: form.data.description ?? '', + DateCreated: timestamp, + DateUpdated: timestamp + // TODO: DateActive? + }); + + if (project !== false) { + queues.scriptoria.add(`Create Project #${project}`, { + type: BullMQ.ScriptoriaJobType.Project_Create, + projectId: project as number + }); + } + + return { form, ok: project !== false }; + } +}; + +async function verifyCanCreateProject(user: Session, orgId: number) { + // Creating a project is allowed if the user is an OrgAdmin or AppBuilder for the organization or a SuperAdmin + const roles = user.user.roles + .filter(([org, role]) => org === orgId || role === RoleId.SuperAdmin) + .map(([org, role]) => role); + return ( + roles.includes(RoleId.AppBuilder) || + roles.includes(RoleId.OrgAdmin) || + roles.includes(RoleId.SuperAdmin) + ); +} diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/new/[id=idNumber]/+page.svelte b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/new/[id=idNumber]/+page.svelte new file mode 100644 index 000000000..2126eaeeb --- /dev/null +++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/new/[id=idNumber]/+page.svelte @@ -0,0 +1,86 @@ + + +
    +
    +

    {m.project_newProject()}

    +
    +
    + + +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    +