Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/lib/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,5 +556,12 @@
"system_buildFailed": "Build failed",
"system_publishFailed": "Publish failed",
"system_unavailable": "Scriptoria is currently unable to process background tasks",
"downloads_title": "Downloads"
}
"downloads_title": "Downloads",
"admin_nav_software_update": "Software Update",
"admin_nav_software_update_description": "Initiate a rebuild of all the projects that have the \"Rebuild on software update\" setting enabled, have a product that has completed the initial publication, and are not currently being rebuilt. All products in this state will be rebuilt.",
"admin_nav_software_update_comment": "Comment",
"admin_software_update_rebuild_start": "Start Rebuilds",
"admin_software_update_comment_required": "A comment is required to start builds.",
"admin_software_update_toast_success": "Successfully started rebuilds!",
"admin_software_update_affected_organizations": "This will affect the following organizations:"
}
11 changes: 9 additions & 2 deletions src/lib/locales/es-419.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,5 +556,12 @@
"system_buildFailed": "Build failed",
"system_publishFailed": "Publish failed",
"system_unavailable": "Scriptoria actualmente no puede procesar tareas",
"downloads_title": "Downloads"
}
"downloads_title": "Downloads",
"admin_nav_software_update": "Actualización de Software",
"admin_nav_software_update_description": "Inicie una reconstrucción de todos los proyectos que tengan habilitada la configuración de \"Reconstrucción en la actualización de software\", tengan un producto que haya completado la publicación inicial y que actualmente no se estén reconstruyendo. Todos los productos en este estado serán reconstruidos.",
"admin_nav_software_update_comment": "Comentario",
"admin_software_update_rebuild_start": "Empezar a Reconstruir",
"admin_software_update_comment_required": "Se requiere un comentario para iniciar las compilaciones.",
"admin_software_update_toast_success": "¡Comenzó con éxito las reconstrucciones!",
"admin_software_update_affected_organizations": "Esto afectará a las siguientes organizaciones:"
}
9 changes: 8 additions & 1 deletion src/lib/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -555,5 +555,12 @@
"system_buildFailed": "Build failed",
"system_publishFailed": "Publish failed",
"system_unavailable": "Scriptoria is currently unable to process background tasks",
"downloads_title": "Downloads"
"downloads_title": "Downloads",
"admin_nav_software_update": "Mise à jour du logiciel",
"admin_nav_software_update_description": "Initier une reconstruction de tous les projets qui ont activé le paramètre \"Reconstruire sur la mise à jour du logiciel\", qui ont un produit qui a terminé la publication initiale et qui ne sont pas actuellement en cours de reconstruction. Tous les produits dans cet état seront reconstruits.",
"admin_nav_software_update_comment": "Comment",
"admin_software_update_rebuild_start": "Commencer les Reconstructions",
"admin_software_update_comment_required": "Un commentaire est requis pour commencer les constructions.",
"admin_software_update_toast_success": "Les reconstructions ont commencé avec succès !",
"admin_software_update_affected_organizations": "Cela affectera les organisations suivantes :"
}
21 changes: 15 additions & 6 deletions src/lib/products/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { DatabaseReads, DatabaseWrites } from '$lib/server/database';
import { Workflow } from '$lib/server/workflow';
import { ProductActionType } from '.';

