Repository: sillsdev/appbuilder-portal
Pull request #1135: Feature/better error pages
Author: login: FyreByrd
Created: 2025-05-14T18:18:17Z | Merged: 2025-05-23T13:22:50Z

Fixes #1114, #1116

Created better error pages that imitate S1's error pages.

I also added helpful error messages for when deleting a group or removing a user from a group is blocked because of project ownership.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Added health checks for database and queue connectivity, redirecting users to an error page if the application is unavailable.
  - Enhanced error pages with user-friendly, localized messages for specific error statuses (403, 404, 503) and contextual navigation options.
  - Introduced new localized error and status messages in English, Spanish, and French.

- **Bug Fixes**
  - Improved feedback when deleting groups or removing users fails due to project associations, displaying specific error toasts.

- **Refactor**
  - Standardized route authorization and permission checks, centralizing error handling and using status codes for more granular control.
  - Streamlined queue connection management and improved reliability of background task monitoring.

- **Style**
  - Improved layout and formatting for better readability and user experience.

- **Documentation**
  - Added and updated comments and JSDoc annotations for improved code clarity.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Linked issue #1114: Handle database connection errors
Current behavior:
- Frontend: redirects to login page
- Backend: tasks fail to run, but doesn't fully crash

Desired behavior:
- Frontend: 503 error page???
- Backend: ???

The backend uses redis to keep track of tasks; we should probably look into what happens when redis is down too.

Diff:
diff --git a/portal/common/bullmq/queues.ts b/portal/common/bullmq/queues.ts
index 8c80614de5..7596136d5e 100644
--- a/portal/common/bullmq/queues.ts
+++ b/portal/common/bullmq/queues.ts
@@ -1,10 +1,44 @@
 import { Queue } from 'bullmq';
+import { Redis } from 'ioredis';
 import type { Job } from './types.js';
 import { QueueName } from './types.js';
 
-const connection = {
+export const connection = {
   host: process.env.NODE_ENV === 'development' ? 'localhost' : 'redis'
-};
+} as const;
+
+class Connection {
+  private conn: Redis;
+  private connected: boolean;
+  constructor() {
+    this.conn = new Redis(connection);
+    this.connected = false;
+    this.conn.on('close', () => (this.connected = false));
+    this.conn.on('connect', () => (this.connected = true));
+    setInterval(() => {
+      if (this.connected) {
+        this.conn
+          .ping()
+          .then(() => {
+            this.connected = true;
+          })
+          .catch((err) => {
+            console.error(err);
+            console.log('Redis disconnected');
+            this.connected = false;
+          });
+      }
+    }, 10000); // Check every 10 seconds
+  }
+  public IsConnected() {
+    return this.connected;
+  }
+}
+
+const conn = new Connection();
+
+/** Redis is up */
+export const connected = () => conn.IsConnected();
 
 /** Queue for Product Builds */
 export const Builds = new Queue<Job>(QueueName.Builds, { connection });
