From e745446b6b8d2a15c1d88a891932c9c4e807dfd1 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 5 Mar 2026 21:37:35 +0100 Subject: [PATCH 1/3] invalidate users when roles change --- .../common/src/services/access/permissions.ts | 47 +++++++++++++++++++ .../src/services/decision/decisionRoles.ts | 22 +++++++++ .../services/profile/updateProfileUserRole.ts | 6 +++ 3 files changed, 75 insertions(+) diff --git a/packages/common/src/services/access/permissions.ts b/packages/common/src/services/access/permissions.ts index 7dfe107aa..ddd5bdf95 100644 --- a/packages/common/src/services/access/permissions.ts +++ b/packages/common/src/services/access/permissions.ts @@ -1,3 +1,4 @@ +import { invalidateMultiple } from '@op/cache'; import { db } from '@op/db/client'; import { accessRolePermissionsOnAccessZones, @@ -10,6 +11,28 @@ import { and, eq } from 'drizzle-orm'; import { CommonError, NotFoundError } from '../../utils'; import { assertProfileAdmin } from '../assert'; +async function invalidateProfileUserCacheForRole(roleId: string) { + const joinRows = await db.query.profileUserToAccessRoles.findMany({ + where: { accessRoleId: roleId }, + }); + + if (joinRows.length === 0) { + return; + } + + const profileUserIds = joinRows.map((r) => r.profileUserId); + const profileUsers = await db.query.profileUsers.findMany({ + where: { id: { in: profileUserIds } }, + }); + + if (profileUsers.length > 0) { + await invalidateMultiple({ + type: 'profileUser', + paramsList: profileUsers.map((pu) => [pu.profileId, pu.authUserId]), + }); + } +} + export type Permissions = { admin: boolean; create: boolean; @@ -152,6 +175,8 @@ export async function updateRolePermissions({ }); } + await invalidateProfileUserCacheForRole(roleId); + return role; } @@ -180,9 +205,31 @@ export async function deleteRole({ await assertProfileAdmin(user, role.profileId); + // Query affected users before deleting (cascade will remove the join rows) + const joinRows = await db.query.profileUserToAccessRoles.findMany({ + where: { accessRoleId: roleId }, + }); + const profileUserIds = joinRows.map((r) => r.profileUserId); + const affectedProfileUsers = + profileUserIds.length > 0 + ? await db.query.profileUsers.findMany({ + where: { id: { in: profileUserIds } }, + }) + : []; + // Delete the role (cascade will handle permissions) await db.delete(accessRoles).where(eq(accessRoles.id, roleId)); + if (affectedProfileUsers.length > 0) { + await invalidateMultiple({ + type: 'profileUser', + paramsList: affectedProfileUsers.map((pu) => [ + pu.profileId, + pu.authUserId, + ]), + }); + } + return { success: true, deletedId: roleId }; } diff --git a/packages/common/src/services/decision/decisionRoles.ts b/packages/common/src/services/decision/decisionRoles.ts index 51f837ba1..7639de10e 100644 --- a/packages/common/src/services/decision/decisionRoles.ts +++ b/packages/common/src/services/decision/decisionRoles.ts @@ -1,3 +1,4 @@ +import { invalidateMultiple } from '@op/cache'; import { type TransactionType, db } from '@op/db/client'; import { accessRolePermissionsOnAccessZones, accessRoles } from '@op/db/schema'; import { permission, toBitField } from 'access-zones'; @@ -191,5 +192,26 @@ export async function updateDecisionRoles({ }); } + const joinRows = await db.query.profileUserToAccessRoles.findMany({ + where: { accessRoleId: roleId }, + }); + + if (joinRows.length > 0) { + const profileUserIds = joinRows.map((r) => r.profileUserId); + const affectedProfileUsers = await db.query.profileUsers.findMany({ + where: { id: { in: profileUserIds } }, + }); + + if (affectedProfileUsers.length > 0) { + await invalidateMultiple({ + type: 'profileUser', + paramsList: affectedProfileUsers.map((pu) => [ + pu.profileId, + pu.authUserId, + ]), + }); + } + } + return { roleId, decisionPermissions }; } diff --git a/packages/common/src/services/profile/updateProfileUserRole.ts b/packages/common/src/services/profile/updateProfileUserRole.ts index e487d234b..b41308ce0 100644 --- a/packages/common/src/services/profile/updateProfileUserRole.ts +++ b/packages/common/src/services/profile/updateProfileUserRole.ts @@ -1,3 +1,4 @@ +import { invalidate } from '@op/cache'; import { and, db, eq, inArray } from '@op/db/client'; import { profileUserToAccessRoles } from '@op/db/schema'; import type { User } from '@op/supabase/lib'; @@ -102,6 +103,11 @@ export const updateProfileUserRoles = async ({ }); } + await invalidate({ + type: 'profileUser', + params: [targetProfileId, targetProfileUser.authUserId], + }); + // Fetch and return the updated profile user with full relations const updatedProfileUser = await getProfileUserWithRelations(profileUserId); if (!updatedProfileUser) { From 47aeb3b9561d5e65e0fa61b8055ce34aee387927 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 5 Mar 2026 21:41:23 +0100 Subject: [PATCH 2/3] Avoid double calls --- .../common/src/services/access/permissions.ts | 25 +++---------------- .../src/services/decision/decisionRoles.ts | 23 ++--------------- 2 files changed, 5 insertions(+), 43 deletions(-) diff --git a/packages/common/src/services/access/permissions.ts b/packages/common/src/services/access/permissions.ts index ddd5bdf95..8f82562d5 100644 --- a/packages/common/src/services/access/permissions.ts +++ b/packages/common/src/services/access/permissions.ts @@ -11,7 +11,7 @@ import { and, eq } from 'drizzle-orm'; import { CommonError, NotFoundError } from '../../utils'; import { assertProfileAdmin } from '../assert'; -async function invalidateProfileUserCacheForRole(roleId: string) { +export async function invalidateProfileUserCacheForRole(roleId: string) { const joinRows = await db.query.profileUserToAccessRoles.findMany({ where: { accessRoleId: roleId }, }); @@ -205,31 +205,12 @@ export async function deleteRole({ await assertProfileAdmin(user, role.profileId); - // Query affected users before deleting (cascade will remove the join rows) - const joinRows = await db.query.profileUserToAccessRoles.findMany({ - where: { accessRoleId: roleId }, - }); - const profileUserIds = joinRows.map((r) => r.profileUserId); - const affectedProfileUsers = - profileUserIds.length > 0 - ? await db.query.profileUsers.findMany({ - where: { id: { in: profileUserIds } }, - }) - : []; + // Invalidate before delete (cascade will remove the join rows we query) + await invalidateProfileUserCacheForRole(roleId); // Delete the role (cascade will handle permissions) await db.delete(accessRoles).where(eq(accessRoles.id, roleId)); - if (affectedProfileUsers.length > 0) { - await invalidateMultiple({ - type: 'profileUser', - paramsList: affectedProfileUsers.map((pu) => [ - pu.profileId, - pu.authUserId, - ]), - }); - } - return { success: true, deletedId: roleId }; } diff --git a/packages/common/src/services/decision/decisionRoles.ts b/packages/common/src/services/decision/decisionRoles.ts index 7639de10e..a4e93edc1 100644 --- a/packages/common/src/services/decision/decisionRoles.ts +++ b/packages/common/src/services/decision/decisionRoles.ts @@ -1,10 +1,10 @@ -import { invalidateMultiple } from '@op/cache'; import { type TransactionType, db } from '@op/db/client'; import { accessRolePermissionsOnAccessZones, accessRoles } from '@op/db/schema'; import { permission, toBitField } from 'access-zones'; import { eq } from 'drizzle-orm'; import { CommonError, NotFoundError } from '../../utils'; +import { invalidateProfileUserCacheForRole } from '../access/permissions'; import { assertProfileAdmin } from '../assert'; import { type DecisionRolePermissions, @@ -192,26 +192,7 @@ export async function updateDecisionRoles({ }); } - const joinRows = await db.query.profileUserToAccessRoles.findMany({ - where: { accessRoleId: roleId }, - }); - - if (joinRows.length > 0) { - const profileUserIds = joinRows.map((r) => r.profileUserId); - const affectedProfileUsers = await db.query.profileUsers.findMany({ - where: { id: { in: profileUserIds } }, - }); - - if (affectedProfileUsers.length > 0) { - await invalidateMultiple({ - type: 'profileUser', - paramsList: affectedProfileUsers.map((pu) => [ - pu.profileId, - pu.authUserId, - ]), - }); - } - } + await invalidateProfileUserCacheForRole(roleId); return { roleId, decisionPermissions }; } From e0eb2b9348a081344c3ccdbcb579231791527301 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 5 Mar 2026 21:59:13 +0100 Subject: [PATCH 3/3] Single query --- .../common/src/services/access/permissions.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/common/src/services/access/permissions.ts b/packages/common/src/services/access/permissions.ts index 8f82562d5..7083aa0fb 100644 --- a/packages/common/src/services/access/permissions.ts +++ b/packages/common/src/services/access/permissions.ts @@ -4,6 +4,8 @@ import { accessRolePermissionsOnAccessZones, accessRoles, organizationUserToAccessRoles, + profileUserToAccessRoles, + profileUsers, } from '@op/db/schema'; import { permission, toBitField } from 'access-zones'; import { and, eq } from 'drizzle-orm'; @@ -12,23 +14,22 @@ import { CommonError, NotFoundError } from '../../utils'; import { assertProfileAdmin } from '../assert'; export async function invalidateProfileUserCacheForRole(roleId: string) { - const joinRows = await db.query.profileUserToAccessRoles.findMany({ - where: { accessRoleId: roleId }, - }); - - if (joinRows.length === 0) { - return; - } - - const profileUserIds = joinRows.map((r) => r.profileUserId); - const profileUsers = await db.query.profileUsers.findMany({ - where: { id: { in: profileUserIds } }, - }); - - if (profileUsers.length > 0) { + const affectedUsers = await db + .select({ + profileId: profileUsers.profileId, + authUserId: profileUsers.authUserId, + }) + .from(profileUserToAccessRoles) + .innerJoin( + profileUsers, + eq(profileUserToAccessRoles.profileUserId, profileUsers.id), + ) + .where(eq(profileUserToAccessRoles.accessRoleId, roleId)); + + if (affectedUsers.length > 0) { await invalidateMultiple({ type: 'profileUser', - paramsList: profileUsers.map((pu) => [pu.profileId, pu.authUserId]), + paramsList: affectedUsers.map((u) => [u.profileId, u.authUserId]), }); } }