export async function doProductAction(productId: string, action: ProductActionType) {
export async function doProductAction(
productId: string,
action: ProductActionType,
comment?: string
) {
const product = await DatabaseReads.products.findUnique({
where: {
Id: productId
Expand Down Expand Up @@ -46,11 +50,15 @@ export async function doProductAction(productId: string, action: ProductActionTy
case ProductActionType.Republish: {
const flowType = action === 'rebuild' ? 'RebuildWorkflow' : 'RepublishWorkflow';
if (product.ProductDefinition[flowType] && !product.WorkflowInstance) {
await Workflow.create(productId, {
productType: product.ProductDefinition[flowType].ProductType,
options: new Set(product.ProductDefinition[flowType].WorkflowOptions),
workflowType: product.ProductDefinition[flowType].Type
});
await Workflow.create(
productId,
{
productType: product.ProductDefinition[flowType].ProductType,
options: new Set(product.ProductDefinition[flowType].WorkflowOptions),
workflowType: product.ProductDefinition[flowType].Type
},
comment
);
}
break;
}
Expand All @@ -76,6 +84,7 @@ export async function doProductAction(productId: string, action: ProductActionTy
// This is how S1 does it. May want to change later
AllowedUserNames: '',
DateTransition: new Date(),
Comment: comment,
TransitionType: ProductTransitionType.CancelWorkflow,
WorkflowType: product.WorkflowInstance.WorkflowDefinition.Type
}
Expand Down
7 changes: 6 additions & 1 deletion src/lib/server/workflow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export class Workflow {

/* PUBLIC METHODS */
/** Create a new workflow instance and populate the database tables. */
public static async create(productId: string, config: WorkflowConfig): Promise<Workflow> {
public static async create(
productId: string,
config: WorkflowConfig,
comment?: string
): Promise<Workflow> {
const check = await DatabaseReads.products.findUnique({
where: {
Id: productId
Expand Down Expand Up @@ -88,6 +92,7 @@ export class Workflow {
data: {
ProductId: productId,
DateTransition: new Date(),
Comment: comment,
TransitionType: ProductTransitionType.StartWorkflow,
WorkflowType: config.workflowType
}
Expand Down
10 changes: 10 additions & 0 deletions src/routes/(authenticated)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,16 @@
{m.sidebar_orgSettings()}
</a>
</li>
<li>
<a
class="rounded-none"
class:active-menu-item={isUrlActive('/software-update')}
href={activeOrgUrl('/software-update')}
onclick={closeDrawer}
>
{m.admin_nav_software_update()}
</a>
</li>
{/if}
{#if isSuperAdmin(data.session.user.roles)}
<li>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { fail } from '@sveltejs/kit';
import { superValidate } from 'sveltekit-superforms';
import { valibot } from 'sveltekit-superforms/adapters';
import * as v from 'valibot';
import type { Actions, PageServerLoad, RouteParams } from './$types';
import { RoleId } from '$lib/prisma';
import { ProductActionType } from '$lib/products';
import { doProductAction } from '$lib/products/server';
import { DatabaseReads } from '$lib/server/database';

/// HELPERS

/**
* Determines the target organizations based on the user's roles.
* If the user is a super admin, all organizations are returned.
* Otherwise, only organizations where the user is an org admin are returned.
* @param locals The request locals containing security information.
* @returns An array of organization IDs.
*/
async function determineTargetOrgs(locals: App.Locals): Promise<number[]> {
if (locals.security.isSuperAdmin) {
const orgs = await DatabaseReads.organizations.findMany({
select: { Id: true }
});
return orgs.map((o) => o.Id);
}

const roles = await DatabaseReads.userRoles.findMany({
where: {
UserId: locals.security.userId,
RoleId: { in: [RoleId.SuperAdmin, RoleId.OrgAdmin] }
},
select: { OrganizationId: true }
});

return Array.from(new Set(roles.map((r) => r.OrganizationId)));
}

async function getOrganizations(locals: App.Locals, params: RouteParams): Promise<number[]> {
// Determine what organizations are being affected
const organizationId = Number(params.orgId);
const searchOrgs: number[] = isNaN(organizationId)
? await determineTargetOrgs(locals)
: [organizationId];
return searchOrgs;
}

interface ProductToRebuild {
id: string; // Product ID (UUID)
latestVersion: string | null;
requiredVersion: string | null;
}

/**
* Fetches products that need to be rebuilt based on the provided organizations.
* Checks to make sure the product is part of a project that has rebuildOnSoftwareUpdate enabled,
* and that the latest product build's AppBuilderVersion does not match the required SystemVersion.
* @param searchOrgs An array or organizations to include products from.
* @returns Array of Pro
*/
async function getProductsForRebuild(searchOrgs: number[]): Promise<ProductToRebuild[]> {
// 1. Fetch all products that meet the initial Project/Organization criteria.
const eligibleProducts = await DatabaseReads.products.findMany({
where: {
Project: {
OrganizationId: { in: searchOrgs },
DateArchived: null, // Project has not been archived
RebuildOnSoftwareUpdate: true // Project setting is true
}
},
select: {
Id: true,
WorkflowBuildId: true, // We need this to identify the latest build, assuming WorkflowBuildId is monotonically increasing
ProductDefinitionId: true,
ProjectId: true,
ProductBuilds: {
orderBy: { Id: 'desc' }, // Order by ID descending to get the 'latest' build
take: 1, // Only take the most recent
select: {
AppBuilderVersion: true
}
},
Project: {
select: {
TypeId: true,
Organization: {
select: {
BuildEngineUrl: true
}
}
}
}
}
});

const productsForRebuild: ProductToRebuild[] = [];

// 2. Iterate through eligible products to perform the cross-model version check.
for (const product of eligibleProducts) {
const latestProductBuild = product.ProductBuilds[0];
const latestVersion = latestProductBuild?.AppBuilderVersion ?? null;

// Get the required SystemVersion for this specific project's type and organization's build engine URL.
const requiredSystemVersion = await DatabaseReads.systemVersions.findUnique({
where: {
BuildEngineUrl_ApplicationTypeId: {
BuildEngineUrl: product.Project.Organization.BuildEngineUrl ?? '',
ApplicationTypeId: product.Project.TypeId
}
},
select: {
Version: true
}
});

const requiredVersion = requiredSystemVersion?.Version ?? null;

// 3. Apply the final filtering logic:
// Is the latest build version NOT equal to the required system version?
if (requiredVersion && latestVersion !== requiredVersion) {
productsForRebuild.push({
id: product.Id,
latestVersion: latestVersion,
requiredVersion: requiredVersion
});
}
}

return productsForRebuild;
}

const formSchema = v.object({
comment: v.pipe(v.string(), v.minLength(1, 'Comment is required'))
// Since we are only getting a comment, I do not believe we need a properties: propertiesSchema here.
});

export const load = (async ({ url, locals, params }) => {
// Determine what organizations are being affected
const searchOrgs = await getOrganizations(locals, params);
if (Number(params.orgId)) locals.security.requireAdminOfOrgIn(searchOrgs);
else locals.security.requireAdminOfOrg(Number(params.orgId));
// Translate organization IDs to names for readability
const names = await DatabaseReads.organizations.findMany({
where: {
Id: { in: searchOrgs }
},
select: {
Name: true
}
});
const organizationsReadable = names.map((n) => n.Name ?? 'Unknown Organization');

// TODO: @becca-perk? Use information to determine whether to show 'start' or 'pause' on button and action being called.
// Check for rebuild status...

const form = await superValidate(valibot(formSchema));
return { form, organizations: organizationsReadable.join(', ') };
}) satisfies PageServerLoad;

/// ACTIONS
export const actions = {
//
/// START: Starts rebuilds for affected organizations.
//
async start({ cookies, request, locals, params }) {
// Check that form is valid upon submission
const form = await superValidate(request, valibot(formSchema));
if (!form.valid) {
return fail(400, { form, ok: false });
}

// Determine what organizations are being affected and check security
const searchOrgs = await getOrganizations(locals, params);
if (isNaN(Number(params.orgId))) locals.security.requireAdminOfOrgIn(searchOrgs);
else locals.security.requireAdminOfOrg(Number(params.orgId));

const productsToRebuild = await getProductsForRebuild(searchOrgs);

await Promise.all(
productsToRebuild.map((p) =>
doProductAction(p.id, ProductActionType.Rebuild, form.data.comment)
)
);

return { form, ok: true };
}
} satisfies Actions;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<script lang="ts">
import { superForm } from 'sveltekit-superforms';
import type { PageData } from './$types';
import LabeledFormInput from '$lib/components/settings/LabeledFormInput.svelte';
import { m } from '$lib/paraglide/messages';
import { toast } from '$lib/utils';

interface Props {
data: PageData;
}

let { data }: Props = $props();

const { form, enhance } = superForm(data.form, {
onUpdated({ form }) {
if (form.valid) {
toast('success', m.admin_software_update_toast_success());
}
}
});
</script>

<div class="w-full">
<h1>{m.admin_nav_software_update()}</h1>
<div class="m-4">
<p class="pl-4">{m.admin_nav_software_update_description()}</p>
<br />
<p class="pl-4">
<b>{m.admin_software_update_affected_organizations()} {data.organizations}</b>
</p>
<br />
<form class="pl-4" method="post" action="?/start" use:enhance>
<LabeledFormInput key="admin_nav_software_update_comment">
<input
type="text"
name="comment"
class="input input-bordered w-full validator"
bind:value={$form.comment}
required
/>
<span class="validator-hint">{m.admin_software_update_comment_required()}</span>
</LabeledFormInput>
<input
type="submit"
class="btn btn-primary"
value={m.admin_software_update_rebuild_start()}
/>
</form>
</div>
</div>
Loading