diff --git a/portal/common/databaseProxy/GroupMemberships.ts b/portal/common/databaseProxy/GroupMemberships.ts
index a2fc891827..7c64b46b4b 100644
--- a/portal/common/databaseProxy/GroupMemberships.ts
+++ b/portal/common/databaseProxy/GroupMemberships.ts
@@ -27,6 +27,7 @@ export async function toggleForOrg(
     }
   } else {
     // check if user owns a project in this group
+    // this is the only check not precluded by the UI
     if (await prisma.projects.findFirst({ where: { OwnerId: UserId, GroupId } })) return false;
     await prisma.groupMemberships.deleteMany({
       where: { UserId, GroupId }
diff --git a/portal/common/databaseProxy/Groups.ts b/portal/common/databaseProxy/Groups.ts
index de66809362..9fddb122a6 100644
--- a/portal/common/databaseProxy/Groups.ts
+++ b/portal/common/databaseProxy/Groups.ts
@@ -3,6 +3,7 @@ import prisma from '../prisma.js';
 export async function update(): Promise<never> {
   throw new Error('Not implemented');
 }
+/** @returns false if the group is associated with at least one project. */
 export async function deleteGroup(id: number) {
   if (
     await prisma.projects.findFirst({
diff --git a/portal/common/index.ts b/portal/common/index.ts
index 1e1ce7cedf..20a1168f34 100644
--- a/portal/common/index.ts
+++ b/portal/common/index.ts
@@ -2,6 +2,6 @@ export * as BuildEngine from './build-engine-api/index.js';
 export * as Queues from './bullmq/queues.js';
 export * as BullMQ from './bullmq/types.js';
 export { default as DatabaseWrites } from './databaseProxy/index.js';
-export { readonlyPrisma as prisma } from './prisma.js';
+export { connected, readonlyPrisma as prisma } from './prisma.js';
 export { Workflow } from './workflow/index.js';
 
diff --git a/portal/common/prisma.ts b/portal/common/prisma.ts
index cdc2bde49d..6f09600343 100644
--- a/portal/common/prisma.ts
+++ b/portal/common/prisma.ts
@@ -1,4 +1,4 @@
-import { PrismaClient } from '@prisma/client';
+import { Prisma, PrismaClient } from '@prisma/client';
 import { ReadonlyClient } from './ReadonlyPrisma.js';
 
 // This is the home of all database operations through prisma
@@ -20,4 +20,40 @@ export type PrismaClientExact = PrismaClient;
 
 export const readonlyPrisma = prisma.$extends(ReadonlyClient);
 
+class ConnectionChecker {
+  private connected: boolean;
+  constructor() {
+    this.connected = false;
+    this.checkConnection();
+    setInterval(async () => this.checkConnection(), 10000); // Check every 10 seconds
+  }
+  private async checkConnection() {
+    try {
+      await prisma.$queryRaw`SELECT 1`;
+      this.connected = true;
+    } catch (e) {
+      if (
+        e instanceof Prisma.PrismaClientKnownRequestError ||
+        e instanceof Prisma.PrismaClientRustPanicError ||
+        e instanceof Prisma.PrismaClientInitializationError
+      ) {
+        // As best as I can tell, the only types of PrismaClientKnownRequestError that
+        // should be thrown by the above query would involve the database being unreachable.
+        this.connected = false;
+        // ISSUE: #1128 this should probably be logged
+      } else {
+        throw e;
+      }
+    }
+  }
+  public IsConnected() {
+    return this.connected;
+  }
+}
+
+const conn = new ConnectionChecker();
+
+/** Main database is up */
+export const connected = () => conn.IsConnected();
+
 export default prisma;
diff --git a/portal/node-server/BullWorker.ts b/portal/node-server/BullWorker.ts
index caf0b1641d..6c617eaad3 100644
--- a/portal/node-server/BullWorker.ts
+++ b/portal/node-server/BullWorker.ts
@@ -6,9 +6,7 @@ export abstract class BullWorker<T> {
   public worker: Worker;
   constructor(public queue: BullMQ.QueueName) {
     this.worker = new Worker<T>(queue, this.run, {
-      connection: {
-        host: process.env.NODE_ENV === 'development' ? 'localhost' : 'redis'
-      }
+      connection: Queues.connection
     });
   }
   abstract run(job: Job<T>): Promise<unknown>;
diff --git a/portal/node-server/dev.ts b/portal/node-server/dev.ts
index 9d832bee9f..a4744a337d 100644
--- a/portal/node-server/dev.ts
+++ b/portal/node-server/dev.ts
@@ -1,6 +1,7 @@
 import { createBullBoard } from '@bull-board/api';
 import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js';
 import { ExpressAdapter } from '@bull-board/express';
+import { Queue } from 'bullmq';
 import express from 'express';
 import * as Workers from './BullWorker.js';
 
@@ -12,7 +13,9 @@ import { Queues } from 'sil.appbuilder.portal.common';
 const serverAdapter = new ExpressAdapter();
 serverAdapter.setBasePath('/');
 createBullBoard({
-  queues: Object.values(Queues).map((q) => new BullMQAdapter(q)),
+  queues: Object.values(Queues)
+    .filter((q) => q instanceof Queue)
+    .map((q) => new BullMQAdapter(q)),
   serverAdapter
 });
 app.use(serverAdapter.getRouter());
diff --git a/portal/node-server/index.ts b/portal/node-server/index.ts
index a86265de65..70867024ba 100644
--- a/portal/node-server/index.ts
+++ b/portal/node-server/index.ts
@@ -3,6 +3,7 @@ import Auth0Provider from '@auth/sveltekit/providers/auth0';
 import { createBullBoard } from '@bull-board/api';
 import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js';
 import { ExpressAdapter } from '@bull-board/express';
+import { Queue } from 'bullmq';
 import express, { type NextFunction, type Request, type Response } from 'express';
 import path from 'path';
 import { prisma, Queues } from 'sil.appbuilder.portal.common';
@@ -86,7 +87,9 @@ new Workers.Emails();
 const serverAdapter = new ExpressAdapter();
 serverAdapter.setBasePath('/admin/jobs');
 createBullBoard({
-  queues: Object.values(Queues).map((q) => new BullMQAdapter(q)),
+  queues: Object.values(Queues)
+    .filter((q) => q instanceof Queue)
+    .map((q) => new BullMQAdapter(q)),
   serverAdapter
 });
 
diff --git a/portal/src/auth.ts b/portal/src/auth.ts
index d7c23c082a..296b8321d8 100644
--- a/portal/src/auth.ts
+++ b/portal/src/auth.ts
@@ -1,11 +1,12 @@
 import { checkInviteErrors } from '$lib/organizationInvites';
 import { localizeHref } from '$lib/paraglide/runtime';
 import { verifyCanViewAndEdit } from '$lib/projects/server';
+import { ServerStatus } from '$lib/utils';
 import { isAdmin, isAdminForOrg, isSuperAdmin } from '$lib/utils/roles';
 import type { Session } from '@auth/sveltekit';
 import { SvelteKitAuth, type DefaultSession, type SvelteKitAuthConfig } from '@auth/sveltekit';
 import Auth0Provider from '@auth/sveltekit/providers/auth0';
-import { error, redirect, type Handle } from '@sveltejs/kit';
+import { redirect, type Handle } from '@sveltejs/kit';
 import { DatabaseWrites, prisma } from 'sil.appbuilder.portal.common';
 import type { RoleId } from 'sil.appbuilder.portal.common/prisma';
 
@@ -167,8 +168,10 @@ export const localRouteHandle: Handle = async ({ event, resolve }) => {
   ) {
     const session = await event.locals.auth();
     if (!session) return redirect(302, localizeHref('/login'));
-    if (!(await validateRouteForAuthenticatedUser(session, event.route.id, event.params)))
-      return error(403);
+    const status = await validateRouteForAuthenticatedUser(session, event.route.id, event.params);
+    if (status !== ServerStatus.Ok) {
+      return redirect(302, localizeHref(`/error?code=${status}`));
+    }
   }
   return resolve(event);
 };
@@ -177,51 +180,53 @@ async function validateRouteForAuthenticatedUser(
   session: Session,
   route: string,
   params: Partial<Record<string, string>>
-): Promise<boolean> {
+): Promise<ServerStatus> {
   const path = route.split('/').filter((r) => !!r);
   // Only guarding authenticated routes
   if (path[0] === '(authenticated)') {
     if (path[1] === 'admin' || path[1] === 'workflow-instances')
-      return isSuperAdmin(session?.user?.roles);
+      return isSuperAdmin(session?.user?.roles) ? ServerStatus.Ok : ServerStatus.Forbidden;
     else if (path[1] === 'directory' || path[1] === 'open-source')
       // Always allowed. Open pages
-      return true;
+      return ServerStatus.Ok;
     else if (path[1] === 'organizations') {
       // Must be org admin for some organization (or a super admin)
-      if (!isAdmin(session?.user?.roles)) return false;
+      if (!isAdmin(session?.user?.roles)) return ServerStatus.Forbidden;
       if (params.id) {
         // Must be org admin for specified organization (or a super admin)
-        return isAdminForOrg(parseInt(params.id!), session?.user?.roles);
+        return isAdminForOrg(parseInt(params.id!), session?.user?.roles)
+          ? ServerStatus.Ok
+          : ServerStatus.Forbidden;
       }
-      return true;
+      return ServerStatus.Ok;
     } else if (path[1] === 'products') {
       const product = await prisma.products.findFirst({
         where: {
           Id: params.id
         }
       });
-      if (!product) return false;
-      // Must be allowed to view associated project 
+      if (!product) return ServerStatus.NotFound;
+      // Must be allowed to view associated project
       // (this route was originally part of the project page but was moved elsewhere to improve load time)
       return await verifyCanViewAndEdit(session, product.ProjectId);
     } else if (path[1] === 'projects') {
-      if (path[2] === '[filter=projectSelector]') return true;
+      if (path[2] === '[filter=projectSelector]') return ServerStatus.Ok;
       else if (path[2] === '[id=idNumber]') {
         // A project can be viewed if the user owns it, is an org admin for the org, or is a super admin
         return await verifyCanViewAndEdit(session, parseInt(params.id!));
       }
-      return true;
+      return ServerStatus.Ok;
     } else if (path[1] === 'tasks') {
       // Own task list always allowed, and specific products checked manually
-      return true;
+      return ServerStatus.Ok;
     } else if (path[1] === 'users') {
       // Checked manually in the users route
-      return true;
+      return ServerStatus.Ok;
     } else {
       // Unknown route. We'll assume it's a legal route
-      return true;
+      return ServerStatus.Ok;
     }
   } else {
-    return true;
+    return ServerStatus.Ok;
   }
 }
diff --git a/portal/src/hooks.server.ts b/portal/src/hooks.server.ts
index 574a77cbad..421b9810f3 100644
--- a/portal/src/hooks.server.ts
+++ b/portal/src/hooks.server.ts
@@ -1,7 +1,9 @@
 // hooks.server.ts
+import { localizeHref } from '$lib/paraglide/runtime';
 import { paraglideMiddleware } from '$lib/paraglide/server';
-import type { Handle } from '@sveltejs/kit';
+import { redirect, type Handle } from '@sveltejs/kit';
 import { sequence } from '@sveltejs/kit/hooks';
+import { connected, Queues } from 'sil.appbuilder.portal.common';
 import {
   authRouteHandle,
   checkUserExistsHandle,
@@ -11,17 +13,34 @@ import {
 
 // creating a handle to use the paraglide middleware
 const paraglideHandle: Handle = ({ event, resolve }) =>
-	paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => {
-		event.request = localizedRequest;
-		return resolve(event, {
-			transformPageChunk: ({ html }) => {
-				return html.replace('%lang%', locale);
-			}
-		});
-	});
+  paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => {
+    event.request = localizedRequest;
+    return resolve(event, {
+      transformPageChunk: ({ html }) => {
+        return html.replace('%lang%', locale);
+      }
+    });
+  });
+
+const heartbeat: Handle = async ({ event, resolve }) => {
+  // this check is important to prevent infinite redirects...
+  if (
+    !(
+      event.route.id === '/(unauthenticated)/error' ||
+      event.route.id === '/(unauthenticated)/(auth)/login'
+    )
+  ) {
+    if (!(connected() && Queues.connected())) {
+      // HTTP 503 *should* be the correct semantics here?
+      return redirect(302, localizeHref(`/error?code=503`));
+    }
+  }
+  return resolve(event);
+};
 
 export const handle: Handle = sequence(
   paraglideHandle,
+  heartbeat,
   organizationInviteHandle,
   authRouteHandle,
   checkUserExistsHandle,
diff --git a/portal/src/lib/locales/en-US.json b/portal/src/lib/locales/en-US.json
index 8bf440ff0f..6d82234099 100644
--- a/portal/src/lib/locales/en-US.json
+++ b/portal/src/lib/locales/en-US.json
@@ -171,6 +171,7 @@
   "org_groupCreated": "Group created",
   "org_groupEdited": "Group edited",
   "org_groupDeleted": "Group deleted",
+  "org_groupHasProjects": "{group} is associated with at least one project and could not be deleted",
   "org_navInfrastructure": "Infrastructure",
   "org_infrastructureTitle": "Infrastructure",
   "org_useDefaultBuildEngineTitle": "Use Default Build Engine",
@@ -222,6 +223,7 @@
   "profile_visibility_restricted": "My profile information is restricted from the public",
   "user_addedTo": "{user} added to {name}",
   "user_removedFrom": "{user} removed from {name}",
+  "user_ownsProjectsInGroup": "{user} owns at least one project associated with {group} and could not be removed.",
   "errors_generic": "An error occurred! {errorMessage}",
   "errors_requiredField": "The {field} is required, but has no value",
   "errors_notAuthorized": "You must login. This could be due to a prior login expiring.",
@@ -242,6 +244,7 @@
   "errors_verifyEmailTitle": "Please verify your email address",
   "errors_verifyEmailDescription": "\n      In order to use Scriptoria, we need you to verify your email address.\n      <br />\n      Please log into your email and clik the verification link. ",
   "errors_invalidProjectSelection": "No valid products available for selected project(s)",
+  "errors_appUnavailable": "Scriptoria is currently unavailable",
   "tasks_title": "My Tasks",
   "tasks_project": "Project",
   "tasks_product": "Product",
diff --git a/portal/src/lib/locales/es-419.json b/portal/src/lib/locales/es-419.json
index 33f01ec979..b53ee5733d 100644
--- a/portal/src/lib/locales/es-419.json
+++ b/portal/src/lib/locales/es-419.json
@@ -169,6 +169,7 @@
   "org_groupCreated": "Grupo creado",
   "org_groupEdited": "Grupo editado",
   "org_groupDeleted": "Grupo eliminado",
+  "org_groupHasProjects": "{group} está asociado con al menos un proyecto y no se pudo eliminar",
   "org_navInfrastructure": "Infraestructura",
   "org_infrastructureTitle": "Infraestructura",
   "org_useDefaultBuildEngineTitle": "Usar el motor de compilación predeterminado",
@@ -220,6 +221,7 @@
   "profile_visibility_restricted": "Mi información de perfil es restringida al público",
   "user_addedTo": "{user} added to {name}",
   "user_removedFrom": "{user} removed from {name}",
+  "user_ownsProjectsInGroup": "{user} posee al menos un proyecto asociado a {group} y no pudo ser eliminado.",
   "errors_generic": "¡Ha ocurrido un error! {errorMessage}",
   "errors_requiredField": "El {field} es requerido, pero no tiene ningún valor",
   "errors_notAuthorized": "Usted debe iniciar sesión. Esto podría ser debido a que expiró un inicio de sesión previo.",
@@ -240,6 +242,7 @@
   "errors_verifyEmailTitle": "Por favor, verifique su dirección de correo electrónico",
   "errors_verifyEmailDescription": "\nPara poder usar Scriptoria, necesitamos que verifiques tu dirección de correo electrónico.\n      <br />\n      Por favor, ingrese a su correo electrónico y haga clic en el enlace de verificación. ",
   "errors_invalidProjectSelection": "Productos no válidos disponibles para el/los proyecto(s) seleccionados",
+  "errors_appUnavailable": "Scriptoria no está disponible actualmente",
   "tasks_title": "Mis tareas",
   "tasks_project": "Proyecto",
   "tasks_product": "Producto",
diff --git a/portal/src/lib/locales/fr-FR.json b/portal/src/lib/locales/fr-FR.json
index 052c59ef0c..6c7032aba3 100644
--- a/portal/src/lib/locales/fr-FR.json
+++ b/portal/src/lib/locales/fr-FR.json
@@ -169,6 +169,7 @@
   "org_groupCreated": "Group created",
   "org_groupEdited": "Group edited",
   "org_groupDeleted": "Group deleted",
+  "org_groupHasProjects": "{group} is associated with at least one project and could not be deleted",
   "org_navInfrastructure": "Infrastructure",
   "org_infrastructureTitle": "Infrastructure",
   "org_useDefaultBuildEngineTitle": "Use Default Build Engine",
@@ -220,6 +221,7 @@
   "profile_visibility_restricted": "My profile information is restricted from the public",
   "user_addedTo": "{user} added to {name}",
   "user_removedFrom": "{user} removed from {name}",
+  "user_ownsProjectsInGroup": "{user} owns at least one project associated with {group} and could not be removed.",
   "errors_generic": "An error occurred! {errorMessage}",
   "errors_requiredField": "The {field} is required, but has no value",
   "errors_notAuthorized": "You must login. This could be due to a prior login expiring.",
@@ -240,6 +242,7 @@
   "errors_verifyEmailTitle": "Please verify your email address",
   "errors_verifyEmailDescription": "\n      In order to use Scriptoria, we need you to verify your email address.\n      <br />\n      Please log into your email and clik the verification link. ",
   "errors_invalidProjectSelection": "No valid products available for selected project(s)",
+  "errors_appUnavailable": "Scriptoria is currently unavailable",
   "tasks_title": "My Tasks",
   "tasks_project": "Project",
   "tasks_product": "Product",
diff --git a/portal/src/lib/projects/index.ts b/portal/src/lib/projects/index.ts
index 1bc3006799..f61a625e63 100644
--- a/portal/src/lib/projects/index.ts
+++ b/portal/src/lib/projects/index.ts
@@ -167,7 +167,7 @@ export function canModifyProject(
   user: Session | null | undefined,
   projectOwnerId: number,
   organizationId: number
-) {
+): boolean {
   return projectOwnerId === user?.user.userId || isAdminForOrg(organizationId, user?.user.roles);
 }
 
diff --git a/portal/src/lib/projects/server.ts b/portal/src/lib/projects/server.ts
index 082c96059e..47649e99e9 100644
--- a/portal/src/lib/projects/server.ts
+++ b/portal/src/lib/projects/server.ts
@@ -1,11 +1,12 @@
 import { canClaimProject, canModifyProject, type ProjectForAction } from '$lib/projects';
+import { ServerStatus } from '$lib/utils';
 import { hasRoleForOrg, isAdminForOrg } from '$lib/utils/roles';
 import type { Session } from '@auth/sveltekit';
 import type { Prisma } from '@prisma/client';
 import { BullMQ, DatabaseWrites, prisma, Queues } from 'sil.appbuilder.portal.common';
 import { RoleId } from 'sil.appbuilder.portal.common/prisma';
 
-export async function verifyCanViewAndEdit(user: Session, projectId: number) {
+export async function verifyCanViewAndEdit(user: Session, projectId: number): Promise<ServerStatus> {
   // Editing is allowed if the user owns the project, or if the user is an organization
   // admin for the project's organization, or if the user is a super admin
   const project = await prisma.projects.findUnique({
@@ -18,8 +19,8 @@ export async function verifyCanViewAndEdit(user: Session, projectId: number) {
       OrganizationId: true
     }
   });
-  if (!project) return false;
-  return canModifyProject(user, project.OwnerId, project.OrganizationId);
+  if (!project) return ServerStatus.NotFound;
+  return canModifyProject(user, project.OwnerId, project.OrganizationId) ? ServerStatus.Ok : ServerStatus.Forbidden;
 }
 
 export function projectFilter(args: {
@@ -105,7 +106,7 @@ export function projectFilter(args: {
     ]
   };
 }
-export async function verifyCanCreateProject(user: Session, orgId: number) {
+export function verifyCanCreateProject(user: Session, orgId: number): boolean {
   // Creating a project is allowed if the user is an OrgAdmin or AppBuilder for the organization or a SuperAdmin
   return (
     isAdminForOrg(orgId, user.user.roles) ||
diff --git a/portal/src/lib/utils/index.ts b/portal/src/lib/utils/index.ts
index f60351d5d2..b0c1acf07d 100644
--- a/portal/src/lib/utils/index.ts
+++ b/portal/src/lib/utils/index.ts
@@ -34,6 +34,12 @@ export function enumNumVals<E extends Record<string, string | number>>(e: E): nu
   return Object.values(e).filter((v) => !(typeof v === 'string'));
 }
 
+export enum ServerStatus {
+  Ok = 200,
+  Forbidden = 403,
+  NotFound = 404,
+};
+
 function pushToast(type: 'info' | 'success' | 'warning' | 'error', message: string) {
   toast.push(message, { pausable: true, classes: [type] });
 }
diff --git a/portal/src/routes/(authenticated)/organizations/[id]/settings/groups/+page.svelte b/portal/src/routes/(authenticated)/organizations/[id]/settings/groups/+page.svelte
index 1c5fb68d17..fcb56fe884 100644
--- a/portal/src/routes/(authenticated)/organizations/[id]/settings/groups/+page.svelte
+++ b/portal/src/routes/(authenticated)/organizations/[id]/settings/groups/+page.svelte
@@ -16,19 +16,25 @@
 </script>
 
 {#each data.groups.toSorted((a, b) => byName(a, b, getLocale())) as group}
-  <form action="?/deleteGroup" class="m-2" method="post" use:enhance={() => {
-    return async({ result }) => {
-      if (result.type === 'success') {
-        const data = result.data as ActionData;
-        if (data?.ok) {
-          toast('success', m.org_groupDeleted())
-        }
-        else {
-          toast('error', m.errors_generic({ errorMessage: '' }))
+  <form
+    action="?/deleteGroup"
+    class="m-2"
+    method="post"
+    use:enhance={() => {
+      return async ({ result }) => {
+        if (result.type === 'success') {
+          const data = result.data as ActionData;
+          if (data?.ok) {
+            toast('success', m.org_groupDeleted());
+          } else if (data?.form.valid) {
+            toast('error', m.org_groupHasProjects({ group: group.Name ?? '' }));
+          } else {
+            toast('error', m.errors_generic({ errorMessage: '' }));
+          }
         }
-      }
-    }
-  }}>
+      };
+    }}
+  >
     <input type="hidden" name="id" value={group.Id} />
     <div class="border w-full flex flex-row p-2 rounded-md items-center place-content-between">
       <div>
@@ -41,26 +47,26 @@
     </div>
   </form>
 {/each}
-<form action="?/addGroup" class="m-2" method="post" use:enhance={() => {
-  return async({ result }) => {
-    if (result.type === 'success') {
-      const data = result.data as ActionData;
-      if (data?.ok) {
-        toast('success', m.org_groupCreated())
+<form
+  action="?/addGroup"
+  class="m-2"
+  method="post"
+  use:enhance={() => {
+    return async ({ result }) => {
+      if (result.type === 'success') {
+        const data = result.data as ActionData;
+        if (data?.ok) {
+          toast('success', m.org_groupCreated());
+        }
       }
-    }
-  }
-}}>
+    };
+  }}
+>
   {m.org_addGroupButton()}
   <input type="hidden" name="orgId" value={data.organization.Id} />
   <div class="my-4 flex flex-row w-full space-x-2 items-center">
     <LabeledFormInput name="common_name">
-      <input
-        class="w-full input input-bordered validator"
-        type="text"
-        name="name"
-        required
-      />
+      <input class="w-full input input-bordered validator" type="text" name="name" required />
       <span class="validator-hint">{m.org_nameError()}</span>
     </LabeledFormInput>
     <LabeledFormInput name="common_abbreviation">
diff --git a/portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts b/portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
index ded4e6061c..baf9d83619 100644
--- a/portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
+++ b/portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
@@ -4,8 +4,7 @@ import { doProductAction } from '$lib/products/server';
 import { canModifyProject, projectActionSchema } from '$lib/projects';
 import {
   doProjectAction,
-  userGroupsForOrg,
-  verifyCanViewAndEdit
+  userGroupsForOrg
 } from '$lib/projects/server';
 import { deleteSchema, idSchema, propertiesSchema, stringIdSchema } from '$lib/valibot';
 import { error } from '@sveltejs/kit';
@@ -46,8 +45,8 @@ const productActionSchema = v.object({
 // Maybe? I pared it down a bit with `select` instead of `include` - Aidan
 export const load = (async ({ locals, params }) => {
   const session = (await locals.auth())!;
-  if (!verifyCanViewAndEdit(session, parseInt(params.id))) return error(403);
-  const project = await prisma.projects.findUnique({
+  // permissions checked in auth
+  const project = await prisma.projects.findUniqueOrThrow({
     where: {
       Id: parseInt(params.id)
     },
@@ -147,7 +146,6 @@ export const load = (async ({ locals, params }) => {
       }
     }
   });
-  if (!project) return error(400);
 
   const organization = await prisma.organizations.findUnique({
     where: {
@@ -301,15 +299,13 @@ export const load = (async ({ locals, params }) => {
 
 export const actions = {
   async deleteProduct(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(productActionSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     await DatabaseWrites.products.delete(form.data.productId);
   },
   async deleteAuthor(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(deleteSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     const author = await DatabaseWrites.authors.delete({ where: { Id: form.data.id } });
@@ -326,8 +322,7 @@ export const actions = {
     return { form, ok: true };
   },
   async deleteReviewer(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(deleteSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     await DatabaseWrites.reviewers.delete({
@@ -338,8 +333,7 @@ export const actions = {
     return { form, ok: true };
   },
   async addProduct(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(addProductSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     const checkRepository = await prisma.projects.findUnique({
@@ -365,8 +359,7 @@ export const actions = {
     return { form, ok: !!productId };
   },
   async productAction(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(productActionSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     const product = await prisma.products.findUnique({
@@ -383,8 +376,7 @@ export const actions = {
     return { form, ok: true };
   },
   async updateProduct(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(updateProductPropertiesSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     const productId = await DatabaseWrites.products.update(form.data.productId, {
@@ -394,8 +386,7 @@ export const actions = {
     return { form, ok: !!productId };
   },
   async addAuthor(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(addAuthorSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     // ISSUE: #1101 Appears that CanUpdate is not used
@@ -418,8 +409,7 @@ export const actions = {
     return { form, ok: true };
   },
   async addReviewer(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(addReviewerSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     await DatabaseWrites.reviewers.create({
@@ -433,8 +423,7 @@ export const actions = {
     return { form, ok: true };
   },
   async editSettings(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(
       event.request,
       valibot(
@@ -452,8 +441,7 @@ export const actions = {
     return { form, ok: true };
   },
   async editOwnerGroup(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(updateOwnerGroupSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     const success = await DatabaseWrites.projects.update(parseInt(event.params.id), {
@@ -463,14 +451,13 @@ export const actions = {
     return { form, ok: success };
   },
   async projectAction(event) {
-    const session = await event.locals.auth();
-    if (!session) return fail(403);
+    // permissions checked in auth
 
     const form = await superValidate(event.request, valibot(projectActionSchema));
     if (!form.valid || !form.data.operation || form.data.projectId === null)
       return fail(400, { form, ok: false });
     // prefer single project over array
-    const project = await prisma.projects.findUnique({
+    const project = await prisma.projects.findUniqueOrThrow({
       where: { Id: form.data.projectId! },
       select: {
         Id: true,
@@ -480,7 +467,7 @@ export const actions = {
         GroupId: true
       }
     });
-    if (!project) return fail(404);
+    const session = (await event.locals.auth())!;
     if (!canModifyProject(session, project.OwnerId, form.data.orgId)) {
       return fail(403);
     }
diff --git a/portal/src/routes/(authenticated)/projects/[id=idNumber]/edit/+page.server.ts b/portal/src/routes/(authenticated)/projects/[id=idNumber]/edit/+page.server.ts
index 9873f23189..eb77fc9f24 100644
--- a/portal/src/routes/(authenticated)/projects/[id=idNumber]/edit/+page.server.ts
+++ b/portal/src/routes/(authenticated)/projects/[id=idNumber]/edit/+page.server.ts
@@ -1,10 +1,8 @@
 import { idSchema } from '$lib/valibot';
-import { error } from '@sveltejs/kit';
 import { DatabaseWrites, prisma } from 'sil.appbuilder.portal.common';
 import { fail, superValidate } from 'sveltekit-superforms';
 import { valibot } from 'sveltekit-superforms/adapters';
 import * as v from 'valibot';
-import { verifyCanViewAndEdit } from '$lib/projects/server';
 import type { Actions, PageServerLoad } from './$types';
 
 const projectPropertyEditSchema = v.object({
@@ -15,14 +13,14 @@ const projectPropertyEditSchema = v.object({
   description: v.nullable(v.string())
 });
 
-export const load = (async ({ locals, params }) => {
-  if (!verifyCanViewAndEdit((await locals.auth())!, parseInt(params.id))) return error(403);
-  const project = await prisma.projects.findUnique({
+export const load = (async ({ params }) => {
+  // permissions checked in auth
+  // verifyCanViewAndEdit already checks if project exists
+  const project = await prisma.projects.findUniqueOrThrow({
     where: {
       Id: parseInt(params.id)
     }
   });
-  if (!project) return error(400);
   const owners = await prisma.users.findMany({
     where: {
       OrganizationMemberships: {
@@ -52,7 +50,7 @@ export const load = (async ({ locals, params }) => {
 
 export const actions: Actions = {
   default: async function (event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id))) return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(projectPropertyEditSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     if (isNaN(parseInt(event.params.id))) return fail(400, { form, ok: false });
diff --git a/portal/src/routes/(authenticated)/users/[id=idNumber]/settings/groups/+page.svelte b/portal/src/routes/(authenticated)/users/[id=idNumber]/settings/groups/+page.svelte
index b44f5a5a22..6c555ce60a 100644
--- a/portal/src/routes/(authenticated)/users/[id=idNumber]/settings/groups/+page.svelte
+++ b/portal/src/routes/(authenticated)/users/[id=idNumber]/settings/groups/+page.svelte
@@ -41,7 +41,17 @@
                     );
                   }
                 } else {
-                  toast('error', m.errors_generic({ errorMessage: '' }));
+                  if (res?.form.valid) {
+                    toast(
+                      'error',
+                      m.user_ownsProjectsInGroup({
+                        user: data.subject?.Name ?? '',
+                        group: group.Name ?? ''
+                      })
+                    );
+                  } else {
+                    toast('error', m.errors_generic({ errorMessage: '' }));
+                  }
                   // reset toggle
                   toggle.checked = !toggle.checked;
                 }
diff --git a/portal/src/routes/(unauthenticated)/(auth)/login/+page.server.ts b/portal/src/routes/(unauthenticated)/(auth)/login/+page.server.ts
index 7f900e2831..894f47e293 100644
--- a/portal/src/routes/(unauthenticated)/(auth)/login/+page.server.ts
+++ b/portal/src/routes/(unauthenticated)/(auth)/login/+page.server.ts
@@ -1,10 +1,11 @@
 import { localizeHref } from '$lib/paraglide/runtime';
 import { redirect } from '@sveltejs/kit';
+import { Queues } from 'sil.appbuilder.portal.common';
 import { signIn } from '../../../../auth';
 import type { Actions, PageServerLoad } from './$types';
 
 export const load: PageServerLoad = async (event) => {
-  if ((await event.locals.auth())?.user) {
+  if ((await event.locals.auth())?.user && Queues.connected()) {
     if (event.cookies.get('inviteToken')) {
       const inviteToken = event.cookies.get('inviteToken')!;
       return redirect(302, localizeHref('/invitations/organization-membership?t=' + inviteToken));
diff --git a/portal/src/routes/(unauthenticated)/error/+page.server.ts b/portal/src/routes/(unauthenticated)/error/+page.server.ts
new file mode 100644
index 0000000000..11c2585a9e
--- /dev/null
+++ b/portal/src/routes/(unauthenticated)/error/+page.server.ts
@@ -0,0 +1,12 @@
+import { localizeHref } from '$lib/paraglide/runtime';
+import { error, redirect } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+// This is an empty page that exists solely to handle specific error states from the handle functions
+export const load: PageServerLoad = async (event) => {
+  const code = parseInt(event.url.searchParams.get('code') ?? '');
+  if (!isNaN(code)) {
+    return error(code);
+  }
+  return redirect(302, localizeHref('/tasks'));
+};
diff --git a/portal/src/routes/(unauthenticated)/error/+page.svelte b/portal/src/routes/(unauthenticated)/error/+page.svelte
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/portal/src/routes/+error.svelte b/portal/src/routes/+error.svelte
index 23661e0df4..e1db59e8d8 100644
--- a/portal/src/routes/+error.svelte
+++ b/portal/src/routes/+error.svelte
@@ -1,5 +1,24 @@
 <script lang="ts">
   import { page } from '$app/state';
+  import { m } from '$lib/paraglide/messages';
+  import { localizeHref } from '$lib/paraglide/runtime';
 </script>
 
-<h1>{page.status}: {page.error?.message}</h1>
+<div class="mx-auto px-2 w-full md:w-1/2 lg:w-1/3">
+  {#if page.status === 403}
+    <p class="pt-20">{m.errors_friendlyForbidden()}</p>
+  {:else if page.status === 404}
+    <h1 class="pl-0">{m.errors_notFoundTitle()}</h1>
+    <p>{m.errors_notFoundDescription()}</p>
+  {:else if page.status === 503}
+    <p class="pt-20">{m.errors_appUnavailable()}</p>
+  {:else}
+    <h1>{page.status}: {page.error?.message}</h1>
+  {/if}
+  <hr class="my-2" />
+  {#if page.status !== 503}
+    <a class="btn btn-primary" href={localizeHref('/tasks')}>{m.home()}</a>
+  {:else}
+    <a class="btn btn-primary" href={localizeHref('/login')}>{m.auth_login()}</a>
+  {/if}
+</div>


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 3**

<details>
<summary>🔭 Outside diff range comments (2)</summary><blockquote>

<details>
<summary>portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts (2)</summary><blockquote>

`304-329`: _⚠️ Potential issue_

**Author / Reviewer deletions need project ownership validation**

`authors.delete({ where: { Id } })` and `reviewers.delete({ where: { Id } })` run without confirming the record’s `ProjectId`.  
A rogue request could target an ID from a different project and still succeed.

Add a guard similar to the one proposed for `deleteProduct` before performing the write.

---

`374-379`: _⚠️ Potential issue_

**`updateProduct` lacks ownership check**

Same vulnerability as `deleteProduct`: ensure the product belongs to `event.params.id` before updating, otherwise return 403.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>portal/node-server/BullWorker.ts (1)</summary><blockquote>

`9-9`: **Redis connection refactored to use centralized management.**

The worker now uses the shared connection object from `Queues.connection` instead of inline configuration. This change improves consistency in connection management across the application and enables health checks to support the improved error handling.


Consider adding a comment explaining the significance of using the shared connection object for maintaining consistent health monitoring across the application.

</blockquote></details>
<details>
<summary>portal/src/hooks.server.ts (1)</summary><blockquote>

`35-38`: **Avoid per-request DB reads – replace with lightweight ping or cached result**

`prisma.userTasks.findFirst()` executes a full ORM query on every request (except two routes).  
This easily becomes the hottest code-path in production and puts avoidable pressure on the DB connection pool. Consider:

1. Using a cheap native ping, e.g.  
```ts
await prisma.$queryRaw`SELECT 1`;
```  
2. Caching the positive result for a few seconds in memory (`setTimeout`) so only a fraction of requests hit the database.

Both changes retain the same semantics while cutting latency and DB load dramatically.

</blockquote></details>
<details>
<summary>portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts (1)</summary><blockquote>

`39-42`: **Naming clash: `productActionSchema` vs imported `projectActionSchema`**

With both `productActionSchema` (local) and `projectActionSchema` (imported) present, it is very easy to confuse the two while editing.  
A small rename (`productActionFormSchema`) will prevent accidental misuse and improve readability.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**
**Cache: Disabled due to data retention organization setting**
**Knowledge Base: Disabled due to data retention organization setting**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 51b7f84421ed790b4d919f05379f8f49fa47e8fa and 53c641a9766e01385fbe5a7941b1a444508c0fa1.

</details>

<details>
<summary>📒 Files selected for processing (24)</summary>

* `portal/common/bullmq/queues.ts` (1 hunks)
* `portal/common/databaseProxy/GroupMemberships.ts` (1 hunks)
* `portal/common/databaseProxy/Groups.ts` (1 hunks)
* `portal/node-server/BullWorker.ts` (1 hunks)
* `portal/node-server/dev.ts` (2 hunks)
* `portal/node-server/index.ts` (2 hunks)
* `portal/src/auth.ts` (3 hunks)
* `portal/src/hooks.server.ts` (2 hunks)
* `portal/src/lib/locales/en-US.json` (3 hunks)
* `portal/src/lib/locales/es-419.json` (3 hunks)
* `portal/src/lib/locales/fr-FR.json` (3 hunks)
* `portal/src/lib/projects/index.ts` (1 hunks)
* `portal/src/lib/projects/server.ts` (3 hunks)
* `portal/src/lib/utils/index.ts` (1 hunks)
* `portal/src/routes/(authenticated)/+layout.svelte` (0 hunks)
* `portal/src/routes/(authenticated)/admin/settings/organizations/edit/+page.server.ts` (0 hunks)
* `portal/src/routes/(authenticated)/organizations/[id]/settings/groups/+page.svelte` (2 hunks)
* `portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts` (13 hunks)
* `portal/src/routes/(authenticated)/projects/[id=idNumber]/edit/+page.server.ts` (2 hunks)
* `portal/src/routes/(authenticated)/users/[id=idNumber]/settings/groups/+page.svelte` (1 hunks)
* `portal/src/routes/(unauthenticated)/(auth)/login/+page.server.ts` (1 hunks)
* `portal/src/routes/(unauthenticated)/error/+page.server.ts` (1 hunks)
* `portal/src/routes/+error.svelte` (1 hunks)
* `portal/src/routes/+layout.svelte` (2 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (2)</summary>

* portal/src/routes/(authenticated)/+layout.svelte
* portal/src/routes/(authenticated)/admin/settings/organizations/edit/+page.server.ts

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>portal/src/lib/projects/server.ts (1)</summary><blockquote>

<details>
<summary>portal/src/lib/projects/index.ts (1)</summary>

* `canModifyProject` (164-170)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (2)</summary>

* GitHub Check: build
* GitHub Check: build

</details>

<details>
<summary>🔇 Additional comments (38)</summary><blockquote>

<details>
<summary>portal/src/lib/utils/index.ts (1)</summary>

`37-41`: **Good addition of a ServerStatus enum for standardizing HTTP responses**

The new `ServerStatus` enum provides a type-safe way to represent common HTTP status codes, replacing what were likely boolean or magic number returns. This improves code readability and maintainability while standardizing status handling across the application.

</details>
<details>
<summary>portal/common/databaseProxy/GroupMemberships.ts (1)</summary>

`30-31`: **Clear documentation of server-side validation**

Good clarifying comment that explains the context of this validation check. It makes it clear to future developers that this is the only server-side check needed since other validations are already handled by the UI.

</details>
<details>
<summary>portal/common/databaseProxy/Groups.ts (1)</summary>

`6-6`: **Clear documentation of function return semantics**

The JSDoc comment effectively documents the return value semantics for the `deleteGroup` function, making it clear under what conditions the function will return `false`.

</details>
<details>
<summary>portal/src/routes/+layout.svelte (1)</summary>

`2-2`: **Good consolidation of toast notifications to global layout**

Moving the `SvelteToast` component to the global layout is a good architectural decision that centralizes toast notifications, making them available throughout the application instead of just in authenticated routes.




Also applies to: 15-17

</details>
<details>
<summary>portal/node-server/dev.ts (1)</summary>

`4-4`: <details>
<summary>❓ Verification inconclusive</summary>

**Improved error handling in Bull Board queue setup**

Filtering `Queues` values to ensure only `Queue` instances are passed to `BullMQAdapter` is a good defensive programming practice that prevents potential runtime errors.



It might be worth investigating why this filtering is necessary. In an ideal scenario, TypeScript's type system should ensure that `Queues` only contains `Queue` instances. Consider checking the type definition of the `Queues` object to see if it can be improved:




Also applies to: 16-18

---

🏁 Script executed:

```shell
#!/bin/bash
# Check the type definition of Queues
grep -r "export const Queues" --include="*.ts" .
```

Length of output: 50

---

🏁 Script executed:

```shell
#!/bin/bash
# Display the first 40 lines of the file to locate the Queues declaration
sed -n '1,40p' portal/node-server/dev.ts

# Search for all occurrences of "Queues" in this file with context
grep -R -n "Queues" -C2 portal/node-server/dev.ts
```

Length of output: 1280

---



</details>

**Investigate the `Queues` type in the common library**

Filtering `Object.values(Queues)` to only pass `Queue` instances into `BullMQAdapter` is a solid defensive measure. Ideally, though, TypeScript should prevent non-`Queue` values from ever being in `Queues`.

Since `Queues` comes from the external package `sil.appbuilder.portal.common`, please:

• Locate and inspect the `Queues` export in that package (it isn’t defined locally in this repo).  
• Ensure it’s typed as something like `Record<string, Queue>` (or an equivalent interface) rather than a loose `any` or mixed-type object.  
• If needed, update the declaration in `sil.appbuilder.portal.common` to enforce `Queue` instances at compile time.

In this repo, review:
• portal/node-server/dev.ts lines 16–18 (where `queues: Object.values(Queues)…` is filtered).

If `Queues` must remain heterogeneous for other reasons, the runtime filter is justified. Otherwise, tightening the type upstream will remove the need for this guard.

</details>
<details>
<summary>portal/src/routes/(authenticated)/users/[id=idNumber]/settings/groups/+page.svelte (1)</summary>

`44-54`: **Improved error handling for group membership operations**

This enhancement provides better user feedback by showing specific error messages when a user can't be removed from a group due to project ownership, rather than just displaying a generic error.


The code properly differentiates between validation errors and business logic constraints, making the user experience more informative. The localization support through the message system ensures these errors will be properly displayed in the user's preferred language.

</details>
<details>
<summary>portal/node-server/index.ts (2)</summary>

`6-6`: **LGTM: Added necessary Queue import**

Adding the import for the Queue class from bullmq is necessary for the filtering implemented below.

---

`89-91`: **Improved queue initialization with proper type filtering**

This change ensures only valid Queue instances are passed to BullBoard, preventing potential runtime errors if the Queues object contains non-Queue items.


The filtering with `instanceof Queue` is a robust approach for ensuring type safety when working with the queue adapter system.

</details>
<details>
<summary>portal/src/lib/projects/index.ts (1)</summary>

`168-168`: **LGTM: Added explicit return type for better type safety**

Adding the explicit `: boolean` return type is a good practice that improves code readability and type safety without changing functionality.


This change aligns with TypeScript best practices by making the function signature more self-documenting.

</details>
<details>
<summary>portal/src/routes/(unauthenticated)/(auth)/login/+page.server.ts (2)</summary>

`3-3`: **LGTM: Added Queues import for connectivity check**

The import is necessary for the queue connection check added in the load function.

---

`8-8`: **Enhanced authentication check with queue connectivity verification**

This is a good improvement that ensures users are only redirected when both authenticated and the queue services are operational.


By checking `Queues.connected()`, the application prevents directing users to functionality that might not work properly due to background service issues. This aligns well with the PR objective of providing better error handling and user feedback.

</details>
<details>
<summary>portal/src/routes/(unauthenticated)/error/+page.server.ts (1)</summary>

`1-12`: **Clean implementation of a central error handler.**

This new error route serves as a centralized error handler that processes status codes from URL parameters. It's concise and follows good practices by using appropriate SvelteKit helpers.

</details>
<details>
<summary>portal/src/routes/(authenticated)/organizations/[id]/settings/groups/+page.svelte (3)</summary>

`19-37`: **Improved error handling with specific user feedback.**

The enhanced form handling now provides more granular feedback based on server responses. This is a great improvement as it helps users understand exactly why a group deletion failed.

---

`29-30`: **Good localization implementation for specific error cases.**

Using the localized message `m.org_groupHasProjects()` with a parameter provides clear, contextual feedback to users when a group can't be deleted due to project associations.

---

`50-64`: **Consistent form handling implementation.**

The add group form has been reformatted for consistency while maintaining the same functionality. This ensures a uniform approach to form handling throughout the application.

</details>
<details>
<summary>portal/src/lib/locales/fr-FR.json (3)</summary>

`170-170`: **Good localization for group deletion constraints.**

The new localization string provides clear feedback when a group can't be deleted due to project associations, improving the French-speaking user experience.

---

`222-222`: **Appropriate message for user-project ownership constraints.**

This localization string effectively communicates when a user can't be removed due to project ownership, which is important for maintaining data integrity.

---

`243-243`: **Clear notification for application unavailability.**

The addition of a general application unavailability message supports the new health check functionality mentioned in the PR objectives.

</details>
<details>
<summary>portal/src/routes/(authenticated)/projects/[id=idNumber]/edit/+page.server.ts (2)</summary>

`16-19`: **Improved code through centralized permission handling.**

Removing duplicate permission checks and replacing them with comments improves code maintainability. Using `findUniqueOrThrow` is also a good practice as it leverages Prisma's built-in error handling.

---

`53-53`: **Consistent approach to permission handling.**

Delegating permission checks to auth middleware is a good architectural decision that creates consistency across the application.

</details>
<details>
<summary>portal/src/lib/locales/es-419.json (3)</summary>

`170-170`: **New localization string added for group deletion constraint.**

The Spanish localization for the error message when a group cannot be deleted because it has associated projects is properly added. This supports the PR objective of providing more informative error messages for group management scenarios.

---

`222-222`: **New localization string added for user removal constraint.**

The Spanish localization for the error message when a user cannot be removed from a group because they own projects is properly added. This complements the group deletion constraint message and supports the PR objective of providing clearer feedback during group management.

---

`243-243`: **New localization string added for application unavailability.**

The Spanish localization for the generic error message indicating that Scriptoria is unavailable has been properly added. This supports the improved error pages feature by providing a localized message for service unavailability scenarios.

</details>
<details>
<summary>portal/src/routes/+error.svelte (2)</summary>

`3-4`: **Imports added for localization support.**

The imports for message localization and URL localization are properly added, enabling the error page to display localized error messages.

---

`7-23`: **Improved error page with localized messages and responsive layout.**

The error page component has been significantly enhanced to:
1. Use a responsive container with appropriate width constraints
2. Display specific, localized messages for different HTTP status codes (403, 404, 503)
3. Provide contextual navigation based on the error type

This implementation directly addresses the PR objective of creating improved error pages.

</details>
<details>
<summary>portal/src/lib/projects/server.ts (4)</summary>

`2-2`: **Added import for ServerStatus enum.**

The ServerStatus enum import supports the transition to more granular error status reporting.

---

`9-9`: **Updated function signature to return ServerStatus.**

The function `verifyCanViewAndEdit` now returns a `Promise<ServerStatus>` instead of a boolean, providing more specific error status information that can be used to generate appropriate error messages.

---

`22-23`: **Improved error status handling with specific return values.**

The function now returns more specific status codes:
- `ServerStatus.NotFound` if the project doesn't exist
- `ServerStatus.Ok` if the user can modify the project
- `ServerStatus.Forbidden` otherwise

This change enables more precise error handling and user feedback, supporting the PR objective of improved error messaging.

---

`109-109`: **Function changed from async to sync.**

The `verifyCanCreateProject` function has been changed from asynchronous to synchronous while maintaining the same logic, which is appropriate since it doesn't contain any asynchronous operations.

</details>
<details>
<summary>portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts (1)</summary>

`465-468`: **`canModifyProject` may receive `undefined` orgId**

`form.data.orgId` is not guaranteed by `projectActionSchema` shown in this file.  
If it is optional, `canModifyProject` could mis-classify permissions. Confirm the schema or add a null-check:

```ts
if (!form.data.orgId) return fail(400);
```

</details>
<details>
<summary>portal/src/auth.ts (1)</summary>

`171-174`: **Enum value might not map to HTTP status code**

`/error?code=${status}` assumes `ServerStatus` serialises to an HTTP number (e.g. `403`).  
If the enum is declared as strings (`'Forbidden'`, `'NotFound'`, …) the error page will not resolve correctly.

Verify the enum definition and, if necessary, convert:

```ts
return redirect(302, localizeHref(`/error?code=${ServerStatus[status]}`));
```

</details>
<details>
<summary>portal/src/lib/locales/en-US.json (3)</summary>

`172-172`: **Good error message for group deletion constraints.**

This localized string provides clear feedback to users when they attempt to delete a group that has associated projects.

---

`224-224`: **Clear error message for user removal constraints.**

The message effectively communicates why a user cannot be removed from a group when they own projects within it.

---

`245-245`: **Appropriate generic unavailability message.**

This message will be useful for the new health check system that detects when Redis or the database are unavailable.

</details>
<details>
<summary>portal/common/bullmq/queues.ts (4)</summary>

`6-8`: **Good centralization of Redis connection config.**

Extracting the connection configuration into a constant improves maintainability and consistency.

---

`10-36`: **Well-implemented connection monitoring.**

The Connection class provides robust Redis connection monitoring with:
- Event-based status tracking
- Periodic health checks every 10 seconds
- Clear error logging
- Public status accessor

This is a good pattern for ensuring the application can detect and respond to Redis connectivity issues.

---

`38-40`: **Good singleton pattern implementation.**

Creating a single connection instance and exposing its status via a function is a clean approach that enables health checks throughout the application.

---

`42-53`: **Consistent queue configuration.**

All queues now use the shared connection configuration, improving consistency and enabling centralized connection monitoring.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T13:50:18Z
user: login: FyreByrd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:04:08Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:04:29Z
user: login: FyreByrd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:04:38Z
user: login: FyreByrd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:06:51Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:07:12Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:09:37Z
user: login: 7dev7urandom
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-17T16:40:03Z

Review comments:
diff_hunk: @@ -11,17 +14,45 @@ import {
 
 // creating a handle to use the paraglide middleware
 const paraglideHandle: Handle = ({ event, resolve }) =>
-	paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => {
-		event.request = localizedRequest;
-		return resolve(event, {
-			transformPageChunk: ({ html }) => {
-				return html.replace('%lang%', locale);
-			}
-		});
-	});
+  paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => {
+    event.request = localizedRequest;
+    return resolve(event, {
+      transformPageChunk: ({ html }) => {
+        return html.replace('%lang%', locale);
+      }
+    });
+  });
+
+const heartbeat: Handle = async ({ event, resolve }) => {
+  // this check is important to prevent infinite redirects...
+  if (
+    !(
+      event.route.id === '/(unauthenticated)/error' ||
+      event.route.id === '/(unauthenticated)/(auth)/login'
+    )
+  ) {
+    try {
+      const dbtest = await prisma.userTasks.findFirst({ select: { Id: true } });
+      if (!Queues.connected()) {
+        return redirect(302, localizeHref(`/error?code=503`));
+      }
+    } catch (e) {
+      if (e instanceof Prisma.PrismaClientKnownRequestError) {
+        // As best as I can tell, the only types of PrismaClientKnownRequestError that
+        // should be thrown by the above query would involve the database being unreachable.
+        // HTTP 503 *should* be the correct semantics here?
+        return redirect(302, localizeHref(`/error?code=503`));
+      } else {
+        throw e;
+      }
+    }
path: portal/src/hooks.server.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Connectivity errors other than `PrismaClientKnownRequestError` slip through**

When the database is down, Prisma often throws `PrismaClientInitializationError` or `PrismaClientRustPanicError`, not `PrismaClientKnownRequestError`.  
Those will bypass this `catch` block and surface as an unhandled 500 instead of a graceful 503 redirect.

```diff
-  } catch (e) {
-    if (e instanceof Prisma.PrismaClientKnownRequestError) {
+  } catch (e) {
+    if (
+      e instanceof Prisma.PrismaClientKnownRequestError ||
+      e instanceof Prisma.PrismaClientInitializationError ||
+      e instanceof Prisma.PrismaClientRustPanicError
+    ) {
       return redirect(302, localizeHref(`/error?code=503`));
```

Also drop the unused `dbtest` variable (`const _ = …`) to silence lint warnings.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    } catch (e) {
      if (
        e instanceof Prisma.PrismaClientKnownRequestError ||
        e instanceof Prisma.PrismaClientInitializationError ||
        e instanceof Prisma.PrismaClientRustPanicError
      ) {
        return redirect(302, localizeHref(`/error?code=503`));
      } else {
        throw e;
      }
    }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In portal/src/hooks.server.ts around lines 40 to 48, the current error handling
only catches PrismaClientKnownRequestError but misses
PrismaClientInitializationError and PrismaClientRustPanicError, causing
unhandled 500 errors instead of a 503 redirect. Update the catch block to also
handle these additional Prisma error types by redirecting to the 503 error page.
Additionally, remove the unused dbtest variable declared as const _ = ... to
eliminate lint warnings.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-15T13:50:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,15 +294,13 @@ export const load = (async ({ locals, params }) => {
 
 export const actions = {
   async deleteProduct(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(productActionSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     await DatabaseWrites.products.delete(form.data.productId);
   },
path: portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`deleteProduct` allows cross-project deletion**

The supplied `productId` is deleted without verifying that it belongs to the current project.  
A user with access to *any* project can therefore delete a product from another project they also view.

```diff
-await DatabaseWrites.products.delete(form.data.productId);
+const product = await prisma.products.findUnique({
+  where: { Id: form.data.productId },
+  select: { ProjectId: true }
+});
+if (!product || product.ProjectId !== parseInt(event.params.id))
+  return fail(403);
+
+await DatabaseWrites.products.delete(form.data.productId);
```
Apply the same pattern to `updateProduct`, `productAction`, `deleteAuthor`, and `deleteReviewer`.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
  async deleteProduct(event) {
    // permissions checked in auth
    const form = await superValidate(event.request, valibot(productActionSchema));
    if (!form.valid) return fail(400, { form, ok: false });

    const product = await prisma.products.findUnique({
      where: { Id: form.data.productId },
      select: { ProjectId: true }
    });
    if (!product || product.ProjectId !== parseInt(event.params.id))
      return fail(403);

    await DatabaseWrites.products.delete(form.data.productId);
  },
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
around lines 296 to 301, the deleteProduct function deletes a product by
productId without verifying it belongs to the current project, allowing
cross-project deletion. Fix this by adding a check to confirm the product's
projectId matches the current project id before deletion. Apply the same
validation pattern to updateProduct, productAction, deleteAuthor, and
deleteReviewer functions to ensure all deletions and updates are scoped to the
current project only.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-15T13:50:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -177,56 +180,58 @@ async function validateRouteForAuthenticatedUser(
   session: Session,
   route: string,
   params: Partial<Record<string, string>>
-): Promise<boolean> {
+): Promise<ServerStatus> {
   const path = route.split('/').filter((r) => !!r);
   // Only guarding authenticated routes
   if (path[0] === '(authenticated)') {
     if (path[1] === 'admin' || path[1] === 'workflow-instances')
       //@ts-expect-error this is the correct type, see comment on the extended Session type
-      return isSuperAdmin(session?.user?.roles);
+      return isSuperAdmin(session?.user?.roles) ? ServerStatus.Ok : ServerStatus.Forbidden;
     else if (path[1] === 'directory' || path[1] === 'open-source')
       // Always allowed. Open pages
-      return true;
+      return ServerStatus.Ok;
     else if (path[1] === 'organizations') {
       // Must be org admin for some organization (or a super admin)
       //@ts-expect-error this is the correct type, see comment on the extended Session type
-      if (!isAdmin(session?.user?.roles)) return false;
+      if (!isAdmin(session?.user?.roles)) return ServerStatus.Forbidden;
       if (params.id) {
         // Must be org admin for specified organization (or a super admin)
         //@ts-expect-error this is the correct type, see comment on the extended Session type
-        return isAdminForOrg(parseInt(params.id!), session?.user?.roles);
+        return isAdminForOrg(parseInt(params.id!), session?.user?.roles)
+          ? ServerStatus.Ok
+          : ServerStatus.Forbidden;
       }
-      return true;
+      return ServerStatus.Ok;
     } else if (path[1] === 'products') {
       const product = await prisma.products.findFirst({
         where: {
           Id: params.id
         }
       });
-      if (!product) return false;
-      // Must be allowed to view associated project 
+      if (!product) return ServerStatus.NotFound;
+      // Must be allowed to view associated project
       // (this route was originally part of the project page but was moved elsewhere to improve load time)
       //@ts-expect-error this is the correct type, see comment on the extended Session type
-      return verifyCanViewAndEdit(session, product.ProjectId);
+      return await verifyCanViewAndEdit(session, product.ProjectId);
     } else if (path[1] === 'projects') {
path: portal/src/auth.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`params.id` should be parsed to `number` before Prisma lookup**

`prisma.products.findFirst({ where: { Id: params.id } })` passes a string to an `Int` column.  
While Prisma often coerces, it can also throw a runtime error in strict mode and prevents index usage.

```diff
-const product = await prisma.products.findFirst({
-  where: { Id: params.id }
+const product = await prisma.products.findFirst({
+  where: { Id: parseInt(params.id!, 10) }
 })
```

Also ensure `params.id` is present; otherwise return `ServerStatus.NotFound`.  



> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In portal/src/auth.ts around lines 206 to 216, params.id is used directly as a
string in a Prisma query for an Int column, which can cause runtime errors or
inefficient queries. Fix this by first checking if params.id exists; if not,
return ServerStatus.NotFound. Then parse params.id to a number before passing it
to prisma.products.findFirst. This ensures type correctness and avoids potential
runtime errors or performance issues.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-15T13:50:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,15 +294,13 @@ export const load = (async ({ locals, params }) => {
 
 export const actions = {
   async deleteProduct(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(productActionSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     await DatabaseWrites.products.delete(form.data.productId);
   },
path: portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
user: login: FyreByrd
body: This will be handled separately. Thanks for catching this
created_at: 2025-05-15T14:04:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,15 +294,13 @@ export const load = (async ({ locals, params }) => {
 
 export const actions = {
   async deleteProduct(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(productActionSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     await DatabaseWrites.products.delete(form.data.productId);
   },
path: portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
user: login: coderabbitai[bot]
body: @FyreByrd You're welcome! I understand you'll be handling this in a separate task. If you'd like me to create an issue to track this security concern for future implementation, just let me know and I can help with that. Otherwise, I'll leave it in your capable hands.



(⌐■_■)🐰 No problem!

---

<!-- This is an auto-generated comment: tweet message by coderabbit.ai -->

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%40coderabbitai%20spotted%20a%20critical%20security%20flaw%3A%20deleting%20products%20without%20verifying%20project%20ownership%20risks%20cross-project%20data%20loss.%20Always%20validate%20ownership%20before%20delete%2Fupdate!%20%F0%9F%94%92%F0%9F%90%87%20Try%20%40coderabbitai!&url=https%3A%2F%2Fgithub.com%2Fsillsdev%2Fappbuilder-portal%2Fpull%2F1135%23discussion_r2091225340)?

Thank you for using CodeRabbit!

<!-- end of auto-generated comment: tweet message by coderabbit.ai -->


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@FyreByrd You're welcome! I understand you'll be handling this in a separate task. If you'd like me to create an issue to track this security concern for future implementation, just let me know and I can help with that. Otherwise, I'll leave it in your capable hands.



(⌐■_■)🐰 No problem!

---

&lt;!-- This is an auto-generated comment: tweet message by coderabbit.ai --&gt;

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%40coderabbitai%20spotted%20a%20critical%20security%20flaw%3A%20deleting%20products%20without%20verifying%20project%20ownership%20risks%20cross-project%20data%20loss.%20Always%20validate%20ownership%20before%20delete%2Fupdate!%20%F0%9F%94%92%F0%9F%90%87%20Try%20%40coderabbitai!&url=https%3A%2F%2Fgithub.com%2Fsillsdev%2Fappbuilder-portal%2Fpull%2F1135%23discussion_r2091225340)?

Thank you for using CodeRabbit!

&lt;!-- end of auto-generated comment: tweet message by coderabbit.ai --&gt;

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-15T14:04:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -177,56 +180,58 @@ async function validateRouteForAuthenticatedUser(
   session: Session,
   route: string,
   params: Partial<Record<string, string>>
-): Promise<boolean> {
+): Promise<ServerStatus> {
   const path = route.split('/').filter((r) => !!r);
   // Only guarding authenticated routes
   if (path[0] === '(authenticated)') {
     if (path[1] === 'admin' || path[1] === 'workflow-instances')
       //@ts-expect-error this is the correct type, see comment on the extended Session type
-      return isSuperAdmin(session?.user?.roles);
+      return isSuperAdmin(session?.user?.roles) ? ServerStatus.Ok : ServerStatus.Forbidden;
     else if (path[1] === 'directory' || path[1] === 'open-source')
       // Always allowed. Open pages
-      return true;
+      return ServerStatus.Ok;
     else if (path[1] === 'organizations') {
       // Must be org admin for some organization (or a super admin)
       //@ts-expect-error this is the correct type, see comment on the extended Session type
-      if (!isAdmin(session?.user?.roles)) return false;
+      if (!isAdmin(session?.user?.roles)) return ServerStatus.Forbidden;
       if (params.id) {
         // Must be org admin for specified organization (or a super admin)
         //@ts-expect-error this is the correct type, see comment on the extended Session type
-        return isAdminForOrg(parseInt(params.id!), session?.user?.roles);
+        return isAdminForOrg(parseInt(params.id!), session?.user?.roles)
+          ? ServerStatus.Ok
+          : ServerStatus.Forbidden;
       }
-      return true;
+      return ServerStatus.Ok;
     } else if (path[1] === 'products') {
       const product = await prisma.products.findFirst({
         where: {
           Id: params.id
         }
       });
-      if (!product) return false;
-      // Must be allowed to view associated project 
+      if (!product) return ServerStatus.NotFound;
+      // Must be allowed to view associated project
       // (this route was originally part of the project page but was moved elsewhere to improve load time)
       //@ts-expect-error this is the correct type, see comment on the extended Session type
-      return verifyCanViewAndEdit(session, product.ProjectId);
+      return await verifyCanViewAndEdit(session, product.ProjectId);
     } else if (path[1] === 'projects') {
path: portal/src/auth.ts
user: login: FyreByrd
body: product id is a string, so this is unnecessary
created_at: 2025-05-15T14:04:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,15 +294,13 @@ export const load = (async ({ locals, params }) => {
 
 export const actions = {
   async deleteProduct(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(productActionSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     await DatabaseWrites.products.delete(form.data.productId);
   },
path: portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
user: login: FyreByrd
body: If you could go ahead and create an issue, that would be great. It might be related enough to #1127 to just make a comment there.
created_at: 2025-05-15T14:06:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -177,56 +180,58 @@ async function validateRouteForAuthenticatedUser(
   session: Session,
   route: string,
   params: Partial<Record<string, string>>
-): Promise<boolean> {
+): Promise<ServerStatus> {
   const path = route.split('/').filter((r) => !!r);
   // Only guarding authenticated routes
   if (path[0] === '(authenticated)') {
     if (path[1] === 'admin' || path[1] === 'workflow-instances')
       //@ts-expect-error this is the correct type, see comment on the extended Session type
-      return isSuperAdmin(session?.user?.roles);
+      return isSuperAdmin(session?.user?.roles) ? ServerStatus.Ok : ServerStatus.Forbidden;
     else if (path[1] === 'directory' || path[1] === 'open-source')
       // Always allowed. Open pages
-      return true;
+      return ServerStatus.Ok;
     else if (path[1] === 'organizations') {
       // Must be org admin for some organization (or a super admin)
       //@ts-expect-error this is the correct type, see comment on the extended Session type
-      if (!isAdmin(session?.user?.roles)) return false;
+      if (!isAdmin(session?.user?.roles)) return ServerStatus.Forbidden;
       if (params.id) {
         // Must be org admin for specified organization (or a super admin)
         //@ts-expect-error this is the correct type, see comment on the extended Session type
-        return isAdminForOrg(parseInt(params.id!), session?.user?.roles);
+        return isAdminForOrg(parseInt(params.id!), session?.user?.roles)
+          ? ServerStatus.Ok
+          : ServerStatus.Forbidden;
       }
-      return true;
+      return ServerStatus.Ok;
     } else if (path[1] === 'products') {
       const product = await prisma.products.findFirst({
         where: {
           Id: params.id
         }
       });
-      if (!product) return false;
-      // Must be allowed to view associated project 
+      if (!product) return ServerStatus.NotFound;
+      // Must be allowed to view associated project
       // (this route was originally part of the project page but was moved elsewhere to improve load time)
       //@ts-expect-error this is the correct type, see comment on the extended Session type
-      return verifyCanViewAndEdit(session, product.ProjectId);
+      return await verifyCanViewAndEdit(session, product.ProjectId);
     } else if (path[1] === 'projects') {
path: portal/src/auth.ts
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

Let me search more specifically for the products model definition:






Let me look for code that's actually using the products table:




@FyreByrd You're absolutely right - I apologize for the confusion. My suggestion to parse the product ID to a number was incorrect since the product ID is stored as a string in your database schema. 

Thank you for the correction! This prevents introducing unnecessary type conversions that could potentially cause issues.

---

🏁 Script executed:
```
#!/bin/bash
# Look for the Prisma schema file
find . -name "schema.prisma" -type f | xargs cat | grep -A 20 "model products"

# If we can't find it directly, let's look for TypeScript type definitions
find . -name "*.ts" -type f -exec grep -l "products.*Id" {} \; | xargs cat | grep -A 10 -B 10 "Id.*:"
```

Length of output: 40489

---

🏁 Script executed:
```
#!/bin/bash
# Search for Prisma schema with more context
find . -name "schema.prisma" -o -name "*.prisma" | xargs grep -l "model products" | xargs cat

# As an alternative, let's look for TypeScript interfaces or types related to products
find . -name "*.ts" -type f -exec grep -l "interface Product" {} \; | xargs cat
```

Length of output: 7927

---

🏁 Script executed:
```
#!/bin/bash
# Look for actual usage of product IDs in the codebase
grep -r "products.*findUnique" --include="*.ts" .
grep -r "products.*findFirst" --include="*.ts" .

# Check for TypeScript type declarations related to products
grep -r "type Product" --include="*.ts" .
grep -r "interface Product" --include="*.ts" .
```

Length of output: 3940

---



</details>

@FyreByrd You're absolutely right - I apologize for the confusion. My suggestion to parse the product ID to a number was incorrect since product IDs are indeed strings in your codebase, as evidenced by their usage throughout the code.

Thank you for the correction! This prevents introducing unnecessary type conversions that could potentially cause issues.

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippets for understanding `portal/src/auth.ts` and the user comment about "product id is a string, so this is unnecessary":

---

#### Snippet from `portal/src/lib/utils/roles.ts` (lines 6-10, 22-24, 28-30)
```ts
export function isAdminForOrg(orgId: number, roles?: [number, number][]): boolean {
  return !!roles?.find(
    ([org, role]) => role === RoleId.SuperAdmin || (role === RoleId.OrgAdmin && org === orgId)
  );
}

export function isAdmin(roles?: [number, number][]): boolean {
  return !!roles?.find((r) => r[1] === RoleId.SuperAdmin || r[1] === RoleId.OrgAdmin);
}

export function isSuperAdmin(roles?: [number, number][]): boolean {
  return !!roles?.find((r) => r[1] === RoleId.SuperAdmin);
}
```
- These functions check user roles for authorization.
- They take roles as an array of tuples `[organizationId, roleId]` where both are numbers.
- Used in `auth.ts` to validate user permissions.

---

#### Snippet from `portal/src/lib/projects/server.ts` (lines 9-24)
```ts
export async function verifyCanViewAndEdit(user: Session, projectId: number): Promise<ServerStatus> {
  const project = await prisma.projects.findUnique({
    where: {
      Id: projectId
    },
    select: {
      Id: true,
      OwnerId: true,
      OrganizationId: true
    }
  });
  if (!project) return ServerStatus.NotFound;
  return canModifyProject(user, project.OwnerId, project.OrganizationId) ? ServerStatus.Ok : ServerStatus.Forbidden;
}
```
- Verifies if a user can view/edit a project by project ID (number).
- Used in `auth.ts` for route validation.

---

#### Snippet from `portal/src/lib/organizationInvites.ts` (lines 3-17)
```ts
export async function checkInviteErrors(inviteToken?: string | null) {
  try {
    const invite = await prisma.organizationMembershipInvites.findFirst({
      where: {
        Token: inviteToken + ''
      }
    });
    if (!invite || !inviteToken) return { error: 'not found' };
    if (invite.Redeemed) return { error: 'redeemed' };
    if (!invite.Expires || invite.Expires < new Date()) return { error: 'expired' };
    return {};
  } catch (e) {
    return { error: 'not found' };
  }
}
```
- Checks validity of an invite token.
- Used in `auth.ts` to set token status.

---

#### Snippet from `portal/node-server/job-executors/build.ts` (lines 16-108)
- This function processes a build job for a product.
- It queries `prisma.products.findUnique` with `Id: job.data.productId`.
- `productId` is used as a key to query products.
- The snippet shows `productId` is used as a key in Prisma queries, implying it is a string or number.
- The snippet is long; main point is that `productId` is used directly as a key without conversion.

---

#### Snippet from `portal/node-server/job-executors/publish.ts` (lines 16-130)
- Similar to the build executor, this function processes a publish job for a product.
- Uses `prisma.products.findUnique` with `Id: job.data.productId`.
- Uses `productId` directly as a key.
- Shows `productId` is treated as a string or number key without explicit conversion.

---

### Summary of relevance to user comment:
- In `auth.ts`, there are places where `params.id` is parsed with `parseInt(params.id!)` for projects and organizations.
- For products, `params.id` is used directly as a string key in Prisma queries without parsing.
- The user comment "product id is a string, so this is unnecessary" likely refers to the unnecessary `parseInt` usage for product IDs or the converse that product IDs are strings and do not need parsing.
- The snippets from job executors and Prisma queries show product IDs are used as strings directly.
- Role checking and project verification functions expect numeric IDs, explaining why `parseInt` is used for projects and organizations but not products.

---

No other snippets from the provided list are directly relevant to the product ID type or its usage in `auth.ts`.

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

Let me search more specifically for the products model definition:






Let me look for code that's actually using the products table:




@FyreByrd You're absolutely right - I apologize for the confusion. My suggestion to parse the product ID to a number was incorrect since the product ID is stored as a string in your database schema. 

Thank you for the correction! This prevents introducing unnecessary type conversions that could potentially cause issues.

---

🏁 Script executed:
```
#!/bin/bash
# Look for the Prisma schema file
find . -name "schema.prisma" -type f | xargs cat | grep -A 20 "model products"

# If we can't find it directly, let's look for TypeScript type definitions
find . -name "*.ts" -type f -exec grep -l "products.*Id" {} \; | xargs cat | grep -A 10 -B 10 "Id.*:"
```

Length of output: 40489

---

🏁 Script executed:
```
#!/bin/bash
# Search for Prisma schema with more context
find . -name "schema.prisma" -o -name "*.prisma" | xargs grep -l "model products" | xargs cat

# As an alternative, let's look for TypeScript interfaces or types related to products
find . -name "*.ts" -type f -exec grep -l "interface Product" {} \; | xargs cat
```

Length of output: 7927

---

🏁 Script executed:
```
#!/bin/bash
# Look for actual usage of product IDs in the codebase
grep -r "products.*findUnique" --include="*.ts" .
grep -r "products.*findFirst" --include="*.ts" .

# Check for TypeScript type declarations related to products
grep -r "type Product" --include="*.ts" .
grep -r "interface Product" --include="*.ts" .
```

Length of output: 3940

---



</details>

@FyreByrd You're absolutely right - I apologize for the confusion. My suggestion to parse the product ID to a number was incorrect since product IDs are indeed strings in your codebase, as evidenced by their usage throughout the code.

Thank you for the correction! This prevents introducing unnecessary type conversions that could potentially cause issues.

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippets for understanding `portal/src/auth.ts` and the user comment about "product id is a string, so this is unnecessary":

---

#### Snippet from `portal/src/lib/utils/roles.ts` (lines 6-10, 22-24, 28-30)
```ts
export function isAdminForOrg(orgId: number, roles?: [number, number][]): boolean {
  return !!roles?.find(
    ([org, role]) => role === RoleId.SuperAdmin || (role === RoleId.OrgAdmin && org === orgId)
  );
}

export function isAdmin(roles?: [number, number][]): boolean {
  return !!roles?.find((r) => r[1] === RoleId.SuperAdmin || r[1] === RoleId.OrgAdmin);
}

export function isSuperAdmin(roles?: [number, number][]): boolean {
  return !!roles?.find((r) => r[1] === RoleId.SuperAdmin);
}
```
- These functions check user roles for authorization.
- They take roles as an array of tuples `[organizationId, roleId]` where both are numbers.
- Used in `auth.ts` to validate user permissions.

---

#### Snippet from `portal/src/lib/projects/server.ts` (lines 9-24)
```ts
export async function verifyCanViewAndEdit(user: Session, projectId: number): Promise<ServerStatus> {
  const project = await prisma.projects.findUnique({
    where: {
      Id: projectId
    },
    select: {
      Id: true,
      OwnerId: true,
      OrganizationId: true
    }
  });
  if (!project) return ServerStatus.NotFound;
  return canModifyProject(user, project.OwnerId, project.OrganizationId) ? ServerStatus.Ok : ServerStatus.Forbidden;
}
```
- Verifies if a user can view/edit a project by project ID (number).
- Used in `auth.ts` for route validation.

---

#### Snippet from `portal/src/lib/organizationInvites.ts` (lines 3-17)
```ts
export async function checkInviteErrors(inviteToken?: string | null) {
  try {
    const invite = await prisma.organizationMembershipInvites.findFirst({
      where: {
        Token: inviteToken + ''
      }
    });
    if (!invite || !inviteToken) return { error: 'not found' };
    if (invite.Redeemed) return { error: 'redeemed' };
    if (!invite.Expires || invite.Expires < new Date()) return { error: 'expired' };
    return {};
  } catch (e) {
    return { error: 'not found' };
  }
}
```
- Checks validity of an invite token.
- Used in `auth.ts` to set token status.

---

#### Snippet from `portal/node-server/job-executors/build.ts` (lines 16-108)
- This function processes a build job for a product.
- It queries `prisma.products.findUnique` with `Id: job.data.productId`.
- `productId` is used as a key to query products.
- The snippet shows `productId` is used as a key in Prisma queries, implying it is a string or number.
- The snippet is long; main point is that `productId` is used directly as a key without conversion.

---

#### Snippet from `portal/node-server/job-executors/publish.ts` (lines 16-130)
- Similar to the build executor, this function processes a publish job for a product.
- Uses `prisma.products.findUnique` with `Id: job.data.productId`.
- Uses `productId` directly as a key.
- Shows `productId` is treated as a string or number key without explicit conversion.

---

### Summary of relevance to user comment:
- In `auth.ts`, there are places where `params.id` is parsed with `parseInt(params.id!)` for projects and organizations.
- For products, `params.id` is used directly as a string key in Prisma queries without parsing.
- The user comment "product id is a string, so this is unnecessary" likely refers to the unnecessary `parseInt` usage for product IDs or the converse that product IDs are strings and do not need parsing.
- The snippets from job executors and Prisma queries show product IDs are used as strings directly.
- Role checking and project verification functions expect numeric IDs, explaining why `parseInt` is used for projects and organizations but not products.

---

No other snippets from the provided list are directly relevant to the product ID type or its usage in `auth.ts`.

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-15T14:07:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,15 +294,13 @@ export const load = (async ({ locals, params }) => {
 
 export const actions = {
   async deleteProduct(event) {
-    if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
-      return fail(403);
+    // permissions checked in auth
     const form = await superValidate(event.request, valibot(productActionSchema));
     if (!form.valid) return fail(400, { form, ok: false });
     await DatabaseWrites.products.delete(form.data.productId);
   },
path: portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

@FyreByrd I'll check issue #1127 first to see if it's related to this cross-project deletion vulnerability, then either comment there or create a new issue as appropriate.

---

🏁 Script executed:
```
#!/bin/bash
# Get information about issue #1127
gh issue view 1127 --repo sillsdev/appbuilder-portal
```

Length of output: 402

---

@FyreByrd I've reviewed issue #1127 and it does indeed cover the broader authorization problems. I'm adding a comment to that issue detailing the specific cross-project deletion vulnerability we found in this PR. The comment will include the affected functions and the suggested fix pattern.

---

🏁 Script executed:
```
#!/bin/bash
# Add a comment to issue #1127 about the cross-project deletion vulnerability
gh issue comment 1127 --repo sillsdev/appbuilder-portal --body "Found a specific instance of this authorization issue in PR #1135: The \`deleteProduct\` action in \`portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts\` deletes products by ID without verifying the product belongs to the current project. This allows users with access to multiple projects to delete products across projects.

Similar validation is missing in \`updateProduct\`, \`productAction\`, \`deleteAuthor\`, and \`deleteReviewer\` functions.

Suggested fix pattern:
\`\`\`typescript
// Before deleting/updating
const product = await prisma.products.findUnique({
  where: { Id: form.data.productId },
  select: { ProjectId: true }
});
if (!product || product.ProjectId !== parseInt(event.params.id))
  return fail(403);
\`\`\`"
```

Length of output: 886

---



</details>

@FyreByrd I've added a comment to issue #1127 as requested. You can find it here: [comment #2883955612](https://github.com/sillsdev/appbuilder-portal/issues/1127#issuecomment-2883955612)

The comment details the cross-project deletion vulnerability in the `deleteProduct` action and mentions that similar validation is missing in `updateProduct`, `productAction`, `deleteAuthor`, and `deleteReviewer` functions. I also included the suggested fix pattern for reference.



---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippets for understanding `portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts` and the user comment:

---

#### File: `portal/src/lib/projects/index.ts`  
Lines 142-147:
```ts
export const projectActionSchema = v.object({
  operation: v.nullable(v.picklist(['archive', 'reactivate', 'claim'])),
  // used to distinguish between single and bulk. will be null if bulk
  projectId: v.nullable(idSchema),
  orgId: idSchema
});
```
*Defines the validation schema for project actions, including operations like 'archive', 'reactivate', and 'claim'. This schema is used in the `projectAction` action in the main file.*

---

Lines 164-170:
```ts
export function canModifyProject(
  user: Session | null | undefined,
  projectOwnerId: number,
  organizationId: number
): boolean {
  return projectOwnerId === user?.user.userId || isAdminForOrg(organizationId, user?.user.roles);
}
```
*Function to check if a user can modify a project, either by ownership or admin role in the organization.*

---

#### File: `portal/src/lib/projects/server.ts`  
Lines 117-131:
```ts
export async function userGroupsForOrg(userId: number, orgId: number) {
  return prisma.groupMemberships.findMany({
    where: {
      UserId: userId,
      Group: {
        is: {
          OwnerId: orgId
        }
      }
    },
    select: {
      GroupId: true
    }
  });
}
```
*Fetches the groups a user belongs to within a specific organization.*

---

Lines 133-172:
```ts
export async function doProjectAction(
  operation: string | null,
  project: Omit<ProjectForAction, 'Name'>,
  session: Session,
  orgId: number,
  groups: number[]
) {
  if (operation === 'archive' && !project?.DateArchived) {
    await DatabaseWrites.projects.update(project.Id, {
      DateArchived: new Date()
    });
    await Queues.UserTasks.add(`Delete UserTasks for Archived Project #${project.Id}`, {
      type: BullMQ.JobType.UserTasks_Modify,
      scope: 'Project',
      projectId: project.Id,
      operation: {
        type: BullMQ.UserTasks.OpType.Delete
      }
    });
  } else if (operation === 'reactivate' && !!project?.DateArchived) {
    await DatabaseWrites.projects.update(project.Id, {
      DateArchived: null
    });
    await Queues.UserTasks.add(`Create UserTasks for Reactivated Project #${project.Id}`, {
      type: BullMQ.JobType.UserTasks_Modify,
      scope: 'Project',
      projectId: project.Id,
      operation: {
        type: BullMQ.UserTasks.OpType.Create
      }
    });
  } else if (
    operation === 'claim' &&
    canClaimProject(session, project?.OwnerId, orgId, project?.GroupId, groups)
  ) {
    await DatabaseWrites.projects.update(project.Id, {
      OwnerId: session.user.userId
    });
  }
}
```
*Performs the project action (archive, reactivate, claim) with appropriate database updates and queueing user task modifications.*

---

#### File: `portal/src/lib/products/index.ts`  
Lines 9-40:
```ts
export function getProductActions(
  product: {
    WorkflowInstance: {
      WorkflowDefinition: {
        Type: WorkflowType;
      };
    } | null;
    DatePublished: unknown;
    ProductDefinition: { RebuildWorkflowId: unknown; RepublishWorkflowId: unknown };
  },
  projectOwnerId: number,
  userId: number
) {
  const ret: ProductActionType[] = [];
  if (!product.WorkflowInstance) {
    if (product.DatePublished) {
      if (product.ProductDefinition.RebuildWorkflowId !== null) {
        ret.push(ProductActionType.Rebuild);
      }
      if (product.ProductDefinition.RepublishWorkflowId !== null) {
        ret.push(ProductActionType.Republish);
      }
    }
  } else if (
    projectOwnerId === userId &&
    product.WorkflowInstance.WorkflowDefinition.Type !== WorkflowType.Startup
  ) {
    ret.push(ProductActionType.Cancel);
  }

  return ret;
}
```
*Determines the possible actions for a product based on its workflow state and user permissions.*

---

#### File: `portal/src/lib/products/server.ts`  
Lines 5-86:
```ts
export async function doProductAction(productId: string, action: ProductActionType) {
  const product = await prisma.products.findUnique({
    where: {
      Id: productId
    },
    select: {
      Id: true,
      ProjectId: true,
      ProductDefinition: {
        select: {
          RebuildWorkflow: {
            select: {
              Type: true,
              ProductType: true,
              WorkflowOptions: true
            }
          },
          RepublishWorkflow: {
            select: {
              Type: true,
              ProductType: true,
              WorkflowOptions: true
            }
          }
        }
      },
      WorkflowInstance: {
        select: {
          WorkflowDefinition: {
            select: { Type: true }
          }
        }
      }
    }
  });

  if (product) {
    switch (action) {
    case ProductActionType.Rebuild:
    case ProductActionType.Republish: {
      const flowType = action === 'rebuild' ? 'RebuildWorkflow' : 'RepublishWorkflow';
      if (product.ProductDefinition[flowType] && !product.WorkflowInstance) {
        await Workflow.create(productId, {
          productType: product.ProductDefinition[flowType].ProductType,
          options: new Set(product.ProductDefinition[flowType].WorkflowOptions),
          workflowType: product.ProductDefinition[flowType].Type
        });
      }
      break;
    }
    case ProductActionType.Cancel:
      if (
        product.WorkflowInstance?.WorkflowDefinition &&
          product.WorkflowInstance.WorkflowDefinition.Type !== WorkflowType.Startup
      ) {
        await Queues.UserTasks.add(
          `Delete UserTasks for canceled workflow (product #${productId})`,
          {
            type: BullMQ.JobType.UserTasks_Modify,
            scope: 'Product',
            productId,
            operation: {
              type: BullMQ.UserTasks.OpType.Delete
            }
          }
        );
        await DatabaseWrites.productTransitions.create({
          data: {
            ProductId: productId,
            // This is how S1 does it. May want to change later
            AllowedUserNames: '',
            DateTransition: new Date(),
            TransitionType: ProductTransitionType.CancelWorkflow,
            WorkflowType: product.WorkflowInstance.WorkflowDefinition.Type
          }
        });
        await DatabaseWrites.workflowInstances.delete(productId, product.ProjectId);
      }
      break;
    }
  }
}
```
*Executes the product action such as rebuild, republish, or cancel, including workflow creation and cleanup.*

---

#### File: `portal/src/lib/valibot.ts`  
Lines 3-6:
```ts
export const idSchema = v.pipe(v.number(), v.minValue(0), v.integer());
export const stringIdSchema = v.pipe(v.string(), v.uuid());
```
*Defines validation schemas for numeric IDs and string UUIDs used in form validation.*

Lines 8-10:
```ts
export const deleteSchema = v.object({
  id: idSchema
});
```
*Validation schema for delete operations requiring an ID.*

Lines 21-53:
```ts
export const propertiesSchema = v.nullable(
  v.pipe(
    v.string(),
    // make sure it is valid JSON
    v.rawTransform(({ dataset, addIssue, NEVER }) => {
      try {
        return JSON.parse(dataset.value || '{}');
      } catch (e) {
        addIssue({
          message: e instanceof Error ? e.message : String(e),
          path: [{
            type: 'unknown',
            origin: 'value',
            input: dataset.value,
            key: 'root',
            value: dataset.value
          }]
        });
        return NEVER;
      }
    }),
    // make sure it has the right structure
    v.strictObject({
      environment: v.optional(recordSchema),
      'build:environment': v.optional(recordSchema),
      'publish:environment': v.optional(recordSchema),
      'build:targets': v.optional(v.string()),
      'publish:targets': v.optional(v.string())
    }),
    // transform it back into a string (huzzah!)
    v.transform((o) => JSON.stringify(o, null, 4))
  )
);
```
*Validation and transformation schema for product properties JSON.*

---

#### File: `portal/src/lib/projects/server.ts`  
Lines 133-172 (repeated for context):
*See above snippet for `doProjectAction`.*

---

#### File: `portal/src/routes/(authenticated)/projects/[filter=projectSelector]/[id]/+page.server.ts`  
Lines 114-209:
```ts
export const actions: Actions = {
  page: async function ({ params, request, locals }) {
    const userId = (await locals.auth())?.user.userId;
    if (!userId) return error(400);

    const form = await superValidate(request, valibot(projectSearchSchema));
    if (!form.valid) return fail(400, { form, ok: false });

    const where: Prisma.ProjectsWhereInput = {
      ...projectFilter(form.data),
      ...whereStatements(params.filter!, parseInt(params.id!), userId)
    };

    const projects = await prisma.projects.findMany({
      where: where,
      include: {
        Products: {
          include: {
            ProductDefinition: true,
            WorkflowInstance: true
          }
        },
        Owner: true,
        Group: true,
        Organization: true
      },
      skip: form.data.page.size * form.data.page.page,
      take: form.data.page.size
    });

    const count = await prisma.projects.count({ where: where });

    return { form, ok: true, query: { data: pruneProjects(projects), count } };
  },
  projectAction: async (event) => {
    const session = await event.locals.auth();
    if (!session) return fail(403);
    const orgId = parseInt(event.params.id!);
    if (
      isNaN(orgId) ||
      !(orgId + '' === event.params.id) ||
      !(await prisma.organizations.findFirst({ where: { Id: orgId } }))
    ) {
      return fail(404);
    }

    const form = await superValidate(event.request, valibot(bulkProjectActionSchema));
    if (
      !form.valid ||
      !form.data.operation ||
      (!form.data.projects?.length && form.data.projectId === null) ||
      orgId !== form.data.orgId
    )
      return fail(400, { form, ok: false });
    // prefer single project over array
    const projects = await prisma.projects.findMany({
      where: {
        Id: { in: form.data.projectId !== null ? [form.data.projectId] : form.data.projects }
      },
      select: {
        Id: true,
        DateArchived: true,
        OwnerId: true,
        GroupId: true
      }
    });
    if (!projects.every((p) => canModifyProject(session, p.OwnerId, orgId))) {
      return fail(403);
    }

    const groups =
      form.data.operation === 'claim'
        ? (await userGroupsForOrg(session.user.userId, orgId)).map((g) => g.GroupId)
        : [];
    await Promise.all(
      projects.map(async (project) => {
        await doProjectAction(form.data.operation, project, session, orgId, groups);
      })
    );

    return { form, ok: true };
  },
  productAction: async (event) => {
    const session = await event.locals.auth();
    if (!session) return fail(403);
    const orgId = parseInt(event.params.id!);
    if (isNaN(orgId) || !(orgId + '' === event.params.id)) return fail(404);

    const form = await superValidate(event.request, valibot(bulkProductActionSchema));
    if (!form.valid || !form.data.operation) return fail(400, { form, ok: false });

    await Promise.all(form.data.products.map((p) => doProductAction(p, form.data.operation!)));

    return { form, ok: true };
  }
};
```
*Shows bulk project and product actions handling, including validation, permission checks, and calling `doProjectAction` and `doProductAction`.*

---

#### File: `portal/node-server/index.ts`  
Lines 36-40:
```ts
async session({ session, token }) {
  // @ts-expect-error userId is not defined in source
  session.user.userId = token.userId;
  return session;
}
```
*Sets the userId on the session object during authentication.*

---

#### File: `portal/src/auth.ts`  
Lines 93-102:
```ts
async session({ session, token }) {
  session.user.userId = token.userId as number;
  const userRoles = await prisma.userRoles.findMany({
    where: {
      UserId: token.userId as number
    }
  });
  session.user.roles = userRoles.map((role) => [role.OrganizationId, role.RoleId]);
  return session;
}
```
*Populates the session with userId and roles from the database.*

---

### Summary

The main file `+page.server.ts` for the project page handles loading project data, including products, authors, reviewers, and related entities, with permission checks. It defines multiple actions for modifying the project and its products, such as adding/removing authors and reviewers, adding products, updating product properties, and performing project or product actions (archive, reactivate, claim, rebuild, republish, cancel).

The relevant validation schemas and permission checks are defined in `portal/src/lib/projects/index.ts` and `portal/src/lib/projects/server.ts`. The actual execution of project and product actions is handled by `doProjectAction` and `doProductAction` respectively.

Session management and user role loading are handled in `portal/node-server/index.ts` and `portal/src/auth.ts`.

Bulk project and product actions are handled in a related route `portal/src/routes/(authenticated)/projects/[filter=projectSelector]/[id]/+page.server.ts`, which uses similar logic for validation and action execution.

This context is relevant to understanding the project actions and possibly related issues such as #1127 mentioned in the user comment.

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

@FyreByrd I'll check issue #1127 first to see if it's related to this cross-project deletion vulnerability, then either comment there or create a new issue as appropriate.

---

🏁 Script executed:
```
#!/bin/bash
# Get information about issue #1127
gh issue view 1127 --repo sillsdev/appbuilder-portal
```

Length of output: 402

---

@FyreByrd I've reviewed issue #1127 and it does indeed cover the broader authorization problems. I'm adding a comment to that issue detailing the specific cross-project deletion vulnerability we found in this PR. The comment will include the affected functions and the suggested fix pattern.

---

🏁 Script executed:
```
#!/bin/bash
# Add a comment to issue #1127 about the cross-project deletion vulnerability
gh issue comment 1127 --repo sillsdev/appbuilder-portal --body "Found a specific instance of this authorization issue in PR #1135: The \`deleteProduct\` action in \`portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts\` deletes products by ID without verifying the product belongs to the current project. This allows users with access to multiple projects to delete products across projects.

Similar validation is missing in \`updateProduct\`, \`productAction\`, \`deleteAuthor\`, and \`deleteReviewer\` functions.

Suggested fix pattern:
\`\`\`typescript
// Before deleting/updating
const product = await prisma.products.findUnique({
  where: { Id: form.data.productId },
  select: { ProjectId: true }
});
if (!product || product.ProjectId !== parseInt(event.params.id))
  return fail(403);
\`\`\`"
```

Length of output: 886

---



</details>

@FyreByrd I've added a comment to issue #1127 as requested. You can find it here: [comment #2883955612](https://github.com/sillsdev/appbuilder-portal/issues/1127#issuecomment-2883955612)

The comment details the cross-project deletion vulnerability in the `deleteProduct` action and mentions that similar validation is missing in `updateProduct`, `productAction`, `deleteAuthor`, and `deleteReviewer` functions. I also included the suggested fix pattern for reference.



---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippets for understanding `portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts` and the user comment:

---

#### File: `portal/src/lib/projects/index.ts`  
Lines 142-147:
```ts
export const projectActionSchema = v.object({
  operation: v.nullable(v.picklist(['archive', 'reactivate', 'claim'])),
  // used to distinguish between single and bulk. will be null if bulk
  projectId: v.nullable(idSchema),
  orgId: idSchema
});
```
*Defines the validation schema for project actions, including operations like 'archive', 'reactivate', and 'claim'. This schema is used in the `projectAction` action in the main file.*

---

Lines 164-170:
```ts
export function canModifyProject(
  user: Session | null | undefined,
  projectOwnerId: number,
  organizationId: number
): boolean {
  return projectOwnerId === user?.user.userId || isAdminForOrg(organizationId, user?.user.roles);
}
```
*Function to check if a user can modify a project, either by ownership or admin role in the organization.*

---

#### File: `portal/src/lib/projects/server.ts`  
Lines 117-131:
```ts
export async function userGroupsForOrg(userId: number, orgId: number) {
  return prisma.groupMemberships.findMany({
    where: {
      UserId: userId,
      Group: {
        is: {
          OwnerId: orgId
        }
      }
    },
    select: {
      GroupId: true
    }
  });
}
```
*Fetches the groups a user belongs to within a specific organization.*

---

Lines 133-172:
```ts
export async function doProjectAction(
  operation: string | null,
  project: Omit<ProjectForAction, 'Name'>,
  session: Session,
  orgId: number,
  groups: number[]
) {
  if (operation === 'archive' && !project?.DateArchived) {
    await DatabaseWrites.projects.update(project.Id, {
      DateArchived: new Date()
    });
    await Queues.UserTasks.add(`Delete UserTasks for Archived Project #${project.Id}`, {
      type: BullMQ.JobType.UserTasks_Modify,
      scope: 'Project',
      projectId: project.Id,
      operation: {
        type: BullMQ.UserTasks.OpType.Delete
      }
    });
  } else if (operation === 'reactivate' && !!project?.DateArchived) {
    await DatabaseWrites.projects.update(project.Id, {
      DateArchived: null
    });
    await Queues.UserTasks.add(`Create UserTasks for Reactivated Project #${project.Id}`, {
      type: BullMQ.JobType.UserTasks_Modify,
      scope: 'Project',
      projectId: project.Id,
      operation: {
        type: BullMQ.UserTasks.OpType.Create
      }
    });
  } else if (
    operation === 'claim' &&
    canClaimProject(session, project?.OwnerId, orgId, project?.GroupId, groups)
  ) {
    await DatabaseWrites.projects.update(project.Id, {
      OwnerId: session.user.userId
    });
  }
}
```
*Performs the project action (archive, reactivate, claim) with appropriate database updates and queueing user task modifications.*

---

#### File: `portal/src/lib/products/index.ts`  
Lines 9-40:
```ts
export function getProductActions(
  product: {
    WorkflowInstance: {
      WorkflowDefinition: {
        Type: WorkflowType;
      };
    } | null;
    DatePublished: unknown;
    ProductDefinition: { RebuildWorkflowId: unknown; RepublishWorkflowId: unknown };
  },
  projectOwnerId: number,
  userId: number
) {
  const ret: ProductActionType[] = [];
  if (!product.WorkflowInstance) {
    if (product.DatePublished) {
      if (product.ProductDefinition.RebuildWorkflowId !== null) {
        ret.push(ProductActionType.Rebuild);
      }
      if (product.ProductDefinition.RepublishWorkflowId !== null) {
        ret.push(ProductActionType.Republish);
      }
    }
  } else if (
    projectOwnerId === userId &&
    product.WorkflowInstance.WorkflowDefinition.Type !== WorkflowType.Startup
  ) {
    ret.push(ProductActionType.Cancel);
  }

  return ret;
}
```
*Determines the possible actions for a product based on its workflow state and user permissions.*

---

#### File: `portal/src/lib/products/server.ts`  
Lines 5-86:
```ts
export async function doProductAction(productId: string, action: ProductActionType) {
  const product = await prisma.products.findUnique({
    where: {
      Id: productId
    },
    select: {
      Id: true,
      ProjectId: true,
      ProductDefinition: {
        select: {
          RebuildWorkflow: {
            select: {
              Type: true,
              ProductType: true,
              WorkflowOptions: true
            }
          },
          RepublishWorkflow: {
            select: {
              Type: true,
              ProductType: true,
              WorkflowOptions: true
            }
          }
        }
      },
      WorkflowInstance: {
        select: {
          WorkflowDefinition: {
            select: { Type: true }
          }
        }
      }
    }
  });

  if (product) {
    switch (action) {
    case ProductActionType.Rebuild:
    case ProductActionType.Republish: {
      const flowType = action === 'rebuild' ? 'RebuildWorkflow' : 'RepublishWorkflow';
      if (product.ProductDefinition[flowType] && !product.WorkflowInstance) {
        await Workflow.create(productId, {
          productType: product.ProductDefinition[flowType].ProductType,
          options: new Set(product.ProductDefinition[flowType].WorkflowOptions),
          workflowType: product.ProductDefinition[flowType].Type
        });
      }
      break;
    }
    case ProductActionType.Cancel:
      if (
        product.WorkflowInstance?.WorkflowDefinition &&
          product.WorkflowInstance.WorkflowDefinition.Type !== WorkflowType.Startup
      ) {
        await Queues.UserTasks.add(
          `Delete UserTasks for canceled workflow (product #${productId})`,
          {
            type: BullMQ.JobType.UserTasks_Modify,
            scope: 'Product',
            productId,
            operation: {
              type: BullMQ.UserTasks.OpType.Delete
            }
          }
        );
        await DatabaseWrites.productTransitions.create({
          data: {
            ProductId: productId,
            // This is how S1 does it. May want to change later
            AllowedUserNames: '',
            DateTransition: new Date(),
            TransitionType: ProductTransitionType.CancelWorkflow,
            WorkflowType: product.WorkflowInstance.WorkflowDefinition.Type
          }
        });
        await DatabaseWrites.workflowInstances.delete(productId, product.ProjectId);
      }
      break;
    }
  }
}
```
*Executes the product action such as rebuild, republish, or cancel, including workflow creation and cleanup.*

---

#### File: `portal/src/lib/valibot.ts`  
Lines 3-6:
```ts
export const idSchema = v.pipe(v.number(), v.minValue(0), v.integer());
export const stringIdSchema = v.pipe(v.string(), v.uuid());
```
*Defines validation schemas for numeric IDs and string UUIDs used in form validation.*

Lines 8-10:
```ts
export const deleteSchema = v.object({
  id: idSchema
});
```
*Validation schema for delete operations requiring an ID.*

Lines 21-53:
```ts
export const propertiesSchema = v.nullable(
  v.pipe(
    v.string(),
    // make sure it is valid JSON
    v.rawTransform(({ dataset, addIssue, NEVER }) => {
      try {
        return JSON.parse(dataset.value || '{}');
      } catch (e) {
        addIssue({
          message: e instanceof Error ? e.message : String(e),
          path: [{
            type: 'unknown',
            origin: 'value',
            input: dataset.value,
            key: 'root',
            value: dataset.value
          }]
        });
        return NEVER;
      }
    }),
    // make sure it has the right structure
    v.strictObject({
      environment: v.optional(recordSchema),
      'build:environment': v.optional(recordSchema),
      'publish:environment': v.optional(recordSchema),
      'build:targets': v.optional(v.string()),
      'publish:targets': v.optional(v.string())
    }),
    // transform it back into a string (huzzah!)
    v.transform((o) => JSON.stringify(o, null, 4))
  )
);
```
*Validation and transformation schema for product properties JSON.*

---

#### File: `portal/src/lib/projects/server.ts`  
Lines 133-172 (repeated for context):
*See above snippet for `doProjectAction`.*

---

#### File: `portal/src/routes/(authenticated)/projects/[filter=projectSelector]/[id]/+page.server.ts`  
Lines 114-209:
```ts
export const actions: Actions = {
  page: async function ({ params, request, locals }) {
    const userId = (await locals.auth())?.user.userId;
    if (!userId) return error(400);

    const form = await superValidate(request, valibot(projectSearchSchema));
    if (!form.valid) return fail(400, { form, ok: false });

    const where: Prisma.ProjectsWhereInput = {
      ...projectFilter(form.data),
      ...whereStatements(params.filter!, parseInt(params.id!), userId)
    };

    const projects = await prisma.projects.findMany({
      where: where,
      include: {
        Products: {
          include: {
            ProductDefinition: true,
            WorkflowInstance: true
          }
        },
        Owner: true,
        Group: true,
        Organization: true
      },
      skip: form.data.page.size * form.data.page.page,
      take: form.data.page.size
    });

    const count = await prisma.projects.count({ where: where });

    return { form, ok: true, query: { data: pruneProjects(projects), count } };
  },
  projectAction: async (event) => {
    const session = await event.locals.auth();
    if (!session) return fail(403);
    const orgId = parseInt(event.params.id!);
    if (
      isNaN(orgId) ||
      !(orgId + '' === event.params.id) ||
      !(await prisma.organizations.findFirst({ where: { Id: orgId } }))
    ) {
      return fail(404);
    }

    const form = await superValidate(event.request, valibot(bulkProjectActionSchema));
    if (
      !form.valid ||
      !form.data.operation ||
      (!form.data.projects?.length && form.data.projectId === null) ||
      orgId !== form.data.orgId
    )
      return fail(400, { form, ok: false });
    // prefer single project over array
    const projects = await prisma.projects.findMany({
      where: {
        Id: { in: form.data.projectId !== null ? [form.data.projectId] : form.data.projects }
      },
      select: {
        Id: true,
        DateArchived: true,
        OwnerId: true,
        GroupId: true
      }
    });
    if (!projects.every((p) => canModifyProject(session, p.OwnerId, orgId))) {
      return fail(403);
    }

    const groups =
      form.data.operation === 'claim'
        ? (await userGroupsForOrg(session.user.userId, orgId)).map((g) => g.GroupId)
        : [];
    await Promise.all(
      projects.map(async (project) => {
        await doProjectAction(form.data.operation, project, session, orgId, groups);
      })
    );

    return { form, ok: true };
  },
  productAction: async (event) => {
    const session = await event.locals.auth();
    if (!session) return fail(403);
    const orgId = parseInt(event.params.id!);
    if (isNaN(orgId) || !(orgId + '' === event.params.id)) return fail(404);

    const form = await superValidate(event.request, valibot(bulkProductActionSchema));
    if (!form.valid || !form.data.operation) return fail(400, { form, ok: false });

    await Promise.all(form.data.products.map((p) => doProductAction(p, form.data.operation!)));

    return { form, ok: true };
  }
};
```
*Shows bulk project and product actions handling, including validation, permission checks, and calling `doProjectAction` and `doProductAction`.*

---

#### File: `portal/node-server/index.ts`  
Lines 36-40:
```ts
async session({ session, token }) {
  // @ts-expect-error userId is not defined in source
  session.user.userId = token.userId;
  return session;
}
```
*Sets the userId on the session object during authentication.*

---

#### File: `portal/src/auth.ts`  
Lines 93-102:
```ts
async session({ session, token }) {
  session.user.userId = token.userId as number;
  const userRoles = await prisma.userRoles.findMany({
    where: {
      UserId: token.userId as number
    }
  });
  session.user.roles = userRoles.map((role) => [role.OrganizationId, role.RoleId]);
  return session;
}
```
*Populates the session with userId and roles from the database.*

---

### Summary

The main file `+page.server.ts` for the project page handles loading project data, including products, authors, reviewers, and related entities, with permission checks. It defines multiple actions for modifying the project and its products, such as adding/removing authors and reviewers, adding products, updating product properties, and performing project or product actions (archive, reactivate, claim, rebuild, republish, cancel).

The relevant validation schemas and permission checks are defined in `portal/src/lib/projects/index.ts` and `portal/src/lib/projects/server.ts`. The actual execution of project and product actions is handled by `doProjectAction` and `doProductAction` respectively.

Session management and user role loading are handled in `portal/node-server/index.ts` and `portal/src/auth.ts`.

Bulk project and product actions are handled in a related route `portal/src/routes/(authenticated)/projects/[filter=projectSelector]/[id]/+page.server.ts`, which uses similar logic for validation and action execution.

This context is relevant to understanding the project actions and possibly related issues such as #1127 mentioned in the user comment.

</details></answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-15T14:09:37Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-05-14T18:18:24Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are disabled on base/target branches other than the default branch.
> 
> 
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

The changes introduce centralized health checks for Redis and database connectivity, refactor queue and worker connection management, and enhance error handling and user feedback for connectivity issues. Error pages and toast notifications are improved, and localization is expanded. Permission checks are streamlined, and new enums and return types clarify status reporting across authentication and project management logic.

## Changes

| File(s)                                                                                                         | Change Summary |
|-----------------------------------------------------------------------------------------------------------------|----------------|
| portal/common/bullmq/queues.ts, portal/node-server/BullWorker.ts                                                | Refactored Redis connection management for BullMQ queues and workers; introduced a shared connection object with health checks and status reporting. |
| portal/node-server/dev.ts, portal/node-server/index.ts                                                          | Filtered Bull Board queue setup to include only valid Queue instances, preventing errors from non-queue values. |
| portal/common/databaseProxy/GroupMemberships.ts, portal/common/databaseProxy/Groups.ts                          | Added clarifying comments and JSDoc regarding group/project ownership checks and group deletion behavior. |
| portal/src/auth.ts, portal/src/lib/projects/server.ts, portal/src/lib/utils/index.ts                            | Refactored route and project permission checks to use a new `ServerStatus` enum for clearer status reporting. |
| portal/src/hooks.server.ts, portal/src/routes/(unauthenticated)/(auth)/login/+page.server.ts                    | Added a heartbeat middleware for health checks (database and queues); updated login logic to check queue connectivity before redirecting. |
| portal/src/lib/locales/en-US.json, portal/src/lib/locales/es-419.json, portal/src/lib/locales/fr-FR.json        | Added new localized strings for group/project ownership errors and application unavailability. |
| portal/src/lib/projects/index.ts                                                                                | Explicitly typed `canModifyProject` return value as boolean. |
| portal/src/routes/(authenticated)/+layout.svelte, portal/src/routes/+layout.svelte                              | Moved SvelteToast component from authenticated layout to global layout for consistent toast notifications. |
| portal/src/routes/(authenticated)/admin/settings/organizations/edit/+page.server.ts                             | Removed unused query for organization stores in organization edit logic. |
| portal/src/routes/(authenticated)/organizations/[id]/settings/groups/+page.svelte, portal/src/routes/(authenticated)/users/[id=idNumber]/settings/groups/+page.svelte | Enhanced form error handling for group/user removal, showing specific toasts for ownership-related failures. |
| portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts, portal/src/routes/(authenticated)/projects/[id=idNumber]/edit/+page.server.ts | Centralized/removed explicit permission checks, relying on upstream auth; used `findUniqueOrThrow` for project queries. |
| portal/src/routes/(unauthenticated)/error/+page.server.ts                                                       | Added error page server load logic to handle error codes from URL and redirect as needed. |
| portal/src/routes/+error.svelte                                                                                 | Enhanced error page with localized, status-specific messages and navigation, especially for 403, 404, and 503 errors. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant SvelteKitServer
    participant PrismaDB
    participant BullMQQueues

    User->>SvelteKitServer: Any request (except login/error)
    SvelteKitServer->>PrismaDB: Query userTasks (heartbeat)
    alt DB error or not connected
        SvelteKitServer->>User: Redirect to /error?code=503
    else DB ok
        SvelteKitServer->>BullMQQueues: Queues.connected()
        alt Queues not connected
            SvelteKitServer->>User: Redirect to /error?code=503
        else Queues connected
            SvelteKitServer->>User: Proceed to next handler
        end
    end
```

```mermaid
sequenceDiagram
    participant User
    participant SvelteKitServer
    participant BullMQQueues

    User->>SvelteKitServer: Visit /login
    SvelteKitServer->>BullMQQueues: Queues.connected()
    alt Queues connected and user authenticated
        SvelteKitServer->>User: Redirect to /tasks or /invitation
    else
        SvelteKitServer->>User: Show login page
    end
```

## Assessment against linked issues

| Objective                                                                                   | Addressed | Explanation |
|---------------------------------------------------------------------------------------------|-----------|-------------|
| Frontend: Show 503 error page when database or Redis (queues) are down (#1114)              | ✅        |             |
| Backend: Consider Redis connectivity for task/worker management (#1114)                     | ✅        |             |
| Provide user-facing feedback for application unavailability (#1114)                         | ✅        |             |
| Add localized error messages for group/project/user constraints (#1114)                      | ✅        |             |

## Poem

> A rabbit hops through fields of code,  
> Ensuring queues and databases load.  
> If servers falter, don’t you fear—  
> A friendly 503 will soon appear!  
> With toasts and translations, all in place,  
> The portal’s heart beats on apace.  
> 🐇✨

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=sillsdev/appbuilder-portal&utm_content=1135):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: FyreByrd
created_at: 2025-05-15T13:44:42Z
body: @coderabbitai review
author_association: CONTRIBUTOR
user: login: coderabbitai[bot]
created_at: 2025-05-15T13:44:48Z
body: <!-- This is an auto-generated reply by CodeRabbit -->
<details>
<summary>✅ Actions performed</summary>

Review triggered.

> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

</details>
author_association: CONTRIBUTOR
user: login: 7dev7urandom
created_at: 2025-05-17T16:38:45Z
body: Possibly worth putting in a different PR, but I'd like to reuse BullMQ connections where possible. 
https://docs.bullmq.io/bull/patterns/reusing-redis-connections
Looks good to me. 
author_association: CONTRIBUTOR
user: login: FyreByrd
created_at: 2025-05-19T12:41:03Z
body: > Possibly worth putting in a different PR, but I'd like to reuse BullMQ connections where possible. 

I think this would be better in a separate PR, but I will definitely do it.


author_association: CONTRIBUTOR
user: login: chrisvire
created_at: 2025-05-23T08:41:39Z
body: if I shutdown the database or redis while the svelte app is running, I get this message (which is what I assume I should get).

<img width="625" alt="image" src="https://github.com/user-attachments/assets/b4715a1b-f91c-471f-b38d-5e73442e79ce" />

This is probably fine for the first release of S2.. But what if redis is just down.  What could the user still do?  They could still interact with the frontend and look at data (retrieved from the DB).  They might not be able to do an action on a workflow or start a new workflow. How could we still allow the system to be mostly running without degrading service 100%?
author_association: MEMBER