From 9edf2d5bccb31ad23b35619878ee3b3daa92e6f1 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Mon, 2 Mar 2026 10:50:39 -0600 Subject: [PATCH 01/20] Improve packet size of user tasks ~30% reduction - Palettes for Project Name & Product Definition Name - Reduced key length --- src/lib/icons/index.ts | 2 +- src/lib/projects/sse.ts | 103 +++++++++++++----- src/lib/utils/sorting.ts | 7 ++ src/routes/(authenticated)/+layout.server.ts | 5 +- src/routes/(authenticated)/+layout.svelte | 2 +- src/routes/(authenticated)/tasks/+page.svelte | 60 +++++----- .../tasks/[product_id=uuid]/+page.svelte | 8 +- 7 files changed, 116 insertions(+), 71 deletions(-) diff --git a/src/lib/icons/index.ts b/src/lib/icons/index.ts index f9affb23cc..f75c9b798c 100644 --- a/src/lib/icons/index.ts +++ b/src/lib/icons/index.ts @@ -94,7 +94,7 @@ export function getFlagIcon( } } -export function getProductIcon(type: ProductType) { +export function getProductIcon(type?: ProductType) { switch (type) { case ProductType.Web: return 'mdi:web'; diff --git a/src/lib/projects/sse.ts b/src/lib/projects/sse.ts index 78495a331a..24859c6c02 100644 --- a/src/lib/projects/sse.ts +++ b/src/lib/projects/sse.ts @@ -7,6 +7,7 @@ import { userGroupsForOrg } from '$lib/projects/server'; import { getURLandToken } from '$lib/server/build-engine-api/requests'; import { DatabaseReads } from '$lib/server/database'; import { isSuperAdmin } from '$lib/utils/roles'; +import { byDate } from '$lib/utils/sorting'; const tracer = trace.getTracer('ProjectSSE'); export type ProjectDataSSE = Awaited>; @@ -389,43 +390,91 @@ export async function getProjectDetails(id: number, userSession: Session['user'] }); } +/** + * S = Status + * C = Comment + * U = DateUpdated + * P = ProductId + * Pj = ProjectId + * PD = ProductDefinitionId + */ export type UserTaskDataSSE = Awaited>; export async function getUserTasks(userId: number) { - const tasks = await DatabaseReads.userTasks.findMany({ + const projects = await DatabaseReads.projects.findMany({ where: { - UserId: userId + Products: { + some: { + UserTasks: { + some: { + UserId: userId + } + } + } + } }, select: { - Status: true, - Comment: true, - DateUpdated: true, - ProductId: true, - Product: { - select: { - ProductDefinition: { - select: { - Name: true, - Workflow: { - select: { - ProductType: true - } - } + Id: true, + Name: true, + Products: { + where: { + UserTasks: { + some: { + UserId: userId } - }, - ProjectId: true, - Project: { + } + }, + select: { + Id: true, + ProductDefinitionId: true, + UserTasks: { select: { - Name: true - } + Status: true, + Comment: true, + DateUpdated: true, + ProductId: true + }, + orderBy: { + DateUpdated: 'desc' + }, + take: 1 } } } - }, - distinct: 'ProductId', - orderBy: { - // most recent first - DateUpdated: 'desc' } }); - return tasks; + return { + tasks: projects + .flatMap((pj) => + pj.Products.flatMap((p) => + p.UserTasks.map((u) => ({ + S: u.Status, + C: u.Comment, + U: u.DateUpdated, + P: u.ProductId, + Pj: pj.Id, + PD: p.ProductDefinitionId + })) + ) + ) + .sort((a, b) => byDate(a.U, b.U)), + projects: new Map(projects.map((p) => [p.Id, p.Name])), + products: new Map( + ( + await DatabaseReads.productDefinitions.findMany({ + where: { + Products: { some: { Id: { in: projects.flatMap((p) => p.Products.map((p) => p.Id)) } } } + }, + select: { + Id: true, + Name: true, + Workflow: { + select: { + ProductType: true + } + } + } + }) + ).map((pd) => [pd.Id, { N: pd.Name, T: pd.Workflow.ProductType }]) + ) + }; } diff --git a/src/lib/utils/sorting.ts b/src/lib/utils/sorting.ts index 22cce88557..abdc9f15c3 100644 --- a/src/lib/utils/sorting.ts +++ b/src/lib/utils/sorting.ts @@ -21,3 +21,10 @@ export function byString( export function byNumber(a: number | bigint | null, b: number | bigint | null): number { return a === b ? 0 : (a ?? 0) > (b ?? 0) ? 1 : -1; } + +/* null sorted last */ +export function byDate(a: Date | null, b: Date | null): number { + const da = a?.valueOf(); + const db = b?.valueOf(); + return da === db ? 0 : da === undefined ? 1 : db === undefined ? -1 : da < db ? -1 : 1; +} diff --git a/src/routes/(authenticated)/+layout.server.ts b/src/routes/(authenticated)/+layout.server.ts index 45a2f41c6b..0191a51523 100644 --- a/src/routes/(authenticated)/+layout.server.ts +++ b/src/routes/(authenticated)/+layout.server.ts @@ -6,7 +6,6 @@ import type { LayoutServerLoad } from './$types'; import { langtagSchema } from '$lib/ldml'; import { readLDML } from '$lib/ldml/server'; import { locales } from '$lib/paraglide/runtime'; -import { getUserTasks } from '$lib/projects/sse'; import { QueueConnected } from '$lib/server/bullmq/queues'; import { DatabaseReads } from '$lib/server/database'; @@ -31,7 +30,9 @@ export const load: LayoutServerLoad = async (event) => { return { organizations, - userTasks: await getUserTasks(sec.userId), + userTasksCount: await DatabaseReads.products.count({ + where: { UserTasks: { some: { UserId: sec.userId } } } + }), // streaming promise langtags: await readFile(join(localDir, 'langtags.dev')) .then((j) => { diff --git a/src/routes/(authenticated)/+layout.svelte b/src/routes/(authenticated)/+layout.svelte index d5fc051724..0b5be170cc 100644 --- a/src/routes/(authenticated)/+layout.svelte +++ b/src/routes/(authenticated)/+layout.svelte @@ -53,7 +53,7 @@ } }); - const userTasksLength = $derived($userTasksSSE?.length ?? data.userTasks.length); + const userTasksLength = $derived($userTasksSSE?.tasks.length ?? data.userTasksCount); let selectingOrg = $state(false); const selectedOrg = $derived(data.organizations.find((o) => o.Id === $orgActive)); diff --git a/src/routes/(authenticated)/tasks/+page.svelte b/src/routes/(authenticated)/tasks/+page.svelte index 3049febfc0..8892cf16eb 100644 --- a/src/routes/(authenticated)/tasks/+page.svelte +++ b/src/routes/(authenticated)/tasks/+page.svelte @@ -18,13 +18,13 @@ const userTasks = $derived($userTasksSSE ?? data.userTasks); - const dateUpdated = $derived(getRelativeTime(userTasks.map((task) => task.DateUpdated))); + const dateUpdated = $derived(getRelativeTime(userTasks.tasks.map((task) => task.U)));

{m.tasks_title()}

- {#if userTasks.length > 0} + {#if userTasks.tasks.length > 0} @@ -34,19 +34,19 @@ - {#each userTasks as task, i} + {#each userTasks.tasks as task, i} goto(localizeHref(`/tasks/${task.ProductId}`))} + onclick={() => goto(localizeHref(`/tasks/${task.P}`))} > @@ -54,36 +54,29 @@ - - + - {#if task.Comment} + {#if task.C} {/if} @@ -99,46 +92,43 @@ - {#each userTasks as task, i} + {#each userTasks.tasks as task, i} goto(localizeHref(`/tasks/${task.ProductId}`))} - class:no-border={task.Comment} + onclick={() => goto(localizeHref(`/tasks/${task.P}`))} + class:no-border={task.C} > - {#if task.Comment} + {#if task.C} {/if} diff --git a/src/routes/(authenticated)/tasks/[product_id=uuid]/+page.svelte b/src/routes/(authenticated)/tasks/[product_id=uuid]/+page.svelte index 35ffae137b..90778197b7 100644 --- a/src/routes/(authenticated)/tasks/[product_id=uuid]/+page.svelte +++ b/src/routes/(authenticated)/tasks/[product_id=uuid]/+page.svelte @@ -58,12 +58,10 @@ let waiting = $state(false); $effect(() => { - if ($userTasksSSE?.length) { - const productTasks = $userTasksSSE.filter((t) => t.ProductId === page.params.product_id); + if ($userTasksSSE?.tasks.length) { + const productTasks = $userTasksSSE.tasks.filter((t) => t.P === page.params.product_id); const fallback = new Date().valueOf(); - const oldTask = productTasks.find( - (t) => (t.DateUpdated?.valueOf() ?? fallback) <= data.loadTime - ); + const oldTask = productTasks.find((t) => (t.U?.valueOf() ?? fallback) <= data.loadTime); const waitRead = untrack(() => waiting); // waiting and task updated if (waitRead && productTasks.length && !oldTask) { From c5b6b48370029105934a84eee177487bee28c72f Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Wed, 4 Mar 2026 15:01:53 -0600 Subject: [PATCH 02/20] Split group data out from project details --- src/lib/bullmq.ts | 1 + src/lib/projects/listener.ts | 1 + src/lib/projects/sse.ts | 177 +++++++++++------- src/lib/server/bullmq/BullWorker.ts | 3 + src/lib/server/bullmq/types.ts | 7 + src/lib/server/database/Authors.ts | 4 +- src/lib/server/database/Projects.ts | 17 +- src/lib/server/database/Reviewers.ts | 4 +- .../projects/[id=number]/+page.server.ts | 3 +- .../projects/[id=number]/+page.svelte | 58 +++--- .../[id=number]/sse/groups/+server.ts | 51 +++++ 11 files changed, 224 insertions(+), 102 deletions(-) create mode 100644 src/routes/(authenticated)/projects/[id=number]/sse/groups/+server.ts diff --git a/src/lib/bullmq.ts b/src/lib/bullmq.ts index cbee147bef..d9449e2dd7 100644 --- a/src/lib/bullmq.ts +++ b/src/lib/bullmq.ts @@ -52,6 +52,7 @@ export enum JobType { Email_ProjectImportReport = 'Project Import Report', // Svelte Project SSE SvelteSSE_UpdateProject = 'Update Project', + SvelteSSE_UpdateProjectGroups = 'Update Project Groups', SvelteSSE_UpdateUserTasks = 'Update UserTasks' } diff --git a/src/lib/projects/listener.ts b/src/lib/projects/listener.ts index d2ec13f514..95d1fff73f 100644 --- a/src/lib/projects/listener.ts +++ b/src/lib/projects/listener.ts @@ -3,6 +3,7 @@ import EventEmitter from 'events'; export const SSEPageUpdates = new EventEmitter<{ projectPage: [number[]]; + projectGroups: [number[]]; userTasksPage: [number[]]; }>().setMaxListeners(400); // Allow 400 listeners (in the last 10 seconds) diff --git a/src/lib/projects/sse.ts b/src/lib/projects/sse.ts index 24859c6c02..0d8711409a 100644 --- a/src/lib/projects/sse.ts +++ b/src/lib/projects/sse.ts @@ -129,23 +129,6 @@ export async function getProjectDetails(id: number, userSession: Session['user'] Name: true } }, - Authors: { - select: { - User: { - select: { - Id: true, - Name: true - } - } - } - }, - Reviewers: { - select: { - Id: true, - Name: true, - Email: true - } - }, ProjectActions: { select: { User: { @@ -274,56 +257,6 @@ export async function getProjectDetails(id: number, userSession: Session['user'] (pd) => !projectProductDefinitionIds.includes(pd.Id) ), stores: organization?.Stores ?? [], - possibleProjectOwners: await DatabaseReads.users.findMany({ - where: { - Organizations: { - some: { - Id: project.OrganizationId - } - }, - Groups: { - some: { - Id: project.Group.Id - } - } - } - }), - // possibleGroups are ones owned by the same org as the project and contain the project's owner - possibleGroups: await DatabaseReads.groups.findMany({ - where: { - OwnerId: project.OrganizationId, - Users: { - some: { - Id: project.Owner.Id - } - } - } - }), - // All users who are members of the group and have the author role in the project's organization - // May be a more efficient way to search this, by referencing group memberships instead of users - authorsToAdd: await DatabaseReads.users.findMany({ - where: { - Groups: { - some: { - Id: project?.Group.Id - } - }, - UserRoles: { - some: { - OrganizationId: project?.OrganizationId, - RoleId: RoleId.Author - } - }, - Authors: { - none: { - ProjectId: project.Id - } - } - } - }), - userGroups: (await userGroupsForOrg(userSession.userId, project.OrganizationId)).map( - (g) => g.Id - ), actionParams: { users: await DatabaseReads.users.findMany({ where: { @@ -390,6 +323,116 @@ export async function getProjectDetails(id: number, userSession: Session['user'] }); } +export type ProjectGroupsSSE = Awaited>; +export async function getProjectGroupData(id: number, userSession: Session['user']) { + // permissions checked in auth + return tracer.startActiveSpan('getProjectGroups', async (span) => { + span.setAttributes({ + 'project.id': id, + 'project.userId': userSession.userId + }); + try { + const project = await DatabaseReads.projects.findUniqueOrThrow({ + where: { + Id: id + }, + select: { + OrganizationId: true, + OwnerId: true, + GroupId: true, + Authors: { + select: { + User: { + select: { + Id: true, + Name: true + } + } + } + }, + Reviewers: { + select: { + Id: true, + Name: true, + Email: true + } + } + } + }); + + return { + authors: project.Authors, + reviewers: project.Reviewers, + possibleOwners: await DatabaseReads.users.findMany({ + where: { + Organizations: { + some: { + Id: project.OrganizationId + } + }, + Groups: { + some: { + Id: project.GroupId + } + } + }, + select: { + Id: true, + Name: true + } + }), + // possibleGroups are ones owned by the same org as the project and contain the project's owner + possibleGroups: await DatabaseReads.groups.findMany({ + where: { + OwnerId: project.OrganizationId, + Users: { + some: { + Id: project.OwnerId + } + } + } + }), + // All users who are members of the group and have the author role in the project's organization + possibleAuthors: await DatabaseReads.users.findMany({ + where: { + Groups: { + some: { + Id: project.GroupId + } + }, + UserRoles: { + some: { + OrganizationId: project?.OrganizationId, + RoleId: RoleId.Author + } + }, + Authors: { + none: { + ProjectId: id + } + } + }, + select: { + Id: true, + Name: true + } + }), + userGroups: (await userGroupsForOrg(userSession.userId, project.OrganizationId)).map( + (g) => g.Id + ) + }; + } catch (e) { + span.recordException(e as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: (e as Error).message + }); + } finally { + span.end(); + } + }); +} + /** * S = Status * C = Comment diff --git a/src/lib/server/bullmq/BullWorker.ts b/src/lib/server/bullmq/BullWorker.ts index c321ac6cad..ce4a2dd416 100644 --- a/src/lib/server/bullmq/BullWorker.ts +++ b/src/lib/server/bullmq/BullWorker.ts @@ -299,6 +299,9 @@ export class SvelteSSE extends BullWorker { case BullMQ.JobType.SvelteSSE_UpdateProject: SSEPageUpdates.emit('projectPage', job.data.projectIds); break; + case BullMQ.JobType.SvelteSSE_UpdateProjectGroups: + SSEPageUpdates.emit('projectGroups', job.data.projectIds); + break; case BullMQ.JobType.SvelteSSE_UpdateUserTasks: SSEPageUpdates.emit('userTasksPage', job.data.userIds); break; diff --git a/src/lib/server/bullmq/types.ts b/src/lib/server/bullmq/types.ts index 7ae1c92917..639a465793 100644 --- a/src/lib/server/bullmq/types.ts +++ b/src/lib/server/bullmq/types.ts @@ -290,6 +290,11 @@ export namespace SvelteProjectSSE { projectIds: number[]; } + export interface UpdateGroups extends BaseJob { + type: JobType.SvelteSSE_UpdateProjectGroups; + projectIds: number[]; + } + export interface UpdateUserTasks extends BaseJob { type: JobType.SvelteSSE_UpdateUserTasks; userIds: number[]; @@ -328,6 +333,7 @@ export type EmailJob = JobTypeMap[ | JobType.Email_ProjectImportReport]; export type SvelteSSEJob = JobTypeMap[ | JobType.SvelteSSE_UpdateProject + | JobType.SvelteSSE_UpdateProjectGroups | JobType.SvelteSSE_UpdateUserTasks]; export type ProductJob = JobTypeMap[ | JobType.Product_Create @@ -369,6 +375,7 @@ export type JobTypeMap = { [JobType.Email_NotifySuperAdminsLowPriority]: Email.NotifySuperAdminsLowPriority; [JobType.Email_ProjectImportReport]: Email.ProjectImportReport; [JobType.SvelteSSE_UpdateProject]: SvelteProjectSSE.UpdateProject; + [JobType.SvelteSSE_UpdateProjectGroups]: SvelteProjectSSE.UpdateGroups; [JobType.SvelteSSE_UpdateUserTasks]: SvelteProjectSSE.UpdateUserTasks; // Add more mappings here as needed }; diff --git a/src/lib/server/database/Authors.ts b/src/lib/server/database/Authors.ts index a1829a6e1c..adcf4cae22 100644 --- a/src/lib/server/database/Authors.ts +++ b/src/lib/server/database/Authors.ts @@ -10,7 +10,7 @@ async function deleteAuthor(ProjectId: number, UserId: number) { if (ret.count) { getQueues().SvelteSSE.add(`Update Project #${ProjectId} (author #${UserId} removed)`, { - type: BullMQ.JobType.SvelteSSE_UpdateProject, + type: BullMQ.JobType.SvelteSSE_UpdateProjectGroups, projectIds: [ProjectId] }); } @@ -24,7 +24,7 @@ export async function create(authorData: Prisma.AuthorsUncheckedCreateInput) { getQueues().SvelteSSE.add( `Update Project #${authorData.ProjectId} (author #${authorData.UserId} added)`, { - type: BullMQ.JobType.SvelteSSE_UpdateProject, + type: BullMQ.JobType.SvelteSSE_UpdateProjectGroups, projectIds: [authorData.ProjectId] } ); diff --git a/src/lib/server/database/Projects.ts b/src/lib/server/database/Projects.ts index 93dc36e208..5ab44a169c 100644 --- a/src/lib/server/database/Projects.ts +++ b/src/lib/server/database/Projects.ts @@ -91,13 +91,22 @@ export async function update( } ); } + + // If the group has changed + if (groupId && groupId !== existing?.GroupId) { + getQueues().SvelteSSE.add(`Update Project #${id} (update groups)`, { + type: BullMQ.JobType.SvelteSSE_UpdateProjectGroups, + projectIds: [id] + }); + } + + getQueues().SvelteSSE.add(`Update Project #${id} (update details)`, { + type: BullMQ.JobType.SvelteSSE_UpdateProject, + projectIds: [id] + }); } catch { return false; } - getQueues().SvelteSSE.add(`Update Project #${id} (update details)`, { - type: BullMQ.JobType.SvelteSSE_UpdateProject, - projectIds: [id] - }); return true; } diff --git a/src/lib/server/database/Reviewers.ts b/src/lib/server/database/Reviewers.ts index b99a0df57b..58b3689742 100644 --- a/src/lib/server/database/Reviewers.ts +++ b/src/lib/server/database/Reviewers.ts @@ -15,7 +15,7 @@ async function deleteReviewer(id: number) { }); getQueues().SvelteSSE.add(`Update Project #${reviewer.ProjectId} (reviewer removed)`, { - type: BullMQ.JobType.SvelteSSE_UpdateProject, + type: BullMQ.JobType.SvelteSSE_UpdateProjectGroups, projectIds: [reviewer.ProjectId] }); return ret; @@ -28,7 +28,7 @@ export async function create(reviewerData: Prisma.ReviewersUncheckedCreateInput) data: reviewerData }); getQueues().SvelteSSE.add(`Update Project #${reviewerData.ProjectId} (reviewer added)`, { - type: BullMQ.JobType.SvelteSSE_UpdateProject, + type: BullMQ.JobType.SvelteSSE_UpdateProjectGroups, projectIds: [reviewerData.ProjectId] }); return ret; diff --git a/src/routes/(authenticated)/projects/[id=number]/+page.server.ts b/src/routes/(authenticated)/projects/[id=number]/+page.server.ts index 16e92e7538..d722c08001 100644 --- a/src/routes/(authenticated)/projects/[id=number]/+page.server.ts +++ b/src/routes/(authenticated)/projects/[id=number]/+page.server.ts @@ -17,7 +17,7 @@ import { ProductActionType } from '$lib/products'; import { doProductAction } from '$lib/products/server'; import { projectActionSchema } from '$lib/projects'; import { doProjectAction, userGroupsForOrg } from '$lib/projects/server'; -import { getProjectDetails } from '$lib/projects/sse'; +import { getProjectDetails, getProjectGroupData } from '$lib/projects/sse'; import { BullMQ, QueueConnected, getQueues } from '$lib/server/bullmq'; import { DatabaseReads, DatabaseWrites } from '$lib/server/database'; import { deleteSchema, idSchema, propertiesSchema, stringIdSchema } from '$lib/valibot'; @@ -60,6 +60,7 @@ export const load = (async ({ locals, params }) => { return { projectData: await getProjectDetails(projectId, locals.security.sessionForm), + groupData: await getProjectGroupData(projectId, locals.security.sessionForm), authorForm: await superValidate(valibot(addAuthorSchema)), reviewerForm: await superValidate({ language: baseLocale }, valibot(addReviewerSchema)), actionForm: await superValidate(valibot(projectActionSchema)), diff --git a/src/routes/(authenticated)/projects/[id=number]/+page.svelte b/src/routes/(authenticated)/projects/[id=number]/+page.svelte index 370b6a0eec..507955d7ca 100644 --- a/src/routes/(authenticated)/projects/[id=number]/+page.svelte +++ b/src/routes/(authenticated)/projects/[id=number]/+page.svelte @@ -20,7 +20,7 @@ import ProjectDetails, { showProjectDetails } from '$lib/projects/components/ProjectDetails.svelte'; - import type { ProjectDataSSE } from '$lib/projects/sse'; + import type { ProjectDataSSE, ProjectGroupsSSE } from '$lib/projects/sse'; import { byName } from '$lib/utils/sorting'; import { getRelativeTime, getTimeDateString } from '$lib/utils/time'; @@ -28,25 +28,31 @@ let addProductModal: HTMLDialogElement | undefined = $state(undefined); - const currentPageUrl = page.url.pathname; - let reconnectDelay = 1000; - const projectDataSSE: Readable = source(`${page.params.id}/sse`, { - close({ connect }) { - setTimeout(() => { - if (currentPageUrl !== page.url.pathname) { - // If the current page has changed, we don't want to reconnect. - return; - } - console.log('Disconnected. Reconnecting...'); - connect(); - reconnectDelay = Math.min(reconnectDelay * 2, 30000); // Exponential backoff, max 30 seconds - }, reconnectDelay); - } - }) - .select('projectData') - .transform((t) => (t ? parse(t) : undefined)); + function createSource(endpoint: string, select: string) { + const currentPageUrl = page.url.pathname; + let reconnectDelay = 1000; + return source(`${page.params.id}/sse${endpoint}`, { + close({ connect }) { + setTimeout(() => { + if (currentPageUrl !== page.url.pathname) { + // If the current page has changed, we don't want to reconnect. + return; + } + console.log('Disconnected. Reconnecting...'); + connect(); + reconnectDelay = Math.min(reconnectDelay * 2, 30000); // Exponential backoff, max 30 seconds + }, reconnectDelay); + } + }) + .select(select) + .transform((t) => (t ? parse(t) : undefined)); + } + + const projectDataSSE: Readable = createSource('', 'projectData'); + const groupDataSSE: Readable = createSource('/groups', 'groupData'); const projectData = $derived($projectDataSSE ?? data.projectData); + const groupData = $derived($groupDataSSE ?? data.groupData); const dateCreated = $derived(getRelativeTime(projectData?.project?.DateCreated ?? null)); const dateArchived = $derived(getRelativeTime(projectData?.project?.DateArchived ?? null)); @@ -63,13 +69,13 @@ projectData?.project.OwnerId ?? -1, projectData?.project.OrganizationId ?? -1, projectData?.project.GroupId ?? -1, - projectData?.userGroups ?? [] + groupData?.userGroups ?? [] ) );
- {#if !projectData} + {#if !(projectData && groupData)}
@@ -105,7 +111,7 @@ {/if}
@@ -257,8 +263,8 @@
o.Id === projectData.project.OrganizationId) ?.Name} endpoint="editOwnerGroup" @@ -267,15 +273,15 @@ /> Date: Thu, 5 Mar 2026 10:18:25 -0600 Subject: [PATCH 03/20] SSE for org settings of project --- src/lib/bullmq.ts | 1 + src/lib/projects/listener.ts | 7 +- src/lib/projects/sse.ts | 170 ++++++++++++------ src/lib/server/bullmq/BullWorker.ts | 3 + src/lib/server/bullmq/types.ts | 7 + src/lib/server/database/ProductDefinitions.ts | 89 +++++---- src/lib/server/database/Stores.ts | 22 ++- src/lib/utils/sorting.ts | 2 +- .../product-definitions/edit/+page.server.ts | 20 +-- .../product-definitions/new/+page.server.ts | 22 +-- .../projects/[id=number]/+page.server.ts | 3 +- .../projects/[id=number]/+page.svelte | 27 ++- .../projects/[id=number]/ProductCard.svelte | 9 +- .../projects/[id=number]/sse/+server.ts | 56 ++---- .../[id=number]/sse/groups/+server.ts | 56 ++---- .../projects/[id=number]/sse/org/+server.ts | 23 +++ 16 files changed, 312 insertions(+), 205 deletions(-) create mode 100644 src/routes/(authenticated)/projects/[id=number]/sse/org/+server.ts diff --git a/src/lib/bullmq.ts b/src/lib/bullmq.ts index d9449e2dd7..d60ba40dea 100644 --- a/src/lib/bullmq.ts +++ b/src/lib/bullmq.ts @@ -53,6 +53,7 @@ export enum JobType { // Svelte Project SSE SvelteSSE_UpdateProject = 'Update Project', SvelteSSE_UpdateProjectGroups = 'Update Project Groups', + SvelteSSE_UpdateProjectOrg = 'Update Project Org', SvelteSSE_UpdateUserTasks = 'Update UserTasks' } diff --git a/src/lib/projects/listener.ts b/src/lib/projects/listener.ts index 95d1fff73f..94d7b5e858 100644 --- a/src/lib/projects/listener.ts +++ b/src/lib/projects/listener.ts @@ -1,11 +1,14 @@ // Create a new bullmq listener for project updates import EventEmitter from 'events'; -export const SSEPageUpdates = new EventEmitter<{ +export type SSEPageEvents = { projectPage: [number[]]; projectGroups: [number[]]; + projectOrg: [number[]]; userTasksPage: [number[]]; -}>().setMaxListeners(400); +}; + +export const SSEPageUpdates = new EventEmitter().setMaxListeners(400); // Allow 400 listeners (in the last 10 seconds) // >400 instances viewing a project page or the user tasks page simultaneously // seems unlikely. If it does happen, we can increase this limit diff --git a/src/lib/projects/sse.ts b/src/lib/projects/sse.ts index 0d8711409a..1dc1cc7308 100644 --- a/src/lib/projects/sse.ts +++ b/src/lib/projects/sse.ts @@ -1,8 +1,10 @@ import type { Session } from '@auth/sveltekit'; import { SpanStatusCode, trace } from '@opentelemetry/api'; +import { error } from '@sveltejs/kit'; +import { stringify } from 'devalue'; +import { produce } from 'sveltekit-sse'; +import { type SSEPageEvents, SSEPageUpdates } from './listener'; import { ProjectActionString, ProjectActionType, RoleId } from '$lib/prisma'; -import { getProductActions } from '$lib/products'; -import { canModifyProject } from '$lib/projects'; import { userGroupsForOrg } from '$lib/projects/server'; import { getURLandToken } from '$lib/server/build-engine-api/requests'; import { DatabaseReads } from '$lib/server/database'; @@ -53,15 +55,7 @@ export async function getProjectDetails(id: number, userSession: Session['user'] Properties: true, ProductDefinition: { select: { - Id: true, - Name: true, - RebuildWorkflowId: true, - RepublishWorkflowId: true, - Workflow: { - select: { - ProductType: true - } - } + Id: true } }, // Probably don't need to optimize this. Unless it's a really large org, @@ -74,12 +68,7 @@ export async function getProjectDetails(id: number, userSession: Session['user'] UserId: true } }, - Store: { - select: { - StoreTypeId: true, - Description: true - } - }, + StoreId: true, BuildEngineJobId: isSuper, CurrentBuildId: isSuper, CurrentReleaseId: isSuper, @@ -155,15 +144,6 @@ export async function getProjectDetails(id: number, userSession: Session['user'] Id: project.OrganizationId }, select: { - Stores: { - select: { - Id: true, - BuildEnginePublisherId: true, - GooglePlayTitle: true, - Description: true, - StoreTypeId: true - } - }, System: isSuper, UseDefaultBuildEngine: isSuper } @@ -209,32 +189,6 @@ export async function getProjectDetails(id: number, userSession: Session['user'] transitions.find((tr) => tr.ProductId === p.Id && tr.DateTransition === null)! ]); - const productDefinitions = await DatabaseReads.productDefinitions.findMany({ - where: { - Organizations: { some: { Id: project.OrganizationId } }, - OR: [ - { AllowAllApplicationTypes: true }, - { ApplicationTypes: { some: { Id: project.ApplicationType.Id } } } - ] - }, - select: { - Id: true, - Name: true, - Description: true, - Workflow: { - select: { - ProductType: true, - StoreTypeId: true - } - } - } - }); - - const projectProductDefinitionIds = project.Products.map((p) => p.ProductDefinition.Id); - span.addEvent('Product definitions fetched'); - - const canEdit = canModifyProject(userSession, project.Owner.Id, project.OrganizationId); - return { project: { ...project, @@ -249,14 +203,9 @@ export async function getProjectDetails(id: number, userSession: Session['user'] ActiveTransition: strippedTransitions.find( (t) => (t[0] ?? t[1])?.ProductId === product.Id )?.[1], - actions: canEdit ? getProductActions(product) : [], BuildEngineUrl: isSuper ? `${getURLandToken(organization).url}` : undefined })) }, - productsToAdd: productDefinitions.filter( - (pd) => !projectProductDefinitionIds.includes(pd.Id) - ), - stores: organization?.Stores ?? [], actionParams: { users: await DatabaseReads.users.findMany({ where: { @@ -433,6 +382,76 @@ export async function getProjectGroupData(id: number, userSession: Session['user }); } +export type ProjectOrgsSSE = Awaited>; +export async function getProjectOrgData(id: number, userSession: Session['user']) { + // permissions checked in auth + return tracer.startActiveSpan('getProjectOrg', async (span) => { + span.setAttributes({ + 'project.id': id, + 'project.userId': userSession.userId + }); + try { + const project = await DatabaseReads.projects.findUniqueOrThrow({ + where: { + Id: id + }, + select: { + OrganizationId: true, + TypeId: true + } + }); + span.addEvent('Project fetched'); + return await DatabaseReads.organizations.findUniqueOrThrow({ + where: { + Id: project.OrganizationId + }, + select: { + Stores: { + select: { + Id: true, + BuildEnginePublisherId: true, + GooglePlayTitle: true, + Description: true, + StoreTypeId: true + } + }, + ProductDefinitions: { + where: { + Organizations: { some: { Id: project.OrganizationId } }, + OR: [ + { AllowAllApplicationTypes: true }, + { ApplicationTypes: { some: { Id: project.TypeId } } } + ] + }, + select: { + Id: true, + Name: true, + Description: true, + Workflow: { + select: { + ProductType: true, + StoreTypeId: true + } + }, + RebuildWorkflowId: true, + RepublishWorkflowId: true + } + } + } + }); + } catch (e) { + span.recordException(e as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: (e as Error).message + }); + throw error(500); + } finally { + span.end(); + } + }); +} + /** * S = Status * C = Comment @@ -521,3 +540,40 @@ export async function getUserTasks(userId: number) { ) }; } + +export function createProducer( + id: number, + userSession: Session['user'], + stream: keyof SSEPageEvents, + event: string, + query: (id: number, userSession: Session['user']) => Promise +) { + return produce(async function start({ emit, lock }) { + // User will be allowed to see project updates until they reload + // even if their permission is revoked during the SSE connection. + const { error } = emit(event, stringify(await query(id, userSession))); + if (error) { + return; + } + async function updateCb(updateId: number[]) { + // This is a little wasteful because it will calculate much of the same data + // multiple times if multiple users are connected to the same project page. + if (updateId.includes(id)) { + // console.log(`Project page SSE update for project ${id}`); + const data = await query(id, userSession); + const { error } = emit(event, stringify(data)); + if (error) { + SSEPageUpdates.off(stream, updateCb); + clearInterval(pingInterval); + } + } + } + SSEPageUpdates.on(stream, updateCb); + const pingInterval = setInterval(function onDisconnect() { + const { error } = emit('ping', ''); + if (!error) return; + SSEPageUpdates.off(stream, updateCb); + clearInterval(pingInterval); + }, 10000).unref(); + }); +} diff --git a/src/lib/server/bullmq/BullWorker.ts b/src/lib/server/bullmq/BullWorker.ts index ce4a2dd416..fddf509db1 100644 --- a/src/lib/server/bullmq/BullWorker.ts +++ b/src/lib/server/bullmq/BullWorker.ts @@ -302,6 +302,9 @@ export class SvelteSSE extends BullWorker { case BullMQ.JobType.SvelteSSE_UpdateProjectGroups: SSEPageUpdates.emit('projectGroups', job.data.projectIds); break; + case BullMQ.JobType.SvelteSSE_UpdateProjectOrg: + SSEPageUpdates.emit('projectOrg', job.data.projectIds); + break; case BullMQ.JobType.SvelteSSE_UpdateUserTasks: SSEPageUpdates.emit('userTasksPage', job.data.userIds); break; diff --git a/src/lib/server/bullmq/types.ts b/src/lib/server/bullmq/types.ts index 639a465793..0cce702448 100644 --- a/src/lib/server/bullmq/types.ts +++ b/src/lib/server/bullmq/types.ts @@ -295,6 +295,11 @@ export namespace SvelteProjectSSE { projectIds: number[]; } + export interface UpdateOrg extends BaseJob { + type: JobType.SvelteSSE_UpdateProjectOrg; + projectIds: number[]; + } + export interface UpdateUserTasks extends BaseJob { type: JobType.SvelteSSE_UpdateUserTasks; userIds: number[]; @@ -334,6 +339,7 @@ export type EmailJob = JobTypeMap[ export type SvelteSSEJob = JobTypeMap[ | JobType.SvelteSSE_UpdateProject | JobType.SvelteSSE_UpdateProjectGroups + | JobType.SvelteSSE_UpdateProjectOrg | JobType.SvelteSSE_UpdateUserTasks]; export type ProductJob = JobTypeMap[ | JobType.Product_Create @@ -376,6 +382,7 @@ export type JobTypeMap = { [JobType.Email_ProjectImportReport]: Email.ProjectImportReport; [JobType.SvelteSSE_UpdateProject]: SvelteProjectSSE.UpdateProject; [JobType.SvelteSSE_UpdateProjectGroups]: SvelteProjectSSE.UpdateGroups; + [JobType.SvelteSSE_UpdateProjectOrg]: SvelteProjectSSE.UpdateOrg; [JobType.SvelteSSE_UpdateUserTasks]: SvelteProjectSSE.UpdateUserTasks; // Add more mappings here as needed }; diff --git a/src/lib/server/database/ProductDefinitions.ts b/src/lib/server/database/ProductDefinitions.ts index 3f282499c7..f2004899a1 100644 --- a/src/lib/server/database/ProductDefinitions.ts +++ b/src/lib/server/database/ProductDefinitions.ts @@ -1,4 +1,5 @@ import type { Prisma } from '@prisma/client'; +import { BullMQ, getQueues } from '../bullmq'; import prisma from './prisma'; import type { RequirePrimitive } from './utility'; @@ -7,7 +8,7 @@ export async function toggleForOrg( OrganizationId: number, enabled: boolean ) { - return !!(await prisma.organizations.update({ + const updated = !!(await prisma.organizations.update({ where: { Id: OrganizationId }, data: { ProductDefinitions: { @@ -20,46 +21,74 @@ export async function toggleForOrg( Id: true } })); + + if (updated) { + getQueues().SvelteSSE.add( + `Update Projects for Org #${OrganizationId} (product #${ProductDefinitionId} ${enabled ? 'enabled' : 'disabled'})`, + { + type: BullMQ.JobType.SvelteSSE_UpdateProjectOrg, + projectIds: ( + await prisma.projects.findMany({ where: { OrganizationId }, select: { Id: true } }) + ).map((p) => p.Id) + } + ); + } + + return updated; } export async function create( - data: RequirePrimitive + data: RequirePrimitive, + ApplicationTypes?: number[] ) { return await prisma.productDefinitions.create({ - data + data: ApplicationTypes + ? { + ...data, + ApplicationTypes: { + connect: ApplicationTypes.map((n) => ({ Id: n })) + } + } + : data }); } export async function update( id: number, - data: RequirePrimitive + data: RequirePrimitive, + ApplicationTypes?: number[] ) { - return await prisma.productDefinitions.update({ + const updated = !!(await prisma.productDefinitions.update({ where: { Id: id }, - data - }); -} - -export async function setApplicationTypes(Id: number, ApplicationTypes: number[]) { - return await prisma.productDefinitions.update({ - where: { - Id - }, - data: { - ApplicationTypes: { - connect: ApplicationTypes.map((n) => ({ Id: n })), - disconnect: (await prisma.applicationTypes.findMany({ select: { Id: true } })).filter( - (at) => !ApplicationTypes.includes(at.Id) - ) - } - }, - select: { - Id: true, - ApplicationTypes: { - select: { - Id: true + data: ApplicationTypes + ? { + ...data, + ApplicationTypes: { + connect: ApplicationTypes.map((n) => ({ Id: n })), + disconnect: (await prisma.applicationTypes.findMany({ select: { Id: true } })).filter( + (at) => !ApplicationTypes.includes(at.Id) + ) + } } - } - } - }); + : data + })); + + if (updated) { + getQueues().SvelteSSE.add(`Update Projects (product #${id} modified)`, { + type: BullMQ.JobType.SvelteSSE_UpdateProjectOrg, + projectIds: ( + await prisma.projects.findMany({ + where: { + OR: [ + { Organization: { ProductDefinitions: { some: { Id: id } } } }, + { Products: { some: { ProductDefinitionId: id } } } + ] + }, + select: { Id: true } + }) + ).map((p) => p.Id) + }); + } + + return updated; } diff --git a/src/lib/server/database/Stores.ts b/src/lib/server/database/Stores.ts index 140fb6e463..34ad88ad6d 100644 --- a/src/lib/server/database/Stores.ts +++ b/src/lib/server/database/Stores.ts @@ -1,9 +1,10 @@ import type { Prisma } from '@prisma/client'; +import { BullMQ, getQueues } from '../bullmq'; import prisma from './prisma'; import type { RequirePrimitive } from './utility'; export async function toggleForOrg(StoreId: number, OrganizationId: number, enabled: boolean) { - return !!(await prisma.stores.update({ + const updated = !!(await prisma.stores.update({ where: { Id: StoreId, OR: [ @@ -26,6 +27,20 @@ export async function toggleForOrg(StoreId: number, OrganizationId: number, enab Id: true } })); + + if (updated) { + getQueues().SvelteSSE.add( + `Update Projects for Org #${OrganizationId} (store #${StoreId} ${enabled ? 'enabled' : 'disabled'})`, + { + type: BullMQ.JobType.SvelteSSE_UpdateProjectOrg, + projectIds: ( + await prisma.projects.findMany({ where: { OrganizationId }, select: { Id: true } }) + ).map((p) => p.Id) + } + ); + } + + return updated; } export async function create(data: RequirePrimitive) { @@ -36,8 +51,11 @@ export async function create(data: RequirePrimitive + data: RequirePrimitive< + Omit + > ) { + // don't need SSE, as the only features updated are display features return await prisma.stores.update({ where: { Id: id }, data diff --git a/src/lib/utils/sorting.ts b/src/lib/utils/sorting.ts index abdc9f15c3..a2d442bf03 100644 --- a/src/lib/utils/sorting.ts +++ b/src/lib/utils/sorting.ts @@ -1,5 +1,5 @@ interface NamedEntity { - Name: string | null | undefined; + Name?: string | null | undefined; } export function byName( diff --git a/src/routes/(authenticated)/admin/settings/product-definitions/edit/+page.server.ts b/src/routes/(authenticated)/admin/settings/product-definitions/edit/+page.server.ts index 08df5d90b0..78bdde1b4e 100644 --- a/src/routes/(authenticated)/admin/settings/product-definitions/edit/+page.server.ts +++ b/src/routes/(authenticated)/admin/settings/product-definitions/edit/+page.server.ts @@ -61,17 +61,17 @@ export const actions = { if (!form.valid) { return fail(400, { form, ok: false }); } - await DatabaseWrites.productDefinitions.update(form.data.id, { - Name: form.data.name, - AllowAllApplicationTypes: form.data.allowAll, - WorkflowId: form.data.workflow, - RebuildWorkflowId: form.data.rebuildWorkflow, - RepublishWorkflowId: form.data.republishWorkflow, - Description: form.data.description, - Properties: form.data.properties - }); - await DatabaseWrites.productDefinitions.setApplicationTypes( + await DatabaseWrites.productDefinitions.update( form.data.id, + { + Name: form.data.name, + AllowAllApplicationTypes: form.data.allowAll, + WorkflowId: form.data.workflow, + RebuildWorkflowId: form.data.rebuildWorkflow, + RepublishWorkflowId: form.data.republishWorkflow, + Description: form.data.description, + Properties: form.data.properties + }, form.data.applicationTypes ); return { ok: true, form }; diff --git a/src/routes/(authenticated)/admin/settings/product-definitions/new/+page.server.ts b/src/routes/(authenticated)/admin/settings/product-definitions/new/+page.server.ts index 53b7709534..5e88dda149 100644 --- a/src/routes/(authenticated)/admin/settings/product-definitions/new/+page.server.ts +++ b/src/routes/(authenticated)/admin/settings/product-definitions/new/+page.server.ts @@ -34,16 +34,18 @@ export const actions = { if (!form.valid) { return fail(400, { form, ok: false }); } - const pd = await DatabaseWrites.productDefinitions.create({ - Name: form.data.name, - WorkflowId: form.data.workflow, - RebuildWorkflowId: form.data.rebuildWorkflow, - RepublishWorkflowId: form.data.republishWorkflow, - Description: form.data.description, - Properties: form.data.properties, - AllowAllApplicationTypes: form.data.allowAll - }); - await DatabaseWrites.productDefinitions.setApplicationTypes(pd.Id, form.data.applicationTypes); + await DatabaseWrites.productDefinitions.create( + { + Name: form.data.name, + WorkflowId: form.data.workflow, + RebuildWorkflowId: form.data.rebuildWorkflow, + RepublishWorkflowId: form.data.republishWorkflow, + Description: form.data.description, + Properties: form.data.properties, + AllowAllApplicationTypes: form.data.allowAll + }, + form.data.applicationTypes + ); return { ok: true, form }; } } satisfies Actions; diff --git a/src/routes/(authenticated)/projects/[id=number]/+page.server.ts b/src/routes/(authenticated)/projects/[id=number]/+page.server.ts index d722c08001..681f70db3e 100644 --- a/src/routes/(authenticated)/projects/[id=number]/+page.server.ts +++ b/src/routes/(authenticated)/projects/[id=number]/+page.server.ts @@ -17,7 +17,7 @@ import { ProductActionType } from '$lib/products'; import { doProductAction } from '$lib/products/server'; import { projectActionSchema } from '$lib/projects'; import { doProjectAction, userGroupsForOrg } from '$lib/projects/server'; -import { getProjectDetails, getProjectGroupData } from '$lib/projects/sse'; +import { getProjectDetails, getProjectGroupData, getProjectOrgData } from '$lib/projects/sse'; import { BullMQ, QueueConnected, getQueues } from '$lib/server/bullmq'; import { DatabaseReads, DatabaseWrites } from '$lib/server/database'; import { deleteSchema, idSchema, propertiesSchema, stringIdSchema } from '$lib/valibot'; @@ -61,6 +61,7 @@ export const load = (async ({ locals, params }) => { return { projectData: await getProjectDetails(projectId, locals.security.sessionForm), groupData: await getProjectGroupData(projectId, locals.security.sessionForm), + orgData: await getProjectOrgData(projectId, locals.security.sessionForm), authorForm: await superValidate(valibot(addAuthorSchema)), reviewerForm: await superValidate({ language: baseLocale }, valibot(addReviewerSchema)), actionForm: await superValidate(valibot(projectActionSchema)), diff --git a/src/routes/(authenticated)/projects/[id=number]/+page.svelte b/src/routes/(authenticated)/projects/[id=number]/+page.svelte index 507955d7ca..ba6222d49f 100644 --- a/src/routes/(authenticated)/projects/[id=number]/+page.svelte +++ b/src/routes/(authenticated)/projects/[id=number]/+page.svelte @@ -20,7 +20,7 @@ import ProjectDetails, { showProjectDetails } from '$lib/projects/components/ProjectDetails.svelte'; - import type { ProjectDataSSE, ProjectGroupsSSE } from '$lib/projects/sse'; + import type { ProjectDataSSE, ProjectGroupsSSE, ProjectOrgsSSE } from '$lib/projects/sse'; import { byName } from '$lib/utils/sorting'; import { getRelativeTime, getTimeDateString } from '$lib/utils/time'; @@ -50,9 +50,11 @@ const projectDataSSE: Readable = createSource('', 'projectData'); const groupDataSSE: Readable = createSource('/groups', 'groupData'); + const orgDataSSE: Readable = createSource('/org', 'orgData'); const projectData = $derived($projectDataSSE ?? data.projectData); const groupData = $derived($groupDataSSE ?? data.groupData); + const orgData = $derived($orgDataSSE ?? data.orgData); const dateCreated = $derived(getRelativeTime(projectData?.project?.DateCreated ?? null)); const dateArchived = $derived(getRelativeTime(projectData?.project?.DateArchived ?? null)); @@ -72,6 +74,16 @@ groupData?.userGroups ?? [] ) ); + + const { productMap, availableProducts } = $derived.by(() => { + const activeProducts = new Set( + projectData?.project.Products.map((p) => p.ProductDefinition.Id) + ); + return { + productMap: new Map(orgData.ProductDefinitions.map((pd) => [pd.Id, pd])), + availableProducts: orgData.ProductDefinitions.filter((pd) => !activeProducts.has(pd.Id)) + }; + });
@@ -210,7 +222,7 @@ onclick={() => addProductModal?.showModal()} disabled={!( canEdit && - projectData.productsToAdd.length && + availableProducts.length && projectData.project.RepositoryUrl && !projectData.project.DateArchived )} @@ -221,8 +233,8 @@ {#if canEdit} {/if} @@ -232,7 +244,12 @@ {#if !projectData?.project?.Products.length} {m.projectTable_noProducts()} {:else} - {#each projectData.project.Products.toSorted( (a, b) => byName(a.ProductDefinition, b.ProductDefinition, getLocale()) ) as product} + {@const products = projectData.project.Products.map((p) => ({ + ...p, + ProductDefinition: productMap.get(p.ProductDefinition.Id)!, + Store: orgData.Stores.find((s) => s.Id === p.StoreId)! + }))} + {#each products.toSorted( (a, b) => byName(a.ProductDefinition, b.ProductDefinition, getLocale()) ) as product} ; product: Prisma.ProductsGetPayload<{ @@ -55,6 +56,8 @@ ProductType: true; }; }; + RebuildWorkflowId: true; + RepublishWorkflowId: true; }; }; UserTasks: { @@ -87,7 +90,6 @@ }; }> & { Transitions: Transition[]; - actions: ProductActionType[]; ActiveTransition?: Transition; PreviousTransition?: Transition; } & ProductDetailProps['product']; @@ -113,6 +115,7 @@ const showTaskWaiting = $derived(!!product.WorkflowInstance); const highlighted = $derived(page.url.hash.substring(1)); + const actions = $derived(canEdit ? getProductActions(product) : []); async function handleProductAction(productId: string, action: string) { try { @@ -278,7 +281,7 @@ {m.products_details()} - {#each product.actions as action} + {#each actions as action} {@const message = //@ts-expect-error this is in fact correct m['products_acts_' + action]({ diff --git a/src/routes/(authenticated)/projects/[id=number]/sse/+server.ts b/src/routes/(authenticated)/projects/[id=number]/sse/+server.ts index a788e062dd..ff4bb825c7 100644 --- a/src/routes/(authenticated)/projects/[id=number]/sse/+server.ts +++ b/src/routes/(authenticated)/projects/[id=number]/sse/+server.ts @@ -1,51 +1,23 @@ -import { stringify } from 'devalue'; -import { produce } from 'sveltekit-sse'; -import { SSEPageUpdates } from '$lib/projects/listener'; -import { getProjectDetails } from '$lib/projects/sse'; +import { createProducer, getProjectDetails } from '$lib/projects/sse'; import { DatabaseReads } from '$lib/server/database'; -export async function POST(request) { - request.locals.security.requireAuthenticated(); - request.locals.security.requireProjectReadAccess( +export async function POST({ locals, params }) { + locals.security.requireAuthenticated(); + const id = parseInt(params.id); + locals.security.requireProjectReadAccess( await DatabaseReads.groups.findMany({ - where: { Users: { some: { Id: request.locals.security.userId } } }, + where: { Users: { some: { Id: locals.security.userId } } }, select: { Id: true } }), await DatabaseReads.projects.findUnique({ - where: { Id: parseInt(request.params.id) } + where: { Id: id } }) ); - const { id: strId } = request.params; - return produce(async function start({ emit, lock }) { - const id = parseInt(strId); - // User will be allowed to see project updates until they reload - // even if their permission is revoked during the SSE connection. - const { error } = emit( - 'projectData', - stringify(await getProjectDetails(id, request.locals.security.sessionForm)) - ); - if (error) { - return; - } - async function updateCb(updateId: number[]) { - // This is a little wasteful because it will calculate much of the same data - // multiple times if multiple users are connected to the same project page. - if (updateId.includes(id)) { - // console.log(`Project page SSE update for project ${id}`); - const projectData = await getProjectDetails(id, request.locals.security.sessionForm); - const { error } = emit('projectData', stringify(projectData)); - if (error) { - SSEPageUpdates.off('projectPage', updateCb); - clearInterval(pingInterval); - } - } - } - SSEPageUpdates.on('projectPage', updateCb); - const pingInterval = setInterval(function onDisconnect() { - const { error } = emit('ping', ''); - if (!error) return; - SSEPageUpdates.off('projectPage', updateCb); - clearInterval(pingInterval); - }, 10000).unref(); - }); + return createProducer( + id, + locals.security.sessionForm, + 'projectPage', + 'projectData', + getProjectDetails + ); } diff --git a/src/routes/(authenticated)/projects/[id=number]/sse/groups/+server.ts b/src/routes/(authenticated)/projects/[id=number]/sse/groups/+server.ts index b6acc96367..5c5df1d56b 100644 --- a/src/routes/(authenticated)/projects/[id=number]/sse/groups/+server.ts +++ b/src/routes/(authenticated)/projects/[id=number]/sse/groups/+server.ts @@ -1,51 +1,23 @@ -import { stringify } from 'devalue'; -import { produce } from 'sveltekit-sse'; -import { SSEPageUpdates } from '$lib/projects/listener'; -import { getProjectGroupData } from '$lib/projects/sse'; +import { createProducer, getProjectGroupData } from '$lib/projects/sse'; import { DatabaseReads } from '$lib/server/database'; -export async function POST(request) { - request.locals.security.requireAuthenticated(); - request.locals.security.requireProjectReadAccess( +export async function POST({ locals, params }) { + locals.security.requireAuthenticated(); + const id = parseInt(params.id); + locals.security.requireProjectReadAccess( await DatabaseReads.groups.findMany({ - where: { Users: { some: { Id: request.locals.security.userId } } }, + where: { Users: { some: { Id: locals.security.userId } } }, select: { Id: true } }), await DatabaseReads.projects.findUnique({ - where: { Id: parseInt(request.params.id) } + where: { Id: id } }) ); - const { id: strId } = request.params; - return produce(async function start({ emit, lock }) { - const id = parseInt(strId); - // User will be allowed to see project updates until they reload - // even if their permission is revoked during the SSE connection. - const { error } = emit( - 'groupData', - stringify(await getProjectGroupData(id, request.locals.security.sessionForm)) - ); - if (error) { - return; - } - async function updateCb(updateId: number[]) { - // This is a little wasteful because it will calculate much of the same data - // multiple times if multiple users are connected to the same project page. - if (updateId.includes(id)) { - // console.log(`Project page SSE update for project ${id}`); - const groupData = await getProjectGroupData(id, request.locals.security.sessionForm); - const { error } = emit('groupData', stringify(groupData)); - if (error) { - SSEPageUpdates.off('projectGroups', updateCb); - clearInterval(pingInterval); - } - } - } - SSEPageUpdates.on('projectGroups', updateCb); - const pingInterval = setInterval(function onDisconnect() { - const { error } = emit('ping', ''); - if (!error) return; - SSEPageUpdates.off('projectGroups', updateCb); - clearInterval(pingInterval); - }, 10000).unref(); - }); + return createProducer( + id, + locals.security.sessionForm, + 'projectGroups', + 'groupData', + getProjectGroupData + ); } diff --git a/src/routes/(authenticated)/projects/[id=number]/sse/org/+server.ts b/src/routes/(authenticated)/projects/[id=number]/sse/org/+server.ts new file mode 100644 index 0000000000..855419e24a --- /dev/null +++ b/src/routes/(authenticated)/projects/[id=number]/sse/org/+server.ts @@ -0,0 +1,23 @@ +import { createProducer, getProjectOrgData } from '$lib/projects/sse'; +import { DatabaseReads } from '$lib/server/database'; + +export async function POST({ locals, params }) { + locals.security.requireAuthenticated(); + const id = parseInt(params.id); + locals.security.requireProjectReadAccess( + await DatabaseReads.groups.findMany({ + where: { Users: { some: { Id: locals.security.userId } } }, + select: { Id: true } + }), + await DatabaseReads.projects.findUnique({ + where: { Id: id } + }) + ); + return createProducer( + id, + locals.security.sessionForm, + 'projectOrg', + 'orgData', + getProjectOrgData + ); +} From d884176c59a78c0f1954cc0d325f9edf72df4ab4 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Thu, 5 Mar 2026 10:46:32 -0600 Subject: [PATCH 04/20] Minor UI fixes --- .../admin/settings/organizations/edit/+page.svelte | 2 +- .../(authenticated)/admin/settings/stores/edit/+page.svelte | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/(authenticated)/admin/settings/organizations/edit/+page.svelte b/src/routes/(authenticated)/admin/settings/organizations/edit/+page.svelte index 355e2f5974..381849ce3c 100644 --- a/src/routes/(authenticated)/admin/settings/organizations/edit/+page.svelte +++ b/src/routes/(authenticated)/admin/settings/organizations/edit/+page.svelte @@ -172,7 +172,7 @@ e.currentTarget.form?.requestSubmit(); }} class="checkbox checkbox-accent mr-2 mt-2" - disabled={!!user._count.Projects} + disabled={!!(user._count.Organizations && user._count.Projects)} checked={!!user._count.Organizations} /> diff --git a/src/routes/(authenticated)/admin/settings/stores/edit/+page.svelte b/src/routes/(authenticated)/admin/settings/stores/edit/+page.svelte index 7d14625092..f1d3db481b 100644 --- a/src/routes/(authenticated)/admin/settings/stores/edit/+page.svelte +++ b/src/routes/(authenticated)/admin/settings/stores/edit/+page.svelte @@ -50,6 +50,7 @@ key="common_type" input={{ readonly: true, + disabled: true, icon: getStoreIcon(data.store.StoreTypeId) }} value={data.store.StoreType.Description} From a16f62ece7a4408757ad6fa8693c94d5c54c9d41 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Thu, 5 Mar 2026 10:46:50 -0600 Subject: [PATCH 05/20] Update SSE for user addition/removal --- src/lib/server/database/Organizations.ts | 42 +++++++- src/lib/server/database/Users.ts | 126 ++++++++++++++++------- 2 files changed, 126 insertions(+), 42 deletions(-) diff --git a/src/lib/server/database/Organizations.ts b/src/lib/server/database/Organizations.ts index 3e2822f84e..8ca3cba9df 100644 --- a/src/lib/server/database/Organizations.ts +++ b/src/lib/server/database/Organizations.ts @@ -1,4 +1,5 @@ import type { Prisma } from '@prisma/client'; +import { BullMQ, getQueues } from '../bullmq'; import prisma from './prisma'; import type { RequirePrimitive } from './utility'; @@ -79,7 +80,7 @@ export async function toggleUser(OrganizationId: number, UserId: number, enabled select: { Id: true } }); - return ( + const updated = (enabled || !userHasProjectInOrg) && !!(await prisma.users.update({ where: { Id: UserId }, @@ -93,6 +94,41 @@ export async function toggleUser(OrganizationId: number, UserId: number, enabled select: { Id: true } - })) - ); + })); + + if (updated) { + // remove user from groups/roles too + if (!enabled) { + await prisma.users.update({ + where: { Id: UserId }, + data: { + Groups: { + disconnect: await prisma.groups.findMany({ + where: { OwnerId: OrganizationId, Users: { some: { Id: UserId } } }, + select: { Id: true } + }) + }, + UserRoles: { + deleteMany: { + OrganizationId + } + } + } + }); + } + getQueues().SvelteSSE.add( + `Update Projects for Org #${OrganizationId} (user #${UserId} ${enabled ? 'added' : 'removed'})`, + { + type: BullMQ.JobType.SvelteSSE_UpdateProjectGroups, + projectIds: ( + await prisma.projects.findMany({ + where: { OrganizationId }, + select: { Id: true } + }) + ).map((p) => p.Id) + } + ); + } + + return updated; } diff --git a/src/lib/server/database/Users.ts b/src/lib/server/database/Users.ts index d77da43f0c..3ffdee9291 100644 --- a/src/lib/server/database/Users.ts +++ b/src/lib/server/database/Users.ts @@ -64,47 +64,65 @@ export async function toggleRole( }); } - /* - * Only enqueue tasks when: - * 1. The role is OrgAdmin AND - * 2. Either we're adding a role that hasn't already been added or removing a role. + /** + * Only update when: + * 1. Either we're adding a role that hasn't already been added or removing a role. * This prevents duplicate task enqueuing when adding an already-added role */ - if (role === RoleId.OrgAdmin && !(enabled && existing)) { - await getQueues().UserTasks.addBulk( - ( - await prisma.projects.findMany({ - where: { OrganizationId }, - select: { Id: true } - }) - ).flatMap((p) => [ - { - name: `${enabled ? 'Add' : 'Remove'} OrgAdmin tasks for User #${UserId} on Project #${p.Id}`, - data: { - type: BullMQ.JobType.UserTasks_Workflow, - scope: 'Project', - projectId: p.Id, - operation: { - type: enabled ? BullMQ.UserTasks.OpType.Create : BullMQ.UserTasks.OpType.Delete, - users: [UserId], - roles: [RoleId.OrgAdmin] + if (!(enabled && existing)) { + /* + * Only enqueue tasks when: + * 1. The role is OrgAdmin + */ + if (role === RoleId.OrgAdmin) { + await getQueues().UserTasks.addBulk( + ( + await prisma.projects.findMany({ + where: { OrganizationId }, + select: { Id: true } + }) + ).flatMap((p) => [ + { + name: `${enabled ? 'Add' : 'Remove'} OrgAdmin tasks for User #${UserId} on Project #${p.Id}`, + data: { + type: BullMQ.JobType.UserTasks_Workflow, + scope: 'Project', + projectId: p.Id, + operation: { + type: enabled ? BullMQ.UserTasks.OpType.Create : BullMQ.UserTasks.OpType.Delete, + users: [UserId], + roles: [RoleId.OrgAdmin] + } } - } - }, - { - name: `${enabled ? 'Add' : 'Remove'} OrgAdmin data deletion requests for User #${UserId} on Project #${p.Id}`, - data: { - type: BullMQ.JobType.UserTasks_DeleteRequest, - scope: 'Project', - projectId: p.Id, - operation: { - type: enabled ? BullMQ.UserTasks.OpType.Create : BullMQ.UserTasks.OpType.Delete, - users: [UserId], - roles: [RoleId.OrgAdmin] + }, + { + name: `${enabled ? 'Add' : 'Remove'} OrgAdmin data deletion requests for User #${UserId} on Project #${p.Id}`, + data: { + type: BullMQ.JobType.UserTasks_DeleteRequest, + scope: 'Project', + projectId: p.Id, + operation: { + type: enabled ? BullMQ.UserTasks.OpType.Create : BullMQ.UserTasks.OpType.Delete, + users: [UserId], + roles: [RoleId.OrgAdmin] + } } } - } - ]) + ]) + ); + } + + getQueues().SvelteSSE.add( + `Update Projects for Org #${OrganizationId} (role #${role} user #${UserId} ${enabled ? 'added' : 'removed'})`, + { + type: BullMQ.JobType.SvelteSSE_UpdateProjectGroups, + projectIds: ( + await prisma.projects.findMany({ + where: { OrganizationId }, + select: { Id: true } + }) + ).map((p) => p.Id) + } ); } return true; @@ -135,7 +153,7 @@ export async function toggleGroup( select: { Id: true } }); - return ( + const updated = orgOwnsGroup && userInOrg && (enabled || !userHasProjectInGroup) && @@ -151,8 +169,24 @@ export async function toggleGroup( select: { Id: true } - })) - ); + })); + + if (updated) { + getQueues().SvelteSSE.add( + `Update Projects for Group #${GroupId} (user #${UserId} ${enabled ? 'added' : 'removed'})`, + { + type: BullMQ.JobType.SvelteSSE_UpdateProjectGroups, + projectIds: ( + await prisma.projects.findMany({ + where: { GroupId }, + select: { Id: true } + }) + ).map((p) => p.Id) + } + ); + } + + return updated; } export async function acceptInvite(userId: number, inviteToken: string) { @@ -217,6 +251,20 @@ export async function acceptInvite(userId: number, inviteToken: string) { Redeemed: true } }); + + getQueues().SvelteSSE.add( + `Update Projects for Org #${invite.OrganizationId} (user #${userId} added)`, + { + type: BullMQ.JobType.SvelteSSE_UpdateProjectGroups, + projectIds: ( + await prisma.projects.findMany({ + where: { OrganizationId: invite.OrganizationId }, + select: { Id: true } + }) + ).map((p) => p.Id) + } + ); + return true; } From 764f4e79ec6fb3cf678f466877e92da15a010347 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Thu, 5 Mar 2026 10:47:03 -0600 Subject: [PATCH 06/20] Filter possible owners by role --- src/lib/projects/sse.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/projects/sse.ts b/src/lib/projects/sse.ts index 1dc1cc7308..a0fae8d63e 100644 --- a/src/lib/projects/sse.ts +++ b/src/lib/projects/sse.ts @@ -323,6 +323,12 @@ export async function getProjectGroupData(id: number, userSession: Session['user some: { Id: project.GroupId } + }, + UserRoles: { + some: { + OrganizationId: project.OrganizationId, + RoleId: { in: [RoleId.AppBuilder, RoleId.OrgAdmin] } + } } }, select: { From 7e023e3f85d3fefe1e7a0ec899d2a93cf99e52fd Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Thu, 5 Mar 2026 11:48:01 -0600 Subject: [PATCH 07/20] Remove unneeded chaining --- src/lib/projects/sse.ts | 2 ++ .../projects/[id=number]/+page.svelte | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/lib/projects/sse.ts b/src/lib/projects/sse.ts index a0fae8d63e..3616410549 100644 --- a/src/lib/projects/sse.ts +++ b/src/lib/projects/sse.ts @@ -266,6 +266,7 @@ export async function getProjectDetails(id: number, userSession: Session['user'] code: SpanStatusCode.ERROR, message: (e as Error).message }); + throw error(500); } finally { span.end(); } @@ -382,6 +383,7 @@ export async function getProjectGroupData(id: number, userSession: Session['user code: SpanStatusCode.ERROR, message: (e as Error).message }); + throw error(500); } finally { span.end(); } diff --git a/src/routes/(authenticated)/projects/[id=number]/+page.svelte b/src/routes/(authenticated)/projects/[id=number]/+page.svelte index ba6222d49f..378af39aa5 100644 --- a/src/routes/(authenticated)/projects/[id=number]/+page.svelte +++ b/src/routes/(authenticated)/projects/[id=number]/+page.svelte @@ -55,30 +55,28 @@ const projectData = $derived($projectDataSSE ?? data.projectData); const groupData = $derived($groupDataSSE ?? data.groupData); const orgData = $derived($orgDataSSE ?? data.orgData); - const dateCreated = $derived(getRelativeTime(projectData?.project?.DateCreated ?? null)); - const dateArchived = $derived(getRelativeTime(projectData?.project?.DateArchived ?? null)); + const dateCreated = $derived(getRelativeTime(projectData.project.DateCreated ?? null)); + const dateArchived = $derived(getRelativeTime(projectData.project.DateArchived ?? null)); const canEdit = $derived( canModifyProject( data.session.user, - projectData?.project.OwnerId ?? -1, - projectData?.project.OrganizationId ?? -1 + projectData.project.OwnerId, + projectData.project.OrganizationId ) ); const canClaim = $derived( canClaimProject( data.session.user, - projectData?.project.OwnerId ?? -1, - projectData?.project.OrganizationId ?? -1, - projectData?.project.GroupId ?? -1, - groupData?.userGroups ?? [] + projectData.project.OwnerId, + projectData.project.OrganizationId, + projectData.project.GroupId, + groupData.userGroups ) ); const { productMap, availableProducts } = $derived.by(() => { - const activeProducts = new Set( - projectData?.project.Products.map((p) => p.ProductDefinition.Id) - ); + const activeProducts = new Set(projectData.project.Products.map((p) => p.ProductDefinition.Id)); return { productMap: new Map(orgData.ProductDefinitions.map((pd) => [pd.Id, pd])), availableProducts: orgData.ProductDefinitions.filter((pd) => !activeProducts.has(pd.Id)) @@ -96,7 +94,7 @@

- {projectData.project?.Name} + {projectData.project.Name}

@@ -110,7 +108,7 @@
- {#if projectData?.project?.DateArchived} + {#if projectData.project.DateArchived} {m.project_archivedOn()} @@ -241,7 +239,7 @@
- {#if !projectData?.project?.Products.length} + {#if !projectData.project.Products.length} {m.projectTable_noProducts()} {:else} {@const products = projectData.project.Products.map((p) => ({ From 80533086784fbb1a3f69c5e5b352c4a737085bcc Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Thu, 5 Mar 2026 13:07:25 -0600 Subject: [PATCH 08/20] Block store owner assignment if shared by multiple --- .../components/StoreListDisplay.svelte | 18 ++++++++- .../admin/settings/stores/+page.server.ts | 40 ++++++++++++++++++- .../admin/settings/stores/+page.svelte | 1 + .../settings/stores/edit/+page.server.ts | 5 ++- .../admin/settings/stores/edit/+page.svelte | 3 +- .../settings/stores/edit/+page.svelte | 2 + 6 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/lib/organizations/components/StoreListDisplay.svelte b/src/lib/organizations/components/StoreListDisplay.svelte index ad2a46566b..f6389461ac 100644 --- a/src/lib/organizations/components/StoreListDisplay.svelte +++ b/src/lib/organizations/components/StoreListDisplay.svelte @@ -6,13 +6,17 @@ > import type { Prisma } from '@prisma/client'; import type { Snippet } from 'svelte'; + import { page } from '$app/state'; import Tooltip from '$lib/components/Tooltip.svelte'; import DataDisplayBox from '$lib/components/settings/DataDisplayBox.svelte'; import { Icons, getStoreIcon } from '$lib/icons'; import IconContainer from '$lib/icons/IconContainer.svelte'; import { m } from '$lib/paraglide/messages'; + import { getLocale } from '$lib/paraglide/runtime'; import { StoreType, displayStoreGPTitle } from '$lib/prisma'; import type { ValidI13nKey } from '$lib/utils'; + import { isSuperAdmin } from '$lib/utils/roles'; + import { byName } from '$lib/utils/sorting'; interface Props { editable: boolean; @@ -21,9 +25,10 @@ getTitle: (store: Store) => string; extra?: Snippet<[Store]>; showDescription?: boolean; + users?: (Prisma.OrganizationsGetPayload<{ select: { Name: true } }> & { Products: number })[]; } - let { editable, editLink, store, getTitle, extra, showDescription }: Props = $props(); + let { editable, editLink, store, getTitle, extra, showDescription, users }: Props = $props(); const missingGPTitle = $derived( store.StoreTypeId === StoreType.GooglePlay && editable && !store.GooglePlayTitle @@ -55,6 +60,17 @@ snippet: missingGPTitle ? gpTitleError : undefined } ] + : []), + ...(users?.length && isSuperAdmin(page.data.session!.user.roles) + ? [ + { + key: 'org_title' as ValidI13nKey, + value: users + .toSorted((a, b) => byName(a, b, getLocale())) + .map((u) => `${u.Name} (${u.Products})`) + .join(', ') + } + ] : []) ]} > diff --git a/src/routes/(authenticated)/admin/settings/stores/+page.server.ts b/src/routes/(authenticated)/admin/settings/stores/+page.server.ts index 5e992be6e9..fa6bbd0605 100644 --- a/src/routes/(authenticated)/admin/settings/stores/+page.server.ts +++ b/src/routes/(authenticated)/admin/settings/stores/+page.server.ts @@ -6,8 +6,44 @@ import { DatabaseReads } from '$lib/server/database'; export const load = (async (event) => { event.locals.security.requireSuperAdmin(); const stores = await DatabaseReads.stores.findMany({ - include: { StoreType: true, Owner: { select: { Name: true } } } + include: { + StoreType: true, + Owner: { select: { Name: true } } + } }); - return { stores }; + return { + stores, + users: new Map( + ( + await DatabaseReads.stores.findMany({ + select: { + Id: true, + Organizations: { + select: { + Id: true, + Name: true + } + }, + Products: { + select: { + Project: { + select: { + OrganizationId: true + } + } + }, + distinct: 'ProjectId' + } + } + }) + ).map((s) => [ + s.Id, + s.Organizations.map((o) => ({ + Name: o.Name, + Products: s.Products.filter((p) => p.Project.OrganizationId === o.Id).length + })).filter((o) => !!o.Products) + ]) + ) + }; }) satisfies PageServerLoad; diff --git a/src/routes/(authenticated)/admin/settings/stores/+page.svelte b/src/routes/(authenticated)/admin/settings/stores/+page.svelte index 8a17889876..90e0d55ab8 100644 --- a/src/routes/(authenticated)/admin/settings/stores/+page.svelte +++ b/src/routes/(authenticated)/admin/settings/stores/+page.svelte @@ -31,6 +31,7 @@ {store} getTitle={(store) => store.BuildEnginePublisherId} showDescription + users={data.users.get(store.Id)} /> {/each}
diff --git a/src/routes/(authenticated)/admin/settings/stores/edit/+page.server.ts b/src/routes/(authenticated)/admin/settings/stores/edit/+page.server.ts index e5245532bf..23a0dc3e03 100644 --- a/src/routes/(authenticated)/admin/settings/stores/edit/+page.server.ts +++ b/src/routes/(authenticated)/admin/settings/stores/edit/+page.server.ts @@ -38,7 +38,10 @@ export const load = (async ({ url, locals }) => { }, valibot(editSchema) ), - options: await DatabaseReads.storeTypes.findMany() + options: await DatabaseReads.storeTypes.findMany(), + orgCount: await DatabaseReads.organizations.count({ + where: { Projects: { some: { Products: { some: { StoreId: id } } } } } + }) }; }) satisfies PageServerLoad; diff --git a/src/routes/(authenticated)/admin/settings/stores/edit/+page.svelte b/src/routes/(authenticated)/admin/settings/stores/edit/+page.svelte index f1d3db481b..84f6e0a0df 100644 --- a/src/routes/(authenticated)/admin/settings/stores/edit/+page.svelte +++ b/src/routes/(authenticated)/admin/settings/stores/edit/+page.svelte @@ -54,12 +54,13 @@ icon: getStoreIcon(data.store.StoreTypeId) }} value={data.store.StoreType.Description} + class="md:max-w-xs" /> 1 }} icon={Icons.Organization} > {#snippet extra()} diff --git a/src/routes/(authenticated)/organizations/[id=number]/settings/stores/edit/+page.svelte b/src/routes/(authenticated)/organizations/[id=number]/settings/stores/edit/+page.svelte index b206a5a99b..d55ee1d69d 100644 --- a/src/routes/(authenticated)/organizations/[id=number]/settings/stores/edit/+page.svelte +++ b/src/routes/(authenticated)/organizations/[id=number]/settings/stores/edit/+page.svelte @@ -50,9 +50,11 @@ key="common_type" input={{ readonly: true, + disabled: true, icon: getStoreIcon(data.store.StoreTypeId) }} value={data.store.StoreType.Description} + class="md:max-w-xs" /> {#if data.store.StoreTypeId === StoreType.GooglePlay} From 8a379cb6213b27406c0189543f8db22a5374af92 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Thu, 5 Mar 2026 13:19:22 -0600 Subject: [PATCH 09/20] Display product count in store list --- .../[id=number]/settings/stores/+page.server.ts | 13 ++++++++++++- .../[id=number]/settings/stores/+page.svelte | 4 ++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/routes/(authenticated)/organizations/[id=number]/settings/stores/+page.server.ts b/src/routes/(authenticated)/organizations/[id=number]/settings/stores/+page.server.ts index 7c6f396a69..18033f51bd 100644 --- a/src/routes/(authenticated)/organizations/[id=number]/settings/stores/+page.server.ts +++ b/src/routes/(authenticated)/organizations/[id=number]/settings/stores/+page.server.ts @@ -29,7 +29,18 @@ export const load = (async (event) => { include: { Organizations: { where: { Id: organization.Id }, select: { Id: true } }, StoreType: true, - Owner: { select: { Name: true } } + Owner: { select: { Name: true } }, + _count: { + select: { + Products: { + where: { + Project: { + OrganizationId: organization.Id + } + } + } + } + } } }) ).map((s) => ({ diff --git a/src/routes/(authenticated)/organizations/[id=number]/settings/stores/+page.svelte b/src/routes/(authenticated)/organizations/[id=number]/settings/stores/+page.svelte index a4e22e3a22..ea9b637442 100644 --- a/src/routes/(authenticated)/organizations/[id=number]/settings/stores/+page.svelte +++ b/src/routes/(authenticated)/organizations/[id=number]/settings/stores/+page.svelte @@ -68,6 +68,10 @@ /> +
+ + {store._count.Products} +
{/snippet} {/each} From 92ea8300fe288fa2a7295027b650c9049b28dd28 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Thu, 5 Mar 2026 13:42:29 -0600 Subject: [PATCH 10/20] Fix display for disabled stores/prodDefs --- src/lib/projects/sse.ts | 84 +++++++++++-------- .../projects/[id=number]/+page.svelte | 6 +- 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/src/lib/projects/sse.ts b/src/lib/projects/sse.ts index 3616410549..4f7c09b603 100644 --- a/src/lib/projects/sse.ts +++ b/src/lib/projects/sse.ts @@ -409,44 +409,62 @@ export async function getProjectOrgData(id: number, userSession: Session['user'] } }); span.addEvent('Project fetched'); - return await DatabaseReads.organizations.findUniqueOrThrow({ - where: { - Id: project.OrganizationId - }, - select: { - Stores: { - select: { - Id: true, - BuildEnginePublisherId: true, - GooglePlayTitle: true, - Description: true, - StoreTypeId: true + return { + Stores: await DatabaseReads.stores.findMany({ + where: { + OR: [ + { Organizations: { some: { Id: project.OrganizationId } } }, + { Products: { some: { ProjectId: id } } } + ] + }, + select: { + Id: true, + BuildEnginePublisherId: true, + GooglePlayTitle: true, + Description: true, + StoreTypeId: true, + _count: { + select: { + Organizations: { where: { Id: project.OrganizationId } } + } } + } + }), + ProductDefinitions: await DatabaseReads.productDefinitions.findMany({ + where: { + OR: [ + { + Organizations: { some: { Id: project.OrganizationId } }, + OR: [ + { AllowAllApplicationTypes: true }, + { ApplicationTypes: { some: { Id: project.TypeId } } } + ] + }, + { + Products: { some: { ProjectId: id } } + } + ] }, - ProductDefinitions: { - where: { - Organizations: { some: { Id: project.OrganizationId } }, - OR: [ - { AllowAllApplicationTypes: true }, - { ApplicationTypes: { some: { Id: project.TypeId } } } - ] + select: { + Id: true, + Name: true, + Description: true, + Workflow: { + select: { + ProductType: true, + StoreTypeId: true + } }, - select: { - Id: true, - Name: true, - Description: true, - Workflow: { - select: { - ProductType: true, - StoreTypeId: true - } - }, - RebuildWorkflowId: true, - RepublishWorkflowId: true + RebuildWorkflowId: true, + RepublishWorkflowId: true, + _count: { + select: { + Organizations: { where: { Id: project.OrganizationId } } + } } } - } - }); + }) + }; } catch (e) { span.recordException(e as Error); span.setStatus({ diff --git a/src/routes/(authenticated)/projects/[id=number]/+page.svelte b/src/routes/(authenticated)/projects/[id=number]/+page.svelte index 378af39aa5..4ea37c518a 100644 --- a/src/routes/(authenticated)/projects/[id=number]/+page.svelte +++ b/src/routes/(authenticated)/projects/[id=number]/+page.svelte @@ -79,7 +79,9 @@ const activeProducts = new Set(projectData.project.Products.map((p) => p.ProductDefinition.Id)); return { productMap: new Map(orgData.ProductDefinitions.map((pd) => [pd.Id, pd])), - availableProducts: orgData.ProductDefinitions.filter((pd) => !activeProducts.has(pd.Id)) + availableProducts: orgData.ProductDefinitions.filter( + (pd) => !activeProducts.has(pd.Id) && pd._count.Organizations + ) }; }); @@ -232,7 +234,7 @@ !!s._count.Organizations)} endpoint="addProduct" /> {/if} From a6a3cd4635d4d9578f1116e7a872902cc7d09fc7 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Fri, 6 Mar 2026 13:23:41 -0600 Subject: [PATCH 11/20] Fix product validation for disabled store/definitions --- src/lib/server/database/Products.ts | 89 ++++++++++++++++------------- 1 file changed, 50 insertions(+), 39 deletions(-) diff --git a/src/lib/server/database/Products.ts b/src/lib/server/database/Products.ts index 72f32aac8c..99fe163512 100644 --- a/src/lib/server/database/Products.ts +++ b/src/lib/server/database/Products.ts @@ -144,9 +144,9 @@ export { deleteProduct as delete }; /** A product is valid if: * 1. The store's type matches the Workflow's store type * 2. The project has a RepositoryUrl - * 3. The store is allowed by the organization + * 3. The store (if being set) is allowed by the organization * 4. The language is allowed by the store - * 5. The product type is allowed by the organization + * 5. The product type (if being set) is allowed by the organization * 6. The product type allows the project type */ async function validateProductBase( @@ -157,6 +157,18 @@ async function validateProductBase( storeLanguageId: number | undefined, productId?: string ) { + let newDefinition = true; + let newStore = true; + if (productId) { + const product = await prisma.products.findUnique({ + where: { Id: productId }, + select: { ProductDefinitionId: true, StoreId: true } + }); + if (product) { + newDefinition = product.ProductDefinitionId !== productDefinitionId; + newStore = product.StoreId !== storeId; + } + } const productDefinition = await prisma.productDefinitions.findUnique({ where: { Id: productDefinitionId @@ -193,34 +205,10 @@ async function validateProductBase( // Store must be allowed by Organization Stores: { where: { - Id: storeId, - // The language, if specified, is allowed by the store - StoreType: - storeLanguageId !== undefined - ? { - StoreLanguages: { - some: { - Id: storeLanguageId - } - } - } - : undefined + Id: storeId }, select: { - StoreType: { - select: { - // Store type must match Workflow store type - Id: true, - StoreLanguages: { - where: { - Id: storeLanguageId - }, - select: { - Id: true - } - } - } - } + Id: true } }, // Product type must be allowed by Organization @@ -234,26 +222,47 @@ async function validateProductBase( } }); + const store = await prisma.stores.findUnique({ + where: { Id: storeId }, + select: { + StoreType: { + select: { + Id: true, + StoreLanguages: { + // The language, if specified, is allowed by the store + where: { + Id: storeLanguageId + }, + select: { + Id: true + }, + take: 1 + } + } + } + } + }); + /** 3. The store is allowed by the organization */ - const storeInOrg = (project?.Organization.Stores.length ?? 0) > 0; + const storeAllowed = !newStore || (project?.Organization.Stores.length ?? 0) > 0; - const prodDefStore = productDefinition?.Workflow.StoreTypeId; - const orgStore = project?.Organization.Stores[0]?.StoreType.Id; + const prodDefStoreType = productDefinition?.Workflow.StoreTypeId; + const storeType = store?.StoreType.Id; /** 1. The store's type matches the Workflow's store type * * Note: if both are undefined, this would be `true`; however, under those circumstances, * condition #3 would evaluate to `false`, rendering the whole check `false`. */ - const storeMatchFlowStore = prodDefStore === orgStore; + const storeMatchFlowStore = prodDefStoreType === storeType; - const storeLang = project?.Organization.Stores.at(0)?.StoreType.StoreLanguages.at(0); + const storeLang = store?.StoreType.StoreLanguages.at(0); /** 4. The language, if specified, is allowed by the store */ const optionalLanguageAllowed = storeLanguageId === undefined || storeLang?.Id === storeLanguageId; const numOrgProdDefs = project?.Organization.ProductDefinitions.length; /** 5. The product type is allowed by the organization */ - const productInOrg = (numOrgProdDefs ?? 0) > 0; + const productAllowed = !newDefinition || (numOrgProdDefs ?? 0) > 0; /** 6. The product definition allows the project type */ const projectTypeAllowed = !!( @@ -262,10 +271,10 @@ async function validateProductBase( ); const check = - storeInOrg && + storeAllowed && storeMatchFlowStore && optionalLanguageAllowed && - productInOrg && + productAllowed && projectTypeAllowed; if (!check) { @@ -277,11 +286,13 @@ async function validateProductBase( 'product.product-definition-id': productDefinitionId, 'product.store-id': storeId, 'product.store-language-id': storeLanguageId ?? false, - 'product.store-in-org': storeInOrg, + 'product.store-in-org': storeAllowed, 'product.store-match-workflow': storeMatchFlowStore, 'product.language-allowed': optionalLanguageAllowed, - 'product.product-definition-allowed': productInOrg, - 'product.project-type-allowed': projectTypeAllowed + 'product.product-definition-allowed': productAllowed, + 'product.project-type-allowed': projectTypeAllowed, + 'product.new-store': newStore, + 'product.new-definition': newDefinition }); span.recordException(new Error(msg)); From 10daa6f57926b026bb8dfc5718108a8f1f723bc2 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Fri, 6 Mar 2026 14:11:31 -0600 Subject: [PATCH 12/20] Update product enable to match stores --- .../settings/products/+page.server.ts | 13 ++++- .../settings/products/+page.svelte | 52 ++++++++++++------- 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/routes/(authenticated)/organizations/[id=number]/settings/products/+page.server.ts b/src/routes/(authenticated)/organizations/[id=number]/settings/products/+page.server.ts index fcb4c9a11c..258ba44072 100644 --- a/src/routes/(authenticated)/organizations/[id=number]/settings/products/+page.server.ts +++ b/src/routes/(authenticated)/organizations/[id=number]/settings/products/+page.server.ts @@ -22,7 +22,18 @@ export const load = (async (event) => { await DatabaseReads.productDefinitions.findMany({ include: { Organizations: { where: { Id: organization.Id }, select: { Id: true } }, - Workflow: { select: { ProductType: true } } + Workflow: { select: { ProductType: true } }, + _count: { + select: { + Products: { + where: { + Project: { + OrganizationId: organization.Id + } + } + } + } + } } }) ).map((pd) => ({ diff --git a/src/routes/(authenticated)/organizations/[id=number]/settings/products/+page.svelte b/src/routes/(authenticated)/organizations/[id=number]/settings/products/+page.svelte index cad7bcf686..e0d3b06eee 100644 --- a/src/routes/(authenticated)/organizations/[id=number]/settings/products/+page.svelte +++ b/src/routes/(authenticated)/organizations/[id=number]/settings/products/+page.svelte @@ -1,8 +1,8 @@ -{#snippet transitionType(transition: (typeof transitions)[0], showRecs: boolean)} - {#if transition.TransitionType === ProductTransitionType.Activity} +{#snippet transitionType(transition: MinifiedProductDetails['PT'][number], showRecs: boolean)} + {#if transition.T === ProductTransitionType.Activity} {@html formatBuildEngineLink( linkToBuildEngine( - (transition.DateTransition && - transition.InitialState && - isBackground(transition.InitialState as WorkflowState)) || - showRecs - ? product.BuildEngineUrl + (transition.D && transition.S && isBackground(transition.S as WorkflowState)) || showRecs + ? product.BE : undefined, getBuildOrPub(transition), - transition.InitialState as WorkflowState + transition.S as WorkflowState ), - transition.InitialState ?? '' + transition.S ?? '' )} {:else} {@const icon = getTransitionIcon( - transition.TransitionType, - transition.WorkflowType ?? 1, - transition.Command ?? transition.InitialState?.match(/(Download|Upload)/)?.at(1) ?? null + transition.T, + transition.W ?? 1, + transition.Cd ?? transition.S?.match(/(Download|Upload)/)?.at(1) ?? null )} {#if icon}   {/if} - {#if transition.TransitionType === ProductTransitionType.ProjectAccess} - {transition.InitialState} - {:else if isLandmark(transition.TransitionType)} - {stateString(transition.WorkflowType ?? 1, transition.TransitionType)} + {#if transition.T === ProductTransitionType.ProjectAccess} + {transition.S} + {:else if isLandmark(transition.T)} + {stateString(transition.W ?? 1, transition.T)} {:else} {m.transitions_types({ - type: transition.TransitionType as ProductTransitionType, + type: transition.T as ProductTransitionType, workflowType: '' })} {/if} {/if} {/snippet} -{#snippet queueRecords(trans: Transition)} - {@const records = trans.QueueRecords} +{#snippet queueRecords(trans: MinifiedProductDetails['PT'][number])} + {@const records = trans.QR}
{m.products_jobRecords()} ({records.length})
{/snippet} - +
{#each entries as transition} - {#if 'DateTransition' in transition} - {@const showRecs = isSuper && !!transition.QueueRecords.length} + {#if 'D' in transition} + {@const showRecs = isSuper && !!transition.QR.length} {#if showRecs} - + {/if} - {#if transition.Comment} + {#if transition.Ct} {/if} @@ -263,45 +214,41 @@ {#each entries as transition} - {#if 'DateTransition' in transition} - {@const showRecs = isSuper && !!transition.QueueRecords.length} + {#if 'D' in transition} + {@const showRecs = isSuper && !!transition.QR.length} - {#if !isLandmark(transition.TransitionType)} - + {#if !isLandmark(transition.T)} + {/if} {#if showRecs} - + {/if} - {#if transition.Comment} + {#if transition.Ct} - + {/if} {:else} diff --git a/src/lib/products/components/ReleaseInfo.svelte b/src/lib/products/components/ReleaseInfo.svelte index 21fe71dc38..3ba287904d 100644 --- a/src/lib/products/components/ReleaseInfo.svelte +++ b/src/lib/products/components/ReleaseInfo.svelte @@ -46,9 +46,9 @@ linkToBuildEngine( buildEngineUrl, { - BuildEngineJobId: 0, - CurrentBuildId: null, - CurrentReleaseId: release.BuildEngineReleaseId + J: 0, + CB: null, + CR: release.BuildEngineReleaseId }, WorkflowState.Product_Publish ), @@ -114,9 +114,9 @@ linkToBuildEngine( buildEngineUrl, { - BuildEngineJobId: 0, - CurrentBuildId: null, - CurrentReleaseId: release.BuildEngineReleaseId + J: 0, + CB: null, + CR: release.BuildEngineReleaseId }, WorkflowState.Product_Publish ), diff --git a/src/lib/products/index.ts b/src/lib/products/index.ts index e3b1b4644d..90c18ca7e1 100644 --- a/src/lib/products/index.ts +++ b/src/lib/products/index.ts @@ -14,24 +14,14 @@ export enum ProductActionType { export function getProductActions( product: Prisma.ProductsGetPayload<{ select: { - WorkflowInstance: { - select: { - State: true; - WorkflowDefinition: { - select: { - Type: true; - }; - }; - }; - }; - DatePublished: true; ProductDefinition: { select: { RebuildWorkflowId: true; RepublishWorkflowId: true } }; }; - }> + }> & + MinifiedProductCard ) { const ret: ProductActionType[] = []; - if (!product.WorkflowInstance) { - if (product.DatePublished) { + if (!product.WT) { + if (product.DP) { if (product.ProductDefinition.RebuildWorkflowId !== null) { ret.push(ProductActionType.Rebuild); } @@ -40,13 +30,13 @@ export function getProductActions( } } } else { - if (product.WorkflowInstance.WorkflowDefinition.Type !== WorkflowType.Startup) { + if (product.WT !== WorkflowType.Startup) { ret.push(ProductActionType.CancelWorkflow); } - if (product.WorkflowInstance.State === WorkflowState.Product_Build) { + if (product.WS === WorkflowState.Product_Build) { ret.push(ProductActionType.StopBuild); } - if (product.WorkflowInstance.State === WorkflowState.Product_Publish) { + if (product.WS === WorkflowState.Product_Publish) { ret.push(ProductActionType.StopPublish); } } @@ -120,3 +110,181 @@ export function getComputeType(properties: string | null) { } return null; } + +/** + * I: ProductId + * J: BuildEngineJobId + * CB: CurrentBuildId + * CR: CurrentReleaseId + * PB: ProductBuilds { I: BuildEngineBuildId, T: TransitionId } + * PR: ProductPublications { I: BuildEngineReleaseId, T: TransitionId } + * PT: ProductTransitions + * BE: BuildEngineUrl + */ +export type MinifiedProductDetails = ReturnType; +export function minifyProductDetails( + product: Partial< + Prisma.ProductsGetPayload<{ + select: { + BuildEngineJobId: true; + CurrentBuildId: true; + CurrentReleaseId: true; + ProductBuilds: { + select: { + BuildEngineBuildId: true; + TransitionId: true; + }; + }; + ProductPublications: { + select: { + BuildEngineReleaseId: true; + TransitionId: true; + }; + }; + }; + }> + > & { Id: string; ProductTransitions: Transition[] }, + buildEngineUrl?: string | null +) { + return { + I: product.Id, + J: product.BuildEngineJobId, + CB: product.CurrentBuildId, + CR: product.CurrentReleaseId, + PB: product.ProductBuilds?.map(({ BuildEngineBuildId, TransitionId }) => ({ + I: BuildEngineBuildId, + T: TransitionId + })), + PR: product.ProductPublications?.map(({ BuildEngineReleaseId, TransitionId }) => ({ + I: BuildEngineReleaseId, + T: TransitionId + })), + PT: product.ProductTransitions.map(minifyTransition), + BE: buildEngineUrl + }; +} + +type Transition = Prisma.ProductTransitionsGetPayload<{ + select: { + Id: true; + TransitionType: true; + InitialState: true; + WorkflowType: true; + AllowedUserNames: true; + Command: true; + Comment: true; + DateTransition: true; + User: { select: { Id: true; Name: true } }; + QueueRecords: { + select: { + Queue: true; + JobId: true; + JobType: true; + }; + }; + }; +}>; + +/** + * I: ProductTransitionId + * T: TransitionType + * S: Initial State + * W: WorkflowType + * AU: AllowedUserNames + * Cd: Command + * Ct: Comment + * D: DateTransition + * U: User.Name + * QR: QueueRecords { Q: Queue, I: JobId, T: JobType } + */ +export type MinifiedTransition = ReturnType; +export function minifyTransition(pt: Transition) { + return { + I: pt.Id, + T: pt.TransitionType, + S: pt.InitialState, + W: pt.WorkflowType, + AU: pt.AllowedUserNames, + Cd: pt.Command, + Ct: pt.Comment, + D: pt.DateTransition, + UI: pt.User?.Id, + UN: pt.User?.Name, + QR: pt.QueueRecords?.map((qr) => ({ Q: qr.Queue, I: qr.JobId, T: qr.JobType })) + }; +} + +/** + * I: ProductId + * DP: DatePublished + * DU: DateUpdated + * L: PublishLink + * S: StoreId + * PD: ProductDefinitionId + * UT: UserTasks { U: UserId, D: DateCreated } + * WS: WorkflowInstance.State + * WT: WorkflowInstance.Type + * AcT: ActiveTransition + * PrT: PreviousTransition.Date + * ABS: ProductBuilds[0].Status + * APS: ProductPublications[0].Status + */ +export type MinifiedProductCard = ReturnType; +export function minifyProductCard( + product: Prisma.ProductsGetPayload<{ + select: { + Id: true; + DatePublished: true; + DateUpdated: true; + Properties: true; + PublishLink: true; + StoreId: true; + ProductDefinitionId: true; + UserTasks: { + select: { + UserId: true; + DateCreated: true; + }; + }; + WorkflowInstance: { + select: { + State: true; + WorkflowDefinition: { + select: { + Type: true; + }; + }; + }; + }; + ProductBuilds: { + select: { + Status: true; + }; + }; + ProductPublications: { + select: { + Status: true; + }; + }; + }; + }>, + ActiveTransition: Transition | undefined, + PreviousTransition: Transition | undefined +) { + return { + I: product.Id, + DP: product.DatePublished, + DU: product.DateUpdated, + P: product.Properties, + L: product.PublishLink, + S: product.StoreId, + PD: product.ProductDefinitionId, + UT: product.UserTasks.map((ut) => ({ U: ut.UserId, D: ut.DateCreated })), + WS: product.WorkflowInstance && product.WorkflowInstance.State, + WT: product.WorkflowInstance && product.WorkflowInstance.WorkflowDefinition.Type, + AcT: ActiveTransition && minifyTransition(ActiveTransition), + PrT: PreviousTransition && PreviousTransition.DateTransition, + ABS: product.ProductBuilds.at(0)?.Status, + APS: product.ProductPublications.at(0)?.Status + }; +} diff --git a/src/lib/projects/sse.ts b/src/lib/projects/sse.ts index 922aa6889a..319ed59733 100644 --- a/src/lib/projects/sse.ts +++ b/src/lib/projects/sse.ts @@ -5,6 +5,7 @@ import { stringify } from 'devalue'; import { produce } from 'sveltekit-sse'; import { type SSEPageEvents, SSEPageUpdates } from './listener'; import { ProjectActionString, ProjectActionType, RoleId } from '$lib/prisma'; +import { minifyProductCard, minifyProductDetails } from '$lib/products'; import { userGroupsForOrg } from '$lib/projects/server'; import { getURLandToken } from '$lib/server/build-engine-api/requests'; import { DatabaseReads } from '$lib/server/database'; @@ -367,26 +368,24 @@ export async function getProjectProducts(id: number, userSession: Session['user' const isSuper = isSuperAdmin(userSession.roles); const BuildEngineUrl = isSuper - ? `${ - getURLandToken( - await DatabaseReads.organizations.findFirstOrThrow({ - where: { - Projects: { - some: { Id: id } + ? getURLandToken( + await DatabaseReads.organizations.findFirstOrThrow({ + where: { + Projects: { + some: { Id: id } + } + }, + select: { + System: { + select: { + BuildEngineApiAccessToken: true, + BuildEngineUrl: true } }, - select: { - System: { - select: { - BuildEngineApiAccessToken: true, - BuildEngineUrl: true - } - }, - UseDefaultBuildEngine: true - } - }) - ).url - }` + UseDefaultBuildEngine: true + } + }) + ).url : undefined; const products = await DatabaseReads.products.findMany({ @@ -399,11 +398,7 @@ export async function getProjectProducts(id: number, userSession: Session['user' DatePublished: true, PublishLink: true, Properties: true, - ProductDefinition: { - select: { - Id: true - } - }, + ProductDefinitionId: 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 @@ -482,14 +477,14 @@ export async function getProjectProducts(id: number, userSession: Session['user' return { products: products.map((p) => ({ - ...p, - PreviousTransition: p.ProductTransitions.findLast( - (tr) => tr.ProductId === p.Id && tr.DateTransition !== null - ), - ActiveTransition: p.ProductTransitions.find( - (tr) => tr.ProductId === p.Id && tr.DateTransition === null + ...minifyProductCard( + p, + p.ProductTransitions.findLast( + (tr) => tr.ProductId === p.Id && tr.DateTransition !== null + ), + p.ProductTransitions.find((tr) => tr.ProductId === p.Id && tr.DateTransition === null) ), - BuildEngineUrl + ...minifyProductDetails(p, BuildEngineUrl) })) }; } catch (e) { diff --git a/src/lib/workflowTypes.ts b/src/lib/workflowTypes.ts index 11795e0bfb..83f106ec1d 100644 --- a/src/lib/workflowTypes.ts +++ b/src/lib/workflowTypes.ts @@ -1,7 +1,6 @@ -import type { Prisma } from '@prisma/client'; import { type TransitionConfig } from 'xstate'; -import { WorkflowType } from './prisma'; -import type { RoleId } from './prisma'; +import { type RoleId, WorkflowType } from './prisma'; +import type { MinifiedProductDetails } from './products'; import type { SetFilter, ValueFilter } from './utils'; import { filterSet, filterValue, sanitizeInput } from './utils'; @@ -68,31 +67,25 @@ export function isBackground(state: WorkflowState): state is BackgroundState { export function linkToBuildEngine( buildEngineUrl: string | null | undefined, - product: Prisma.ProductsGetPayload<{ - select: { - BuildEngineJobId: true; - CurrentBuildId: true; - CurrentReleaseId: true; - }; - }>, + product: Pick, state: WorkflowState ) { if (!buildEngineUrl) return {}; switch (state) { case WorkflowState.Product_Creation: return { - href: `${buildEngineUrl}/job-admin${product.BuildEngineJobId ? `/view?id=${product.BuildEngineJobId}` : ''}`, - id: product.BuildEngineJobId + href: `${buildEngineUrl}/job-admin${product.J ? `/view?id=${product.J}` : ''}`, + id: product.J }; case WorkflowState.Product_Build: return { - href: `${buildEngineUrl}/build-admin${product.CurrentBuildId ? `/view?id=${product.CurrentBuildId}` : ''}`, - id: product.CurrentBuildId + href: `${buildEngineUrl}/build-admin${product.CB ? `/view?id=${product.CB}` : ''}`, + id: product.CB }; case WorkflowState.Product_Publish: return { - href: `${buildEngineUrl}/release-admin${product.CurrentReleaseId ? `/view?id=${product.CurrentReleaseId}` : ''}`, - id: product.CurrentReleaseId + href: `${buildEngineUrl}/release-admin${product.CR ? `/view?id=${product.CR}` : ''}`, + id: product.CR }; default: return {}; diff --git a/src/routes/(authenticated)/projects/[id=number]/+page.svelte b/src/routes/(authenticated)/projects/[id=number]/+page.svelte index 65fb16b747..9fef0290cb 100644 --- a/src/routes/(authenticated)/projects/[id=number]/+page.svelte +++ b/src/routes/(authenticated)/projects/[id=number]/+page.svelte @@ -84,7 +84,7 @@ ); const { productMap, availableProducts } = $derived.by(() => { - const activeProducts = new Set(productData.products.map((p) => p.ProductDefinition.Id)); + const activeProducts = new Set(productData.products.map((p) => p.PD)); return { productMap: new Map(orgData.ProductDefinitions.map((pd) => [pd.Id, pd])), availableProducts: orgData.ProductDefinitions.filter( @@ -254,8 +254,8 @@ {:else} {@const products = productData.products.map((p) => ({ ...p, - ProductDefinition: productMap.get(p.ProductDefinition.Id)!, - Store: orgData.Stores.find((s) => s.Id === p.StoreId)! + ProductDefinition: productMap.get(p.PD)!, + Store: orgData.Stores.find((s) => s.Id === p.S)! }))} {#each products.toSorted( (a, b) => byName(a.ProductDefinition, b.ProductDefinition, getLocale()) ) as product} ; product: Prisma.ProductsGetPayload<{ select: { - DatePublished: true; - DateUpdated: true; - Properties: true; - PublishLink: true; ProductDefinition: { select: { Name: true; @@ -60,39 +56,11 @@ RepublishWorkflowId: true; }; }; - UserTasks: { - select: { - UserId: true; - DateCreated: true; - }; - }; - WorkflowInstance: { - select: { - State: true; - WorkflowDefinition: { - select: { - Type: true; - }; - }; - }; - }; Store: { select: { StoreTypeId: true; Description: true } }; - ProductBuilds: { - select: { - Status: true; - }; - }; - ProductPublications: { - select: { - Status: true; - }; - }; }; - }> & { - ProductTransitions: Transition[]; - ActiveTransition?: Transition; - PreviousTransition?: Transition; - } & ProductDetailProps['product']; + }> & + MinifiedProductCard & + ProductDetailProps['product']; actionEndpoint: string; deleteEndpoint: string; updateEndpoint: string; @@ -112,7 +80,7 @@ let deleteProductModal: HTMLDialogElement | undefined = $state(undefined); let updateProductModal: HTMLDialogElement | undefined = $state(undefined); - const showTaskWaiting = $derived(!!product.WorkflowInstance); + const showTaskWaiting = $derived(!!product.WS); const highlighted = $derived(page.url.hash.substring(1)); const actions = $derived(canEdit ? getProductActions(product) : []); @@ -139,23 +107,17 @@ console.error('Error performing product action:', error); } } - const waitTime = $derived( - getRelativeTime( - product.UserTasks.slice(-1)[0]?.DateCreated ?? - product.PreviousTransition?.DateTransition ?? - null - ) - ); - const updatedTime = $derived(getRelativeTime(product.DateUpdated)); - const publishedTime = $derived(getRelativeTime(product.DatePublished)); + const waitTime = $derived(getRelativeTime(product.UT.slice(-1)[0]?.D ?? product.PrT ?? null)); + const updatedTime = $derived(getRelativeTime(product.DU)); + const publishedTime = $derived(getRelativeTime(product.DP));
@@ -165,7 +127,7 @@ /> {product.ProductDefinition.Name} @@ -181,13 +143,13 @@ {#snippet content()}
- {#if product.PublishLink} + {#if product.L} {@const pType = product.ProductDefinition.Workflow.ProductType} {#if pType !== ProductType.Web}
{m.common_updated()}: - + {$updatedTime}
@@ -268,7 +230,7 @@
{m.products_published()}: - + {$publishedTime}
@@ -276,7 +238,7 @@
{/if} - +
diff --git a/src/routes/(authenticated)/projects/[id=number]/modals/DeleteProduct.svelte b/src/routes/(authenticated)/projects/[id=number]/modals/DeleteProduct.svelte index b2e58bd2cd..cba855918e 100644 --- a/src/routes/(authenticated)/projects/[id=number]/modals/DeleteProduct.svelte +++ b/src/routes/(authenticated)/projects/[id=number]/modals/DeleteProduct.svelte @@ -9,21 +9,21 @@ import IconContainer from '$lib/icons/IconContainer.svelte'; import { m } from '$lib/paraglide/messages'; import { getLocale } from '$lib/paraglide/runtime'; + import type { MinifiedProductCard } from '$lib/products'; import { sanitizeInput, toast } from '$lib/utils'; interface Props { modal?: HTMLDialogElement; product: Prisma.ProductsGetPayload<{ select: { - Id: true; - DatePublished: true; ProductDefinition: { select: { Name: true; }; }; }; - }>; + }> & + MinifiedProductCard; endpoint: string; project: string; } @@ -58,8 +58,8 @@ })}
- - {#if product.DatePublished} + + {#if product.DP}
{@html m.deletePrompt_warningIfPublished()}
diff --git a/src/routes/(authenticated)/projects/[id=number]/modals/Properties.svelte b/src/routes/(authenticated)/projects/[id=number]/modals/Properties.svelte index e0bb22d139..46c2cd4b6e 100644 --- a/src/routes/(authenticated)/projects/[id=number]/modals/Properties.svelte +++ b/src/routes/(authenticated)/projects/[id=number]/modals/Properties.svelte @@ -1,5 +1,4 @@ {#snippet actionType(act: Action)} @@ -49,50 +28,50 @@ {#if icon}   {/if} - {#if act.ActionType === ProjectActionType.Access} - {act.Action} - {:else if act.ActionType === ProjectActionType.Author} + {#if act.T === ProjectActionType.Access} + {act.A} + {:else if act.T === ProjectActionType.Author} - {m[act.Action as ValidI13nKey]?.({ name: m.authors_title() } as any) ?? act.Action} - {:else if act.ActionType === ProjectActionType.Reviewer} + {m[act.A as ValidI13nKey]?.({ name: m.authors_title() } as any) ?? act.A} + {:else if act.T === ProjectActionType.Reviewer} - {m[act.Action as ValidI13nKey]?.({ name: m.reviewers_title() } as any) ?? act.Action} - {:else if act.ActionType === ProjectActionType.EditField} - {m.models_edit({ name: m[act.Action as ValidI13nKey]?.({} as never) ?? act.Action })} + {m[act.A as ValidI13nKey]?.({ name: m.reviewers_title() } as any) ?? act.A} + {:else if act.T === ProjectActionType.EditField} + {m.models_edit({ name: m[act.A as ValidI13nKey]?.({} as never) ?? act.A })} {:else} - {m[act.Action as ValidI13nKey]?.({} as never) ?? act.Action} + {m[act.A as ValidI13nKey]?.({} as never) ?? act.A} {/if} {/snippet} {#snippet details(act: Action)} - {#if act.ActionType === ProjectActionType.Product} - {@const pd = prodDefs?.find((p) => p.Id === act.ExternalId)} + {#if act.T === ProjectActionType.Product} + {@const pd = act.E && prodDefs?.get(act.E)}
{#if pd}
- - {pd.Name} + + {pd.N}
{/if} - {#if act.Action !== ProjectActionString.RemoveProduct} + {#if act.A !== ProjectActionString.RemoveProduct} {m.stores_name()}: {:else} {m.transitions_state()}: {/if} - {act.Value} + {act.V}
- {:else if act.ActionType === ProjectActionType.OwnerGroup || act.ActionType === ProjectActionType.Author} - {#if act.Action === ProjectActionString.AssignGroup} - {groups?.find((g) => g.Id === act.ExternalId)?.Name} - {:else if act.Action !== ProjectActionString.Claim} - {users?.find((u) => u.Id === act.ExternalId)?.Name ?? `User #${act.ExternalId}`} + {:else if act.T === ProjectActionType.OwnerGroup || act.T === ProjectActionType.Author} + {#if act.A === ProjectActionString.AssignGroup} + {(act.E && groups?.get(act.E)) ?? `Group #${act.E}`} + {:else if act.A !== ProjectActionString.Claim} + {(act.E && users?.get(act.E)) ?? `User #${act.E}`} {/if} - {:else if act.ActionType === ProjectActionType.Reviewer} - {act.Value} + {:else if act.T === ProjectActionType.Reviewer} + {act.V} {/if} {/snippet} @@ -102,25 +81,23 @@ {@render actionType(act)}
{#if extraBox} {@const useI18n = - act.Action !== ProjectActionString.EditName && - act.Action !== ProjectActionString.EditDescription && - act.Action !== ProjectActionString.EditLanguage} + act.A !== ProjectActionString.EditName && + act.A !== ProjectActionString.EditDescription && + act.A !== ProjectActionString.EditLanguage} {/if} @@ -130,12 +107,12 @@ {@render actionType(act)} {#if extraBox} {@const useI18n = - act.Action !== ProjectActionString.EditName && - act.Action !== ProjectActionString.EditDescription && - act.Action !== ProjectActionString.EditLanguage} + act.A !== ProjectActionString.EditName && + act.A !== ProjectActionString.EditDescription && + act.A !== ProjectActionString.EditLanguage} {/if} diff --git a/src/lib/projects/index.ts b/src/lib/projects/index.ts index e49df0ba4a..c940d6023d 100644 --- a/src/lib/projects/index.ts +++ b/src/lib/projects/index.ts @@ -282,3 +282,44 @@ export function canReactivate( !!project.DateArchived && canModifyProject(security, project.OwnerId, project.OrganizationId) ); } + +/** + * U: UserId + * D: DateAction + * T: ActionType + * A: Action + * V: Value + * E: ExternalId + */ +export type MinifiedActions = ReturnType; +export function minifyProjectActions( + actions: Prisma.ProjectActionsGetPayload<{ + select: { + UserId: true; + DateAction: true; + ActionType: true; + Action: true; + Value: true; + ExternalId: true; + }; + }>[], + prodDefs: Prisma.ProductDefinitionsGetPayload<{ + select: { Id: true; Name: true; Workflow: { select: { ProductType: true } } }; + }>[], + users: Prisma.UsersGetPayload<{ select: { Id: true; Name: true } }>[], + groups: Prisma.GroupsGetPayload<{ select: { Id: true; Name: true } }>[] +) { + return { + actions: actions.map((a) => ({ + U: a.UserId, + D: a.DateAction, + T: a.ActionType, + A: a.Action, + V: a.Value, + E: a.ExternalId + })), + users: new Map(users.map((u) => [u.Id, u.Name])), + groups: new Map(groups.map((g) => [g.Id, g.Name])), + prodDefs: new Map(prodDefs.map((pd) => [pd.Id, { N: pd.Name, T: pd.Workflow.ProductType }])) + }; +} diff --git a/src/lib/projects/sse.ts b/src/lib/projects/sse.ts index cfefabe9d9..14d3fcbe0d 100644 --- a/src/lib/projects/sse.ts +++ b/src/lib/projects/sse.ts @@ -7,6 +7,7 @@ import { produce } from 'sveltekit-sse'; import { type SSEPageEvents, SSEPageUpdates } from './listener'; import { ProductTransitionType, ProjectActionString, ProjectActionType, RoleId } from '$lib/prisma'; import { minifyProductCard, minifyProductDetails } from '$lib/products'; +import { minifyProjectActions } from '$lib/projects'; import { userGroupsForOrg } from '$lib/projects/server'; import { getURLandToken } from '$lib/server/build-engine-api/requests'; import { DatabaseReads } from '$lib/server/database'; @@ -83,61 +84,84 @@ export async function getProjectDetails(id: number, userSession: Session['user'] }); span.addEvent('Project fetched'); + const actions = await DatabaseReads.projectActions.findMany({ + where: { ProjectId: id }, + select: { + UserId: true, + DateAction: true, + ActionType: true, + Action: true, + Value: true, + ExternalId: true + } + }); + span.addEvent('Project Actions fetched'); + return { project, - actionParams: { - users: await DatabaseReads.users.findMany({ + ...minifyProjectActions( + actions, + await DatabaseReads.productDefinitions.findMany({ where: { Id: { - in: project.ProjectActions.filter( - (pa) => - pa.ExternalId && - (pa.ActionType === ProjectActionType.Author || - (pa.ActionType === ProjectActionType.OwnerGroup && - pa.Action !== ProjectActionString.AssignGroup)) - ).map((pa) => pa.ExternalId!) + in: actions + .filter((pa) => pa.ExternalId && pa.ActionType === ProjectActionType.Product) + .map((pa) => pa.ExternalId!) } }, select: { Id: true, - Name: true + Name: true, + Workflow: { + select: { + ProductType: true + } + } } }), - groups: await DatabaseReads.groups.findMany({ + await DatabaseReads.users.findMany({ where: { - Id: { - in: project.ProjectActions.filter( - (pa) => - pa.ExternalId && - pa.ActionType === ProjectActionType.OwnerGroup && - pa.Action === ProjectActionString.AssignGroup - ).map((pa) => pa.ExternalId!) - } + OR: [ + { + Id: { + in: actions + .filter( + (pa) => + pa.ExternalId && + (pa.ActionType === ProjectActionType.Author || + (pa.ActionType === ProjectActionType.OwnerGroup && + pa.Action !== ProjectActionString.AssignGroup)) + ) + .map((pa) => pa.ExternalId!) + } + }, + { ProjectActions: { some: { ProjectId: id } } } + ] }, select: { Id: true, Name: true } }), - prodDefs: await DatabaseReads.productDefinitions.findMany({ + await DatabaseReads.groups.findMany({ where: { Id: { - in: project.ProjectActions.filter( - (pa) => pa.ExternalId && pa.ActionType === ProjectActionType.Product - ).map((pa) => pa.ExternalId!) + in: actions + .filter( + (pa) => + pa.ExternalId && + pa.ActionType === ProjectActionType.OwnerGroup && + pa.Action === ProjectActionString.AssignGroup + ) + .map((pa) => pa.ExternalId!) } }, select: { Id: true, - Name: true, - Workflow: { - select: { - ProductType: true - } - } + Name: true } }) - } + ) }; } catch (e) { span.recordException(e as Error); diff --git a/src/routes/(authenticated)/projects/[id=number]/+page.svelte b/src/routes/(authenticated)/projects/[id=number]/+page.svelte index 9fef0290cb..21309a895b 100644 --- a/src/routes/(authenticated)/projects/[id=number]/+page.svelte +++ b/src/routes/(authenticated)/projects/[id=number]/+page.svelte @@ -153,11 +153,7 @@ {m.products_details()} - + {/if}

{m.project_details_title()}

@@ -265,10 +261,8 @@ deleteEndpoint="deleteProduct" updateEndpoint="updateProduct" {canEdit} - projectActions={projectData.project.ProjectActions.filter( - (pa) => - pa.ActionType === ProjectActionType.Access || - pa.ActionType === ProjectActionType.Archival + projectActions={projectData.actions.filter( + (pa) => pa.T === ProjectActionType.Access || pa.T === ProjectActionType.Archival )} /> {/each} From ccae4b48265754091d758d03d58bbcf5c5c75f64 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Wed, 25 Mar 2026 14:59:02 -0500 Subject: [PATCH 20/20] Consolidate doProjectAction --- src/lib/projects/server.ts | 71 +++++++++++++++----------------------- 1 file changed, 28 insertions(+), 43 deletions(-) diff --git a/src/lib/projects/server.ts b/src/lib/projects/server.ts index 1d12063e26..be521ef347 100644 --- a/src/lib/projects/server.ts +++ b/src/lib/projects/server.ts @@ -115,53 +115,14 @@ export async function doProjectAction( security: Security, groups: number[] ) { - if (operation === 'archive' && !project?.DateArchived) { - const timestamp = new Date(); - await DatabaseWrites.projects.update(project.Id, { - DateArchived: timestamp - }); - await DatabaseWrites.projectActions.create({ - ProjectId: project.Id, - UserId: security.userId, - DateAction: timestamp, - ActionType: ProjectActionType.Archival, - Action: ProjectActionString.Archive - }); - await getQueues().UserTasks.add(`Delete UserTasks for Archived Project #${project.Id}`, { - type: BullMQ.JobType.UserTasks_Workflow, - scope: 'Project', - projectId: project.Id, - operation: { - type: BullMQ.UserTasks.OpType.Delete - } - }); - } else if (operation === 'reactivate' && !!project?.DateArchived) { - const timestamp = new Date(); - await DatabaseWrites.projects.update(project.Id, { - DateArchived: null - }); - await DatabaseWrites.projectActions.create({ - ProjectId: project.Id, - UserId: security.userId, - DateAction: timestamp, - ActionType: ProjectActionType.Archival, - Action: ProjectActionString.Reactivate - }); - await getQueues().UserTasks.add(`Create UserTasks for Reactivated Project #${project.Id}`, { - type: BullMQ.JobType.UserTasks_Workflow, - scope: 'Project', - projectId: project.Id, - operation: { - type: BullMQ.UserTasks.OpType.Create - } - }); - } else if ( + console.log(`doProjectAction: ${operation}`); + if ( operation === 'claim' && canClaimProject( security.sessionForm, - project?.OwnerId, + project.OwnerId, project.OrganizationId, - project?.GroupId, + project.GroupId, groups ) ) { @@ -177,6 +138,30 @@ export async function doProjectAction( ExternalId: security.userId }); } + } else if (project.DateArchived ? operation === 'reactivate' : operation === 'archive') { + const archiving = operation === 'archive'; + const timestamp = new Date(); + await DatabaseWrites.projects.update(project.Id, { + DateArchived: archiving ? timestamp : null + }); + await DatabaseWrites.projectActions.create({ + ProjectId: project.Id, + UserId: security.userId, + DateAction: timestamp, + ActionType: ProjectActionType.Archival, + Action: archiving ? ProjectActionString.Archive : ProjectActionString.Reactivate + }); + await getQueues().UserTasks.add( + `${archiving ? 'Delete' : 'Create'} UserTasks for ${archiving ? 'Archived' : 'Reactivated'} Project #${project.Id}`, + { + type: BullMQ.JobType.UserTasks_Workflow, + scope: 'Project', + projectId: project.Id, + operation: { + type: archiving ? BullMQ.UserTasks.OpType.Delete : BullMQ.UserTasks.OpType.Create + } + } + ); } }
- {task.Product.ProductDefinition.Name} + {userTasks.products.get(task.PD)?.N}
{m.tasks_project()} - - {task.Product.Project.Name} + + {userTasks.projects.get(task.Pj)}
goto(localizeHref(`/tasks/${task.ProductId}`))} - > +
goto(localizeHref(`/tasks/${task.P}`))}> - {task.Status} + {task.S} - + {$dateUpdated[i]}
- +
- {task.Product.ProductDefinition.Name} + {userTasks.products.get(task.PD)?.N} - {task.Status} + {task.S} - - {task.Product.Project.Name} + + {userTasks.projects.get(task.Pj)} - + {$dateUpdated[i]}
- +
{@render transitionType(transition, showRecs)} - {#if !isLandmark(transition.TransitionType)} - {(transition.User && (transition.User.Name ?? `User #${transition.User.Id}`)) || - transition.AllowedUserNames || + {#if !isLandmark(transition.T)} + {(transition.UI && (transition.UN ?? `User #${transition.UI}`)) || + transition.AU || m.appName()} {/if} - {transition.TransitionType === ProductTransitionType.Activity - ? transition.Command - : ''} + {transition.T === ProductTransitionType.Activity ? transition.Cd : ''} - {getTimeDateString(transition.DateTransition)} + {getTimeDateString(transition.D)}
{@render queueRecords(transition)}
- +
{@render transitionType(transition, showRecs)} - {getTimeDateString(transition.DateTransition)} + {getTimeDateString(transition.D)}
- {(transition.User && (transition.User.Name ?? `User #${transition.User.Id}`)) || - transition.AllowedUserNames || + {(transition.UI && (transition.UN ?? `User #${transition.UI}`)) || + transition.AU || m.appName()} - {transition.TransitionType === ProductTransitionType.Activity - ? transition.Command - : ''} + {transition.T === ProductTransitionType.Activity ? transition.Cd : ''}
{@render queueRecords(transition)}
- {act.User.Name ?? `User #${act.User.Id}`} + {users?.get(act.U) ?? `User #${act.U}`} {@render details(act)} - {getTimeDateString(act.DateAction)} + {getTimeDateString(act.D)}
- +
- {getTimeDateString(act.DateAction)} + {getTimeDateString(act.D)}
- {act.User.Name ?? `User #${act.User.Id}`} + {users?.get(act.U) ?? `User #${act.U}`} {@render details(act)} @@ -143,14 +120,12 @@
- +