From 31b908dd44d15030175d305d37514e5bed538538 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 13:08:02 +0100 Subject: [PATCH 01/24] feat: [US-002] - Suppress email sending for invites to draft processes --- .../services/profile/inviteUsersToProfile.ts | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/common/src/services/profile/inviteUsersToProfile.ts b/packages/common/src/services/profile/inviteUsersToProfile.ts index bd4912a2e..0448c91bc 100644 --- a/packages/common/src/services/profile/inviteUsersToProfile.ts +++ b/packages/common/src/services/profile/inviteUsersToProfile.ts @@ -1,6 +1,6 @@ import { OPURLConfig } from '@op/core'; import { db } from '@op/db/client'; -import { allowList, profileInvites } from '@op/db/schema'; +import { allowList, ProcessStatus, profileInvites } from '@op/db/schema'; import { Events, event } from '@op/events'; import { User } from '@op/supabase/lib'; import { assertAccess, permission } from 'access-zones'; @@ -64,6 +64,7 @@ export const inviteUsersToProfile = async ({ existingPendingInvites, profileUser, proposalWithDecision, + processInstanceForProfile, ] = await Promise.all([ // Get the target profile details assertProfile(profileId), @@ -107,6 +108,10 @@ export const inviteUsersToProfile = async ({ }, }, }), + // Check if this profile belongs directly to a process instance (decision-level invites) + db.query.processInstances.findFirst({ + where: { profileId }, + }), ]); if (!profileUser) { @@ -255,23 +260,39 @@ export const inviteUsersToProfile = async ({ }); } + // Determine if emails should be sent immediately or queued. + // Invites to draft processes are queued (notified=false) until publish. + // Check both proposal-level (via proposal -> processInstance) and + // decision-level (direct processInstance profile) relationships. + const processInstanceStatus = + proposalWithDecision?.processInstance?.status ?? + processInstanceForProfile?.status; + const shouldNotify = processInstanceStatus !== ProcessStatus.DRAFT; + // Batch insert and send event in a single transaction // If event.send fails, we rollback the DB inserts if (profileInviteEntries.length > 0) { + const inviteEntries = profileInviteEntries.map((entry) => ({ + ...entry, + notified: shouldNotify, + })); + await db.transaction(async (tx) => { if (allowListEntries.length > 0) { await tx.insert(allowList).values(allowListEntries); } - await tx.insert(profileInvites).values(profileInviteEntries); - - // Send event inside transaction - failure rolls back DB changes - await event.send({ - name: Events.profileInviteSent.name, - data: { - senderProfileId: requesterProfileId, - invitations: emailsToInvite, - }, - }); + await tx.insert(profileInvites).values(inviteEntries); + + // Only send email event if the process is not in draft + if (shouldNotify) { + await event.send({ + name: Events.profileInviteSent.name, + data: { + senderProfileId: requesterProfileId, + invitations: emailsToInvite, + }, + }); + } }); // Mark all as successful since transaction completed From c874b7467c284e33ed95ba8713ceedf968257c52 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 13:12:07 +0100 Subject: [PATCH 02/24] feat: [US-003] - Send queued invite emails on process publish --- .../decision/updateDecisionInstance.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/common/src/services/decision/updateDecisionInstance.ts b/packages/common/src/services/decision/updateDecisionInstance.ts index e0aba7f10..f28e718ec 100644 --- a/packages/common/src/services/decision/updateDecisionInstance.ts +++ b/packages/common/src/services/decision/updateDecisionInstance.ts @@ -1,10 +1,13 @@ +import { OPURLConfig } from '@op/core'; import { db, eq } from '@op/db/client'; import { ProcessStatus, decisionProcessTransitions, processInstances, + profileInvites, profiles, } from '@op/db/schema'; +import { Events, event } from '@op/events'; import type { User } from '@op/supabase/lib'; import { assertAccess, permission } from 'access-zones'; @@ -227,6 +230,59 @@ export const updateDecisionInstance = async ({ } }); + // When publishing, send queued invite emails for this process instance's profile + const isPublishing = + status === ProcessStatus.PUBLISHED && + existingInstance.status !== ProcessStatus.PUBLISHED; + + if (isPublishing) { + const queuedInvites = await db.query.profileInvites.findMany({ + where: { + profileId, + notified: false, + }, + with: { + profile: true, + inviter: true, + }, + }); + + if (queuedInvites.length > 0) { + const baseUrl = OPURLConfig('APP').ENV_URL; + const decisionProfile = await db.query.profiles.findFirst({ + where: { id: profileId }, + }); + const decisionSlug = decisionProfile?.slug; + + const invitations = queuedInvites.map((invite) => ({ + email: invite.email, + inviterName: invite.inviter.name || 'A team member', + profileName: invite.profile.name, + inviteUrl: decisionSlug + ? `${baseUrl}/decisions/${decisionSlug}` + : baseUrl, + personalMessage: invite.message ?? undefined, + })); + + // Use the first invite's inviter as the sender + const senderProfileId = queuedInvites[0]!.invitedBy; + + await event.send({ + name: Events.profileInviteSent.name, + data: { + senderProfileId, + invitations, + }, + }); + + // Mark all queued invites as notified + await db + .update(profileInvites) + .set({ notified: true }) + .where(eq(profileInvites.profileId, profileId)); + } + } + // Fetch the profile with processInstance joined for the response const profile = await db._query.profiles.findFirst({ where: eq(profiles.id, profileId), From 8356de5531abcf907b1faa2ee51bb4702ecb958f Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 13:14:10 +0100 Subject: [PATCH 03/24] feat: [US-004] - Update Inngest workflow to mark invites as notified --- .../decision/updateDecisionInstance.ts | 1 + .../services/profile/inviteUsersToProfile.ts | 6 +++++- services/events/src/types.ts | 1 + .../notifications/sendProfileInviteEmails.ts | 18 +++++++++++++++--- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/common/src/services/decision/updateDecisionInstance.ts b/packages/common/src/services/decision/updateDecisionInstance.ts index f28e718ec..2d0e4a951 100644 --- a/packages/common/src/services/decision/updateDecisionInstance.ts +++ b/packages/common/src/services/decision/updateDecisionInstance.ts @@ -271,6 +271,7 @@ export const updateDecisionInstance = async ({ name: Events.profileInviteSent.name, data: { senderProfileId, + inviteIds: queuedInvites.map((inv) => inv.id), invitations, }, }); diff --git a/packages/common/src/services/profile/inviteUsersToProfile.ts b/packages/common/src/services/profile/inviteUsersToProfile.ts index 0448c91bc..1c2213ee6 100644 --- a/packages/common/src/services/profile/inviteUsersToProfile.ts +++ b/packages/common/src/services/profile/inviteUsersToProfile.ts @@ -281,7 +281,10 @@ export const inviteUsersToProfile = async ({ if (allowListEntries.length > 0) { await tx.insert(allowList).values(allowListEntries); } - await tx.insert(profileInvites).values(inviteEntries); + const insertedInvites = await tx + .insert(profileInvites) + .values(inviteEntries) + .returning({ id: profileInvites.id }); // Only send email event if the process is not in draft if (shouldNotify) { @@ -289,6 +292,7 @@ export const inviteUsersToProfile = async ({ name: Events.profileInviteSent.name, data: { senderProfileId: requesterProfileId, + inviteIds: insertedInvites.map((inv) => inv.id), invitations: emailsToInvite, }, }); diff --git a/services/events/src/types.ts b/services/events/src/types.ts index ef60fd608..cf81106e9 100644 --- a/services/events/src/types.ts +++ b/services/events/src/types.ts @@ -31,6 +31,7 @@ export const Events = { name: 'profile/invites-sent' as const, schema: z.object({ senderProfileId: z.string(), + inviteIds: z.array(z.string()).optional(), invitations: z.array( z.object({ email: z.string().email(), diff --git a/services/workflows/src/functions/notifications/sendProfileInviteEmails.ts b/services/workflows/src/functions/notifications/sendProfileInviteEmails.ts index 5121b7326..35c9fd929 100644 --- a/services/workflows/src/functions/notifications/sendProfileInviteEmails.ts +++ b/services/workflows/src/functions/notifications/sendProfileInviteEmails.ts @@ -1,5 +1,8 @@ +import { db } from '@op/db/client'; +import { profileInvites } from '@op/db/schema'; import { OPBatchSend, OPInvitationEmail } from '@op/emails'; import { Events, inngest } from '@op/events'; +import { inArray } from 'drizzle-orm'; const { profileInviteSent } = Events; @@ -9,9 +12,8 @@ export const sendProfileInviteEmails = inngest.createFunction( }, { event: profileInviteSent.name }, async ({ event, step }) => { - const { invitations, senderProfileId } = profileInviteSent.schema.parse( - event.data, - ); + const { invitations, senderProfileId, inviteIds } = + profileInviteSent.schema.parse(event.data); const result = await step.run('send-profile-invite-emails', async () => { console.log( @@ -49,6 +51,16 @@ export const sendProfileInviteEmails = inngest.createFunction( }; }); + // Mark invites as notified after successful email delivery + if (inviteIds && inviteIds.length > 0) { + await step.run('mark-invites-notified', async () => { + await db + .update(profileInvites) + .set({ notified: true }) + .where(inArray(profileInvites.id, inviteIds)); + }); + } + return { message: `${result.sent} profile invite email(s) sent, ${result.failed} failed`, }; From 59b82296253145d1f1e69105599a0c71c926d720 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 13:14:53 +0100 Subject: [PATCH 04/24] feat: [US-005] - Hide unnotified invites from invitee personal invite list --- packages/common/src/services/profile/listUserInvites.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/common/src/services/profile/listUserInvites.ts b/packages/common/src/services/profile/listUserInvites.ts index 38b399e02..808626c66 100644 --- a/packages/common/src/services/profile/listUserInvites.ts +++ b/packages/common/src/services/profile/listUserInvites.ts @@ -22,6 +22,7 @@ export const listUserInvites = async ({ const invites = await db.query.profileInvites.findMany({ where: { email: { ilike: user.email }, + notified: true, ...(pending === true && { acceptedOn: { isNull: true } }), ...(pending === false && { acceptedOn: { isNotNull: true } }), ...(entityType && { profileEntityType: entityType }), From 67ab7a388f269b5dcf5376db3713b67359ddaf48 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 13:17:19 +0100 Subject: [PATCH 05/24] feat: [US-006] - Show notified status in admin participant list --- .../decisions/ProfileUsersAccessTable.tsx | 22 +++++++++++++++---- apps/app/src/lib/i18n/dictionaries/bn.json | 1 + apps/app/src/lib/i18n/dictionaries/en.json | 1 + apps/app/src/lib/i18n/dictionaries/es.json | 1 + apps/app/src/lib/i18n/dictionaries/fr.json | 1 + apps/app/src/lib/i18n/dictionaries/pt.json | 1 + services/api/src/encoders/profiles.ts | 1 + .../src/routers/profile/listProfileInvites.ts | 1 + .../routers/profile/updateProfileInvite.ts | 1 + 9 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index 326397b87..b96f27ee6 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -434,7 +434,14 @@ const MobileInviteCard = ({
{displayName} - {t('Invited')} +
+ {t('Invited')} + {!invite.notified && ( + + {t('Pending notification')} + + )} +
{invite.email} @@ -557,9 +564,16 @@ const ProfileUsersAccessTableContent = ({ {displayName} - - {t('Invited')} - +
+ + {t('Invited')} + + {!invite.notified && ( + + {t('Pending notification')} + + )} +
diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 97c06d395..e7a8f4a94 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -688,6 +688,7 @@ "Failed to remove user": "ব্যবহারকারী সরাতে ব্যর্থ", "No one has been invited yet": "এখনও কাউকে আমন্ত্রণ করা হয়নি", "Invited": "আমন্ত্রিত", + "Pending notification": "বিজ্ঞপ্তি মুলতুবি", "Failed to cancel invite": "আমন্ত্রণ বাতিল করতে ব্যর্থ", "Accepts PDF, DOCX, XLSX up to {size}MB": "PDF, DOCX, XLSX {size}MB পর্যন্ত গ্রহণ করে", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "এই প্রক্রিয়ায় প্রস্তাবগুলি যে বিভাগগুলি এগিয়ে নেওয়া উচিত তা সংজ্ঞায়িত করুন। প্রস্তাবকারীরা তাদের প্রস্তাব কোন বিভাগগুলি সমর্থন করে তা নির্বাচন করবেন।", diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 2cb64f097..290171e1f 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -682,6 +682,7 @@ "Failed to remove user": "Failed to remove user", "No one has been invited yet": "No one has been invited yet", "Invited": "Invited", + "Pending notification": "Pending notification", "Failed to cancel invite": "Failed to cancel invite", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.", "No categories defined yet": "No categories defined yet", diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index f4c562612..6d018bb63 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -681,6 +681,7 @@ "Failed to remove user": "Error al eliminar usuario", "No one has been invited yet": "Nadie ha sido invitado todavía", "Invited": "Invitado", + "Pending notification": "Notificación pendiente", "Failed to cancel invite": "Error al cancelar invitación", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Define las categorías que las propuestas en este proceso deben avanzar. Los proponentes seleccionarán qué categorías apoya su propuesta.", "No categories defined yet": "Aún no se han definido categorías", diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index bac51d4bd..b69d3e95d 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -681,6 +681,7 @@ "Failed to remove user": "Échec de la suppression de l'utilisateur", "No one has been invited yet": "Personne n'a encore été invité", "Invited": "Invité", + "Pending notification": "Notification en attente", "Failed to cancel invite": "Échec de l'annulation de l'invitation", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Définissez les catégories que les propositions de ce processus doivent faire avancer. Les proposants sélectionneront les catégories que leur proposition soutient.", "No categories defined yet": "Aucune catégorie définie pour le moment", diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 17eecaac3..3018d9f87 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -677,6 +677,7 @@ "Failed to remove user": "Falha ao remover usuário", "No one has been invited yet": "Ninguém foi convidado ainda", "Invited": "Convidado", + "Pending notification": "Notificação pendente", "Failed to cancel invite": "Falha ao cancelar convite", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Defina as categorias que as propostas neste processo devem avançar. Os proponentes selecionarão quais categorias sua proposta apoia.", "No categories defined yet": "Nenhuma categoria definida ainda", diff --git a/services/api/src/encoders/profiles.ts b/services/api/src/encoders/profiles.ts index 70b91e2cd..80bf80125 100644 --- a/services/api/src/encoders/profiles.ts +++ b/services/api/src/encoders/profiles.ts @@ -106,6 +106,7 @@ export const profileInviteEncoder = createSelectSchema(profileInvites) email: true, accessRoleId: true, createdAt: true, + notified: true, }) .extend({ inviteeProfile: profileMinimalEncoder.nullable(), diff --git a/services/api/src/routers/profile/listProfileInvites.ts b/services/api/src/routers/profile/listProfileInvites.ts index 4f7de0fb0..b4ba932bc 100644 --- a/services/api/src/routers/profile/listProfileInvites.ts +++ b/services/api/src/routers/profile/listProfileInvites.ts @@ -27,6 +27,7 @@ export const listProfileInvitesRouter = router({ email: invite.email, accessRoleId: invite.accessRoleId, createdAt: invite.createdAt, + notified: invite.notified, inviteeProfile: invite.inviteeProfile ?? null, })); }), diff --git a/services/api/src/routers/profile/updateProfileInvite.ts b/services/api/src/routers/profile/updateProfileInvite.ts index 599f7bbd9..7ad9f5efa 100644 --- a/services/api/src/routers/profile/updateProfileInvite.ts +++ b/services/api/src/routers/profile/updateProfileInvite.ts @@ -25,6 +25,7 @@ export const updateProfileInviteRouter = router({ email: invite.email, accessRoleId: invite.accessRoleId, createdAt: invite.createdAt, + notified: invite.notified, inviteeProfile: invite.inviteeProfile, }; }), From 70f920112e93dcbcfc28a039f132d43d17cdb477 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 13:23:04 +0100 Subject: [PATCH 06/24] fix: remove premature notified flag update in updateDecisionInstance --- .../src/services/decision/updateDecisionInstance.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/common/src/services/decision/updateDecisionInstance.ts b/packages/common/src/services/decision/updateDecisionInstance.ts index 2d0e4a951..efc310485 100644 --- a/packages/common/src/services/decision/updateDecisionInstance.ts +++ b/packages/common/src/services/decision/updateDecisionInstance.ts @@ -4,7 +4,6 @@ import { ProcessStatus, decisionProcessTransitions, processInstances, - profileInvites, profiles, } from '@op/db/schema'; import { Events, event } from '@op/events'; @@ -275,12 +274,7 @@ export const updateDecisionInstance = async ({ invitations, }, }); - - // Mark all queued invites as notified - await db - .update(profileInvites) - .set({ notified: true }) - .where(eq(profileInvites.profileId, profileId)); + // notified=true is set by the Inngest workflow after successful email delivery } } From 91f8f929a94a700ddee0d76c3bfbbb47ae3c3062 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 22:57:40 +0100 Subject: [PATCH 07/24] Bypass draft queueing for admins --- .../services/profile/inviteUsersToProfile.ts | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/common/src/services/profile/inviteUsersToProfile.ts b/packages/common/src/services/profile/inviteUsersToProfile.ts index 1c2213ee6..8e7483470 100644 --- a/packages/common/src/services/profile/inviteUsersToProfile.ts +++ b/packages/common/src/services/profile/inviteUsersToProfile.ts @@ -1,6 +1,6 @@ import { OPURLConfig } from '@op/core'; import { db } from '@op/db/client'; -import { allowList, ProcessStatus, profileInvites } from '@op/db/schema'; +import { ProcessStatus, allowList, profileInvites } from '@op/db/schema'; import { Events, event } from '@op/events'; import { User } from '@op/supabase/lib'; import { assertAccess, permission } from 'access-zones'; @@ -70,9 +70,14 @@ export const inviteUsersToProfile = async ({ assertProfile(profileId), // Get the requester's profile for the inviter name assertProfile(requesterProfileId), - // Get all target roles + // Get all target roles with their zone permissions (needed to detect admin roles) db.query.accessRoles.findMany({ where: { id: { in: uniqueRoleIds } }, + with: { + zonePermissions: { + with: { accessZone: true }, + }, + }, }), // Get all users with their profile memberships for this profile db._query.users.findMany({ @@ -137,6 +142,19 @@ export const inviteUsersToProfile = async ({ ); } + // Identify roles that include decisions: ADMIN — these bypass draft queueing + const adminRoleIds = new Set( + targetRoles + .filter((role) => + role.zonePermissions.some( + (zp) => + zp.accessZone.name === 'decisions' && + (zp.permission & permission.ADMIN) !== 0, + ), + ) + .map((role) => role.id), + ); + const results = { successful: [] as string[], failed: [] as { email: string; reason: string }[], @@ -261,20 +279,21 @@ export const inviteUsersToProfile = async ({ } // Determine if emails should be sent immediately or queued. - // Invites to draft processes are queued (notified=false) until publish. + // Invites to draft processes are queued (notified=false) until publish, + // UNLESS the role being assigned includes decisions: ADMIN. // Check both proposal-level (via proposal -> processInstance) and // decision-level (direct processInstance profile) relationships. const processInstanceStatus = proposalWithDecision?.processInstance?.status ?? processInstanceForProfile?.status; - const shouldNotify = processInstanceStatus !== ProcessStatus.DRAFT; + const isDraft = processInstanceStatus === ProcessStatus.DRAFT; // Batch insert and send event in a single transaction // If event.send fails, we rollback the DB inserts if (profileInviteEntries.length > 0) { const inviteEntries = profileInviteEntries.map((entry) => ({ ...entry, - notified: shouldNotify, + notified: !isDraft || adminRoleIds.has(entry.accessRoleId), })); await db.transaction(async (tx) => { @@ -286,14 +305,18 @@ export const inviteUsersToProfile = async ({ .values(inviteEntries) .returning({ id: profileInvites.id }); - // Only send email event if the process is not in draft - if (shouldNotify) { + // Send email events only for invites marked as notified + const notifiedIndices = inviteEntries + .map((entry, idx) => (entry.notified ? idx : -1)) + .filter((idx) => idx !== -1); + + if (notifiedIndices.length > 0) { await event.send({ name: Events.profileInviteSent.name, data: { senderProfileId: requesterProfileId, - inviteIds: insertedInvites.map((inv) => inv.id), - invitations: emailsToInvite, + inviteIds: notifiedIndices.map((idx) => insertedInvites[idx]!.id), + invitations: notifiedIndices.map((idx) => emailsToInvite[idx]!), }, }); } From e9ec22292f69345fd3e99909fde195d81fe07852 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 5 Mar 2026 17:29:12 +0100 Subject: [PATCH 08/24] Update tests --- services/api/src/routers/account/listUserInvites.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/services/api/src/routers/account/listUserInvites.test.ts b/services/api/src/routers/account/listUserInvites.test.ts index 46f273e22..62336c02c 100644 --- a/services/api/src/routers/account/listUserInvites.test.ts +++ b/services/api/src/routers/account/listUserInvites.test.ts @@ -34,6 +34,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.DECISION, accessRoleId: ROLES.MEMBER.id, invitedBy: adminUser.userProfileId, + notified: true, }) .returning(); @@ -93,6 +94,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.DECISION, accessRoleId: ROLES.MEMBER.id, invitedBy: decisionAdmin.userProfileId, + notified: true, }); testData.trackProfileInvite(invitee.email, decisionProfile.id); @@ -104,6 +106,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.ORG, accessRoleId: ROLES.MEMBER.id, invitedBy: orgAdmin.userProfileId, + notified: true, }); testData.trackProfileInvite(invitee.email, orgProfile.id); @@ -157,6 +160,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.ORG, accessRoleId: ROLES.MEMBER.id, invitedBy: adminUser.userProfileId, + notified: true, }); testData.trackProfileInvite(invitee.email, profile.id); @@ -169,6 +173,7 @@ describe.concurrent('account.listUserInvites', () => { accessRoleId: ROLES.MEMBER.id, invitedBy: admin2.userProfileId, acceptedOn: new Date().toISOString(), + notified: true, }); testData.trackProfileInvite(invitee.email, profile2.id); @@ -214,6 +219,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.ORG, accessRoleId: ROLES.MEMBER.id, invitedBy: adminUser.userProfileId, + notified: true, }); testData.trackProfileInvite(uppercaseEmail, profile.id); @@ -244,6 +250,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.DECISION, accessRoleId: ROLES.MEMBER.id, invitedBy: adminUser.userProfileId, + notified: true, }); testData.trackProfileInvite(invitee.email, profile.id); @@ -303,6 +310,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.ORG, accessRoleId: ROLES.MEMBER.id, invitedBy: adminUser.userProfileId, + notified: true, }); testData.trackProfileInvite(invitee.email, profile.id); From 8f3a539fb7dfcdc896ab5659a814a9dc8a4993a6 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 5 Mar 2026 17:32:41 +0100 Subject: [PATCH 09/24] Use proper color for pending --- apps/app/src/components/decisions/ProfileUsersAccessTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index b96f27ee6..ea0e864c0 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -437,7 +437,7 @@ const MobileInviteCard = ({
{t('Invited')} {!invite.notified && ( - + {t('Pending notification')} )} @@ -569,7 +569,7 @@ const ProfileUsersAccessTableContent = ({ {t('Invited')} {!invite.notified && ( - + {t('Pending notification')} )} From 87d32ec8f2a40233a247aef0d66103cd92b2f761 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 5 Mar 2026 19:39:21 +0100 Subject: [PATCH 10/24] Add in banner --- .../decisions/ProfileUsersAccessTable.tsx | 43 ++++++++++++------- apps/app/src/lib/i18n/dictionaries/bn.json | 3 +- apps/app/src/lib/i18n/dictionaries/en.json | 3 +- apps/app/src/lib/i18n/dictionaries/es.json | 3 +- apps/app/src/lib/i18n/dictionaries/fr.json | 3 +- apps/app/src/lib/i18n/dictionaries/pt.json | 3 +- 6 files changed, 38 insertions(+), 20 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index ea0e864c0..6ea524421 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -21,6 +21,8 @@ import { useState } from 'react'; import type { SortDescriptor } from 'react-aria-components'; import { LuUsers } from 'react-icons/lu'; +import { Note } from '@op/ui/Note'; + import { Link, useTranslations } from '@/lib/i18n'; import { ProfileAvatar } from '@/components/ProfileAvatar'; @@ -72,20 +74,18 @@ export const ProfileUsersAccessTable = ({ ); } - if (isMobile) { - return ( - - ); - } + const hasPendingLaunchInvites = invites.some((invite) => !invite.notified); - return ( + const content = isMobile ? ( + + ) : ( ); + + return ( +
+ {hasPendingLaunchInvites && ( + + {t( + 'This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.', + )} + + )} + {content} +
+ ); }; const getProfileUserStatus = (): string => { @@ -438,7 +451,7 @@ const MobileInviteCard = ({ {t('Invited')} {!invite.notified && ( - {t('Pending notification')} + {t('Pending launch')} )}
@@ -570,7 +583,7 @@ const ProfileUsersAccessTableContent = ({ {!invite.notified && ( - {t('Pending notification')} + {t('Pending launch')} )} diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index e7a8f4a94..79350e819 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -688,7 +688,8 @@ "Failed to remove user": "ব্যবহারকারী সরাতে ব্যর্থ", "No one has been invited yet": "এখনও কাউকে আমন্ত্রণ করা হয়নি", "Invited": "আমন্ত্রিত", - "Pending notification": "বিজ্ঞপ্তি মুলতুবি", + "Pending launch": "লঞ্চ মুলতুবি", + "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "এই প্রক্রিয়াটি এখনও খসড়ায় আছে। সম্পাদনা অ্যাক্সেস সহ অংশগ্রহণকারীদের অবিলম্বে আমন্ত্রণ জানানো হবে। সম্পাদনা অ্যাক্সেস ছাড়া অংশগ্রহণকারীদের আমন্ত্রণ প্রক্রিয়া চালু হলে পাঠানো হবে।", "Failed to cancel invite": "আমন্ত্রণ বাতিল করতে ব্যর্থ", "Accepts PDF, DOCX, XLSX up to {size}MB": "PDF, DOCX, XLSX {size}MB পর্যন্ত গ্রহণ করে", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "এই প্রক্রিয়ায় প্রস্তাবগুলি যে বিভাগগুলি এগিয়ে নেওয়া উচিত তা সংজ্ঞায়িত করুন। প্রস্তাবকারীরা তাদের প্রস্তাব কোন বিভাগগুলি সমর্থন করে তা নির্বাচন করবেন।", diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 290171e1f..751cf8052 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -682,7 +682,8 @@ "Failed to remove user": "Failed to remove user", "No one has been invited yet": "No one has been invited yet", "Invited": "Invited", - "Pending notification": "Pending notification", + "Pending launch": "Pending launch", + "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.", "Failed to cancel invite": "Failed to cancel invite", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.", "No categories defined yet": "No categories defined yet", diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 6d018bb63..4ab7be3d1 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -681,7 +681,8 @@ "Failed to remove user": "Error al eliminar usuario", "No one has been invited yet": "Nadie ha sido invitado todavía", "Invited": "Invitado", - "Pending notification": "Notificación pendiente", + "Pending launch": "Lanzamiento pendiente", + "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "Este proceso aún está en borrador. Los participantes con acceso de edición serán invitados inmediatamente. Las invitaciones de participantes sin acceso de edición se enviarán cuando se lance el proceso.", "Failed to cancel invite": "Error al cancelar invitación", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Define las categorías que las propuestas en este proceso deben avanzar. Los proponentes seleccionarán qué categorías apoya su propuesta.", "No categories defined yet": "Aún no se han definido categorías", diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index b69d3e95d..8b05d0134 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -681,7 +681,8 @@ "Failed to remove user": "Échec de la suppression de l'utilisateur", "No one has been invited yet": "Personne n'a encore été invité", "Invited": "Invité", - "Pending notification": "Notification en attente", + "Pending launch": "Lancement en attente", + "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "Ce processus est encore en brouillon. Les participants ayant un accès en édition seront invités immédiatement. Les invitations des participants sans accès en édition seront envoyées au lancement du processus.", "Failed to cancel invite": "Échec de l'annulation de l'invitation", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Définissez les catégories que les propositions de ce processus doivent faire avancer. Les proposants sélectionneront les catégories que leur proposition soutient.", "No categories defined yet": "Aucune catégorie définie pour le moment", diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 3018d9f87..ce972eff8 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -677,7 +677,8 @@ "Failed to remove user": "Falha ao remover usuário", "No one has been invited yet": "Ninguém foi convidado ainda", "Invited": "Convidado", - "Pending notification": "Notificação pendente", + "Pending launch": "Lançamento pendente", + "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "Este processo ainda está em rascunho. Participantes com acesso de edição serão convidados imediatamente. Os convites de participantes sem acesso de edição serão enviados quando o processo for lançado.", "Failed to cancel invite": "Falha ao cancelar convite", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Defina as categorias que as propostas neste processo devem avançar. Os proponentes selecionarão quais categorias sua proposta apoia.", "No categories defined yet": "Nenhuma categoria definida ainda", From 737f0e3f16f94701640b5000ac32815b48d3dc54 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 5 Mar 2026 20:45:33 +0100 Subject: [PATCH 11/24] Add in alert to top of invites for non-notified --- .../decisions/ProfileUsersAccessTable.tsx | 38 ++++++------- .../decision/updateDecisionInstance.ts | 32 +++++------ .../services/profile/inviteUsersToProfile.ts | 53 ++++++++++++------- 3 files changed, 64 insertions(+), 59 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index 6ea524421..4a6d1f4bb 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -6,6 +6,7 @@ import { Button } from '@op/ui/Button'; import { DialogTrigger } from '@op/ui/Dialog'; import { EmptyState } from '@op/ui/EmptyState'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; +import { Note } from '@op/ui/Note'; import { Select, SelectItem } from '@op/ui/Select'; import { Skeleton } from '@op/ui/Skeleton'; import { toast } from '@op/ui/Toast'; @@ -21,8 +22,6 @@ import { useState } from 'react'; import type { SortDescriptor } from 'react-aria-components'; import { LuUsers } from 'react-icons/lu'; -import { Note } from '@op/ui/Note'; - import { Link, useTranslations } from '@/lib/i18n'; import { ProfileAvatar } from '@/components/ProfileAvatar'; @@ -112,6 +111,20 @@ export const ProfileUsersAccessTable = ({ ); }; +const InviteStatusLabel = ({ notified }: { notified: boolean }) => { + const t = useTranslations(); + return ( +
+ {t('Invited')} + {!notified && ( + + {t('Pending launch')} + + )} +
+ ); +}; + const getProfileUserStatus = (): string => { // TODO: We need this logic in the backend // Default to "Active" for existing profile users @@ -437,7 +450,6 @@ const MobileInviteCard = ({ roles: { id: string; name: string }[]; processName?: string; }) => { - const t = useTranslations(); const displayName = invite.inviteeProfile?.name ?? invite.email; return ( @@ -447,14 +459,7 @@ const MobileInviteCard = ({
{displayName} -
- {t('Invited')} - {!invite.notified && ( - - {t('Pending launch')} - - )} -
+
{invite.email} @@ -577,16 +582,7 @@ const ProfileUsersAccessTableContent = ({ {displayName} -
- - {t('Invited')} - - {!invite.notified && ( - - {t('Pending launch')} - - )} -
+
diff --git a/packages/common/src/services/decision/updateDecisionInstance.ts b/packages/common/src/services/decision/updateDecisionInstance.ts index efc310485..a33c3ee44 100644 --- a/packages/common/src/services/decision/updateDecisionInstance.ts +++ b/packages/common/src/services/decision/updateDecisionInstance.ts @@ -229,6 +229,18 @@ export const updateDecisionInstance = async ({ } }); + // Fetch the profile with processInstance joined for the response + const profile = await db._query.profiles.findFirst({ + where: eq(profiles.id, profileId), + with: { + processInstance: true, + }, + }); + + if (!profile) { + throw new CommonError('Failed to fetch updated decision profile'); + } + // When publishing, send queued invite emails for this process instance's profile const isPublishing = status === ProcessStatus.PUBLISHED && @@ -248,17 +260,13 @@ export const updateDecisionInstance = async ({ if (queuedInvites.length > 0) { const baseUrl = OPURLConfig('APP').ENV_URL; - const decisionProfile = await db.query.profiles.findFirst({ - where: { id: profileId }, - }); - const decisionSlug = decisionProfile?.slug; const invitations = queuedInvites.map((invite) => ({ email: invite.email, inviterName: invite.inviter.name || 'A team member', profileName: invite.profile.name, - inviteUrl: decisionSlug - ? `${baseUrl}/decisions/${decisionSlug}` + inviteUrl: profile.slug + ? `${baseUrl}/decisions/${profile.slug}` : baseUrl, personalMessage: invite.message ?? undefined, })); @@ -278,17 +286,5 @@ export const updateDecisionInstance = async ({ } } - // Fetch the profile with processInstance joined for the response - const profile = await db._query.profiles.findFirst({ - where: eq(profiles.id, profileId), - with: { - processInstance: true, - }, - }); - - if (!profile) { - throw new CommonError('Failed to fetch updated decision profile'); - } - return profile; }; diff --git a/packages/common/src/services/profile/inviteUsersToProfile.ts b/packages/common/src/services/profile/inviteUsersToProfile.ts index 8e7483470..7a199700c 100644 --- a/packages/common/src/services/profile/inviteUsersToProfile.ts +++ b/packages/common/src/services/profile/inviteUsersToProfile.ts @@ -142,18 +142,24 @@ export const inviteUsersToProfile = async ({ ); } - // Identify roles that include decisions: ADMIN — these bypass draft queueing - const adminRoleIds = new Set( - targetRoles - .filter((role) => - role.zonePermissions.some( - (zp) => - zp.accessZone.name === 'decisions' && - (zp.permission & permission.ADMIN) !== 0, - ), - ) - .map((role) => role.id), - ); + // adminRoleIds is computed lazily — only needed when the process is in draft + let _adminRoleIds: Set | undefined; + const getAdminRoleIds = () => { + if (!_adminRoleIds) { + _adminRoleIds = new Set( + targetRoles + .filter((role) => + role.zonePermissions.some( + (zp) => + zp.accessZone.name === 'decisions' && + (zp.permission & permission.ADMIN) !== 0, + ), + ) + .map((role) => role.id), + ); + } + return _adminRoleIds; + }; const results = { successful: [] as string[], @@ -293,7 +299,7 @@ export const inviteUsersToProfile = async ({ if (profileInviteEntries.length > 0) { const inviteEntries = profileInviteEntries.map((entry) => ({ ...entry, - notified: !isDraft || adminRoleIds.has(entry.accessRoleId), + notified: !isDraft || getAdminRoleIds().has(entry.accessRoleId), })); await db.transaction(async (tx) => { @@ -306,17 +312,24 @@ export const inviteUsersToProfile = async ({ .returning({ id: profileInvites.id }); // Send email events only for invites marked as notified - const notifiedIndices = inviteEntries - .map((entry, idx) => (entry.notified ? idx : -1)) - .filter((idx) => idx !== -1); - - if (notifiedIndices.length > 0) { + const notifiedInvites = inviteEntries.flatMap((entry, idx) => + entry.notified + ? [ + { + inviteId: insertedInvites[idx]!.id, + email: emailsToInvite[idx]!, + }, + ] + : [], + ); + + if (notifiedInvites.length > 0) { await event.send({ name: Events.profileInviteSent.name, data: { senderProfileId: requesterProfileId, - inviteIds: notifiedIndices.map((idx) => insertedInvites[idx]!.id), - invitations: notifiedIndices.map((idx) => emailsToInvite[idx]!), + inviteIds: notifiedInvites.map((n) => n.inviteId), + invitations: notifiedInvites.map((n) => n.email), }, }); } From 7e7d514cdcaa571091239d0a80f86726a03b8a2c Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 14:27:15 +0100 Subject: [PATCH 12/24] Pull in AlertBanner from dev and rebase --- .../src/components/decisions/ProfileUsersAccessTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index 4a6d1f4bb..4f72aad93 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -6,7 +6,7 @@ import { Button } from '@op/ui/Button'; import { DialogTrigger } from '@op/ui/Dialog'; import { EmptyState } from '@op/ui/EmptyState'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; -import { Note } from '@op/ui/Note'; +import { AlertBanner } from '@op/ui/AlertBanner'; import { Select, SelectItem } from '@op/ui/Select'; import { Skeleton } from '@op/ui/Skeleton'; import { toast } from '@op/ui/Toast'; @@ -100,11 +100,11 @@ export const ProfileUsersAccessTable = ({ return (
{hasPendingLaunchInvites && ( - + {t( 'This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.', )} - + )} {content}
From 927439b67f386485524f7493125e1f2e958340d3 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 14:49:34 +0100 Subject: [PATCH 13/24] Switch to notified_at --- .../decisions/ProfileUsersAccessTable.tsx | 12 ++++++------ .../services/decision/updateDecisionInstance.ts | 4 ++-- .../src/services/profile/inviteUsersToProfile.ts | 15 +++++---------- .../src/services/profile/listUserInvites.ts | 2 +- services/api/src/encoders/profiles.ts | 2 +- .../src/routers/account/listUserInvites.test.ts | 16 ++++++++-------- .../src/routers/profile/listProfileInvites.ts | 2 +- .../src/routers/profile/updateProfileInvite.ts | 2 +- .../notifications/sendProfileInviteEmails.ts | 2 +- 9 files changed, 26 insertions(+), 31 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index 4f72aad93..a2b2b7a20 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -2,11 +2,11 @@ import { trpc } from '@op/api/client'; import type { ProfileInvite, ProfileUser } from '@op/api/encoders'; +import { AlertBanner } from '@op/ui/AlertBanner'; import { Button } from '@op/ui/Button'; import { DialogTrigger } from '@op/ui/Dialog'; import { EmptyState } from '@op/ui/EmptyState'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; -import { AlertBanner } from '@op/ui/AlertBanner'; import { Select, SelectItem } from '@op/ui/Select'; import { Skeleton } from '@op/ui/Skeleton'; import { toast } from '@op/ui/Toast'; @@ -73,7 +73,7 @@ export const ProfileUsersAccessTable = ({ ); } - const hasPendingLaunchInvites = invites.some((invite) => !invite.notified); + const hasPendingLaunchInvites = invites.some((invite) => !invite.notifiedAt); const content = isMobile ? ( { +const InviteStatusLabel = ({ notifiedAt }: { notifiedAt: string | null }) => { const t = useTranslations(); return (
{t('Invited')} - {!notified && ( + {!notifiedAt && ( {t('Pending launch')} @@ -459,7 +459,7 @@ const MobileInviteCard = ({
{displayName} - +
{invite.email} @@ -582,7 +582,7 @@ const ProfileUsersAccessTableContent = ({ {displayName} - +
diff --git a/packages/common/src/services/decision/updateDecisionInstance.ts b/packages/common/src/services/decision/updateDecisionInstance.ts index a33c3ee44..feaa5c495 100644 --- a/packages/common/src/services/decision/updateDecisionInstance.ts +++ b/packages/common/src/services/decision/updateDecisionInstance.ts @@ -250,7 +250,7 @@ export const updateDecisionInstance = async ({ const queuedInvites = await db.query.profileInvites.findMany({ where: { profileId, - notified: false, + notifiedAt: { isNull: true }, }, with: { profile: true, @@ -282,7 +282,7 @@ export const updateDecisionInstance = async ({ invitations, }, }); - // notified=true is set by the Inngest workflow after successful email delivery + // notifiedAt is set by the Inngest workflow after successful email delivery } } diff --git a/packages/common/src/services/profile/inviteUsersToProfile.ts b/packages/common/src/services/profile/inviteUsersToProfile.ts index 7a199700c..9bbb2bd49 100644 --- a/packages/common/src/services/profile/inviteUsersToProfile.ts +++ b/packages/common/src/services/profile/inviteUsersToProfile.ts @@ -285,7 +285,7 @@ export const inviteUsersToProfile = async ({ } // Determine if emails should be sent immediately or queued. - // Invites to draft processes are queued (notified=false) until publish, + // Invites to draft processes are queued (notifiedAt=null) until publish, // UNLESS the role being assigned includes decisions: ADMIN. // Check both proposal-level (via proposal -> processInstance) and // decision-level (direct processInstance profile) relationships. @@ -297,23 +297,18 @@ export const inviteUsersToProfile = async ({ // Batch insert and send event in a single transaction // If event.send fails, we rollback the DB inserts if (profileInviteEntries.length > 0) { - const inviteEntries = profileInviteEntries.map((entry) => ({ - ...entry, - notified: !isDraft || getAdminRoleIds().has(entry.accessRoleId), - })); - await db.transaction(async (tx) => { if (allowListEntries.length > 0) { await tx.insert(allowList).values(allowListEntries); } const insertedInvites = await tx .insert(profileInvites) - .values(inviteEntries) + .values(profileInviteEntries) .returning({ id: profileInvites.id }); - // Send email events only for invites marked as notified - const notifiedInvites = inviteEntries.flatMap((entry, idx) => - entry.notified + // Send email events only for invites that should be notified immediately + const notifiedInvites = profileInviteEntries.flatMap((entry, idx) => + !isDraft || getAdminRoleIds().has(entry.accessRoleId) ? [ { inviteId: insertedInvites[idx]!.id, diff --git a/packages/common/src/services/profile/listUserInvites.ts b/packages/common/src/services/profile/listUserInvites.ts index 808626c66..fed8413ec 100644 --- a/packages/common/src/services/profile/listUserInvites.ts +++ b/packages/common/src/services/profile/listUserInvites.ts @@ -22,7 +22,7 @@ export const listUserInvites = async ({ const invites = await db.query.profileInvites.findMany({ where: { email: { ilike: user.email }, - notified: true, + notifiedAt: { isNotNull: true }, ...(pending === true && { acceptedOn: { isNull: true } }), ...(pending === false && { acceptedOn: { isNotNull: true } }), ...(entityType && { profileEntityType: entityType }), diff --git a/services/api/src/encoders/profiles.ts b/services/api/src/encoders/profiles.ts index 80bf80125..b11d9e65e 100644 --- a/services/api/src/encoders/profiles.ts +++ b/services/api/src/encoders/profiles.ts @@ -106,7 +106,7 @@ export const profileInviteEncoder = createSelectSchema(profileInvites) email: true, accessRoleId: true, createdAt: true, - notified: true, + notifiedAt: true, }) .extend({ inviteeProfile: profileMinimalEncoder.nullable(), diff --git a/services/api/src/routers/account/listUserInvites.test.ts b/services/api/src/routers/account/listUserInvites.test.ts index 62336c02c..1de73372f 100644 --- a/services/api/src/routers/account/listUserInvites.test.ts +++ b/services/api/src/routers/account/listUserInvites.test.ts @@ -34,7 +34,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.DECISION, accessRoleId: ROLES.MEMBER.id, invitedBy: adminUser.userProfileId, - notified: true, + notifiedAt: new Date().toISOString(), }) .returning(); @@ -94,7 +94,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.DECISION, accessRoleId: ROLES.MEMBER.id, invitedBy: decisionAdmin.userProfileId, - notified: true, + notifiedAt: new Date().toISOString(), }); testData.trackProfileInvite(invitee.email, decisionProfile.id); @@ -106,7 +106,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.ORG, accessRoleId: ROLES.MEMBER.id, invitedBy: orgAdmin.userProfileId, - notified: true, + notifiedAt: new Date().toISOString(), }); testData.trackProfileInvite(invitee.email, orgProfile.id); @@ -160,7 +160,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.ORG, accessRoleId: ROLES.MEMBER.id, invitedBy: adminUser.userProfileId, - notified: true, + notifiedAt: new Date().toISOString(), }); testData.trackProfileInvite(invitee.email, profile.id); @@ -173,7 +173,7 @@ describe.concurrent('account.listUserInvites', () => { accessRoleId: ROLES.MEMBER.id, invitedBy: admin2.userProfileId, acceptedOn: new Date().toISOString(), - notified: true, + notifiedAt: new Date().toISOString(), }); testData.trackProfileInvite(invitee.email, profile2.id); @@ -219,7 +219,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.ORG, accessRoleId: ROLES.MEMBER.id, invitedBy: adminUser.userProfileId, - notified: true, + notifiedAt: new Date().toISOString(), }); testData.trackProfileInvite(uppercaseEmail, profile.id); @@ -250,7 +250,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.DECISION, accessRoleId: ROLES.MEMBER.id, invitedBy: adminUser.userProfileId, - notified: true, + notifiedAt: new Date().toISOString(), }); testData.trackProfileInvite(invitee.email, profile.id); @@ -310,7 +310,7 @@ describe.concurrent('account.listUserInvites', () => { profileEntityType: EntityType.ORG, accessRoleId: ROLES.MEMBER.id, invitedBy: adminUser.userProfileId, - notified: true, + notifiedAt: new Date().toISOString(), }); testData.trackProfileInvite(invitee.email, profile.id); diff --git a/services/api/src/routers/profile/listProfileInvites.ts b/services/api/src/routers/profile/listProfileInvites.ts index b4ba932bc..a19ded3b3 100644 --- a/services/api/src/routers/profile/listProfileInvites.ts +++ b/services/api/src/routers/profile/listProfileInvites.ts @@ -27,7 +27,7 @@ export const listProfileInvitesRouter = router({ email: invite.email, accessRoleId: invite.accessRoleId, createdAt: invite.createdAt, - notified: invite.notified, + notifiedAt: invite.notifiedAt, inviteeProfile: invite.inviteeProfile ?? null, })); }), diff --git a/services/api/src/routers/profile/updateProfileInvite.ts b/services/api/src/routers/profile/updateProfileInvite.ts index 7ad9f5efa..1cd8d7086 100644 --- a/services/api/src/routers/profile/updateProfileInvite.ts +++ b/services/api/src/routers/profile/updateProfileInvite.ts @@ -25,7 +25,7 @@ export const updateProfileInviteRouter = router({ email: invite.email, accessRoleId: invite.accessRoleId, createdAt: invite.createdAt, - notified: invite.notified, + notifiedAt: invite.notifiedAt, inviteeProfile: invite.inviteeProfile, }; }), diff --git a/services/workflows/src/functions/notifications/sendProfileInviteEmails.ts b/services/workflows/src/functions/notifications/sendProfileInviteEmails.ts index 35c9fd929..c031d9e19 100644 --- a/services/workflows/src/functions/notifications/sendProfileInviteEmails.ts +++ b/services/workflows/src/functions/notifications/sendProfileInviteEmails.ts @@ -56,7 +56,7 @@ export const sendProfileInviteEmails = inngest.createFunction( await step.run('mark-invites-notified', async () => { await db .update(profileInvites) - .set({ notified: true }) + .set({ notifiedAt: new Date().toISOString() }) .where(inArray(profileInvites.id, inviteIds)); }); } From 2e02c03912d42739756d61166e7eb9598e8d6d4d Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 17:03:52 +0100 Subject: [PATCH 14/24] pluralize tab labels --- apps/app/src/components/decisions/RoleSelector.tsx | 2 +- apps/app/src/lib/i18n/dictionaries/bn.json | 4 +++- apps/app/src/lib/i18n/dictionaries/en.json | 4 +++- apps/app/src/lib/i18n/dictionaries/es.json | 4 +++- apps/app/src/lib/i18n/dictionaries/fr.json | 4 +++- apps/app/src/lib/i18n/dictionaries/pt.json | 4 +++- 6 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/decisions/RoleSelector.tsx b/apps/app/src/components/decisions/RoleSelector.tsx index 01634eb25..7de9f65b0 100644 --- a/apps/app/src/components/decisions/RoleSelector.tsx +++ b/apps/app/src/components/decisions/RoleSelector.tsx @@ -58,7 +58,7 @@ export const RoleSelector = ({ return ( - {role.name} + {t('{roleName} plural', { roleName: role.name })} {count > 0 && ( {count} diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 79350e819..642901034 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -923,5 +923,7 @@ "No score, just written feedback": "কোনো স্কোর নেই, শুধু লিখিত প্রতিক্রিয়া", "Score labels cannot be empty": "স্কোর লেবেল খালি রাখা যাবে না", "Delete criterion": "মানদণ্ড মুছুন", - "Are you sure you want to delete this criterion? This action cannot be undone.": "আপনি কি নিশ্চিত যে এই মানদণ্ডটি মুছতে চান? এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।" + "Are you sure you want to delete this criterion? This action cannot be undone.": "আপনি কি নিশ্চিত যে এই মানদণ্ডটি মুছতে চান? এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।", + "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", + "{roleName} plural": "{roleName}" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 751cf8052..4a17e0ed7 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -916,5 +916,7 @@ "No score, just written feedback": "No score, just written feedback", "Score labels cannot be empty": "Score labels cannot be empty", "Delete criterion": "Delete criterion", - "Are you sure you want to delete this criterion? This action cannot be undone.": "Are you sure you want to delete this criterion? This action cannot be undone." + "Are you sure you want to delete this criterion? This action cannot be undone.": "Are you sure you want to delete this criterion? This action cannot be undone.", + "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", + "{roleName} plural": "{roleName}s" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 4ab7be3d1..c938a3463 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -916,5 +916,7 @@ "No score, just written feedback": "Sin puntuación, solo retroalimentación escrita", "Score labels cannot be empty": "Las etiquetas de puntuación no pueden estar vacías", "Delete criterion": "Eliminar criterio", - "Are you sure you want to delete this criterion? This action cannot be undone.": "¿Está seguro de que desea eliminar este criterio? Esta acción no se puede deshacer." + "Are you sure you want to delete this criterion? This action cannot be undone.": "¿Está seguro de que desea eliminar este criterio? Esta acción no se puede deshacer.", + "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", + "{roleName} plural": "{roleName}s" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 8b05d0134..7ba8edd6f 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -916,5 +916,7 @@ "No score, just written feedback": "Pas de note, uniquement des commentaires écrits", "Score labels cannot be empty": "Les libellés des notes ne peuvent pas être vides", "Delete criterion": "Supprimer le critère", - "Are you sure you want to delete this criterion? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce critère ? Cette action est irréversible." + "Are you sure you want to delete this criterion? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce critère ? Cette action est irréversible.", + "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", + "{roleName} plural": "{roleName}s" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index ce972eff8..469be3287 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -916,5 +916,7 @@ "No score, just written feedback": "Sem pontuação, apenas feedback escrito", "Score labels cannot be empty": "Os rótulos das notas não podem estar vazios", "Delete criterion": "Excluir critério", - "Are you sure you want to delete this criterion? This action cannot be undone.": "Tem certeza de que deseja excluir este critério? Esta ação não pode ser desfeita." + "Are you sure you want to delete this criterion? This action cannot be undone.": "Tem certeza de que deseja excluir este critério? Esta ação não pode ser desfeita.", + "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", + "{roleName} plural": "{roleName}s" } From 973d0c2f02951ac9bcb08bf2ff67d7822b6cc782 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 17:04:16 +0100 Subject: [PATCH 15/24] Update positioning of AlertBanners --- .../participants/ParticipantsSection.tsx | 2 + .../decisions/ProfileInviteModal.tsx | 38 +++++++++++++++++++ .../decisions/ProfileUsersAccess.tsx | 36 ++++++++++++------ .../decisions/ProfileUsersAccessTable.tsx | 29 ++------------ 4 files changed, 68 insertions(+), 37 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/ParticipantsSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/ParticipantsSection.tsx index 988a00738..a4029f145 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/ParticipantsSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/ParticipantsSection.tsx @@ -4,6 +4,7 @@ import type { SectionProps } from '../../contentRegistry'; export default function ParticipantsSection({ decisionProfileId, + instanceId, decisionName, }: SectionProps) { return ( @@ -11,6 +12,7 @@ export default function ParticipantsSection({
diff --git a/apps/app/src/components/decisions/ProfileInviteModal.tsx b/apps/app/src/components/decisions/ProfileInviteModal.tsx index 938ca9e8b..6d83c2941 100644 --- a/apps/app/src/components/decisions/ProfileInviteModal.tsx +++ b/apps/app/src/components/decisions/ProfileInviteModal.tsx @@ -3,7 +3,9 @@ import { getPublicUrl } from '@/utils'; import { trpc } from '@op/api/client'; import { EntityType } from '@op/api/encoders'; +import { ProcessStatus } from '@op/api/encoders'; import { useDebounce } from '@op/hooks'; +import { AlertBanner } from '@op/ui/AlertBanner'; import { Avatar } from '@op/ui/Avatar'; import { Button } from '@op/ui/Button'; import { EmptyState } from '@op/ui/EmptyState'; @@ -49,10 +51,12 @@ type SelectedItemsByRole = Record; export const ProfileInviteModal = ({ profileId, + instanceId, isOpen, onOpenChange, }: { profileId: string; + instanceId?: string; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; }) => { @@ -86,6 +90,7 @@ export const ProfileInviteModal = ({ > @@ -96,9 +101,11 @@ export const ProfileInviteModal = ({ function ProfileInviteModalContent({ profileId, + instanceId, onOpenChange, }: { profileId: string; + instanceId?: string; onOpenChange: (isOpen: boolean) => void; }) { const t = useTranslations(); @@ -118,6 +125,29 @@ function ProfileInviteModalContent({ width: 0, }); + // Fetch process instance to check draft status + const { data: instance } = trpc.decision.getInstance.useQuery( + { instanceId: instanceId! }, + { enabled: !!instanceId }, + ); + const isDraft = instance?.status === ProcessStatus.DRAFT; + + // Fetch roles with decisions zone permissions to identify admin roles + const { data: rolesWithPerms } = trpc.profile.listRoles.useQuery( + { profileId, zoneName: 'decisions' }, + { enabled: isDraft }, + ); + const adminRoleIds = useMemo(() => { + if (!rolesWithPerms) { + return new Set(); + } + return new Set( + rolesWithPerms.items.filter((r) => r.permissions?.admin).map((r) => r.id), + ); + }, [rolesWithPerms]); + + const showDraftBanner = isDraft && !adminRoleIds.has(selectedRoleId); + // Fetch existing pending invites and members const [serverInvites] = trpc.profile.listProfileInvites.useSuspenseQuery({ profileId, @@ -419,6 +449,14 @@ function ProfileInviteModalContent({ onRoleNameChange={setSelectedRoleName} /> + {showDraftBanner && ( + + {t( + 'This process is still in draft. Participant invites will be sent when the process launches.', + )} + + )} + {/* Search Input */}
{ const t = useTranslations(); @@ -92,10 +95,26 @@ export const ProfileUsersAccess = ({ return ( }>
-
-

- {t('Participants')} -

+

+ {t('Participants')} +

+ + {invites?.some((invite) => !invite.notifiedAt) && ( + + {t( + 'This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.', + )} + + )} + +
+
- - diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx index a2b2b7a20..4030b179a 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -2,7 +2,6 @@ import { trpc } from '@op/api/client'; import type { ProfileInvite, ProfileUser } from '@op/api/encoders'; -import { AlertBanner } from '@op/ui/AlertBanner'; import { Button } from '@op/ui/Button'; import { DialogTrigger } from '@op/ui/Dialog'; import { EmptyState } from '@op/ui/EmptyState'; @@ -73,9 +72,7 @@ export const ProfileUsersAccessTable = ({ ); } - const hasPendingLaunchInvites = invites.some((invite) => !invite.notifiedAt); - - const content = isMobile ? ( + return isMobile ? ( ); - - return ( -
- {hasPendingLaunchInvites && ( - - {t( - 'This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.', - )} - - )} - {content} -
- ); }; const InviteStatusLabel = ({ notifiedAt }: { notifiedAt: string | null }) => { const t = useTranslations(); return ( -
- {t('Invited')} - {!notifiedAt && ( - - {t('Pending launch')} - - )} -
+ + {notifiedAt ? t('Invited') : t('Pending launch')} + ); }; From 67e23d42f97a351db68e8fcf6d4a7563d879030f Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 17:26:57 +0100 Subject: [PATCH 16/24] Show modal in the case of pending notifications --- .../ProcessBuilder/LaunchProcessModal.tsx | 30 ++++++++++++++----- apps/app/src/lib/i18n/dictionaries/bn.json | 4 ++- apps/app/src/lib/i18n/dictionaries/en.json | 4 ++- apps/app/src/lib/i18n/dictionaries/es.json | 4 ++- apps/app/src/lib/i18n/dictionaries/fr.json | 4 ++- apps/app/src/lib/i18n/dictionaries/pt.json | 4 ++- 6 files changed, 38 insertions(+), 12 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx b/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx index bf2aaecd2..f9ca3356a 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx @@ -33,6 +33,13 @@ export const LaunchProcessModal = ({ (s) => s.instances[decisionProfileId], ); + const { data: invites } = trpc.profile.listProfileInvites.useQuery( + { profileId: decisionProfileId }, + { enabled: isOpen }, + ); + const pendingNotificationCount = + invites?.filter((i) => !i.notifiedAt).length ?? 0; + const phasesCount = instanceData?.phases?.length ?? 0; const categoriesCount = instanceData?.config?.categories?.length ?? 0; const showNoCategoriesWarning = categoriesCount === 0; @@ -59,14 +66,23 @@ export const LaunchProcessModal = ({ return ( - {t('Launch Process')} + {t('Launch process?')} -

- {t( - 'This will open {processName} for proposal submissions. Participants will be notified and can begin submitting proposals.', - { processName }, - )} -

+ {pendingNotificationCount > 0 ? ( +

+ {t( + 'Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.', + { count: pendingNotificationCount }, + )} +

+ ) : ( +

+ {t( + 'This will open {processName} for proposal submissions. Participants will be notified and can begin submitting proposals.', + { processName }, + )} +

+ )} {/* Summary Section */}
diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 642901034..bef2024b8 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -925,5 +925,7 @@ "Delete criterion": "মানদণ্ড মুছুন", "Are you sure you want to delete this criterion? This action cannot be undone.": "আপনি কি নিশ্চিত যে এই মানদণ্ডটি মুছতে চান? এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।", "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", - "{roleName} plural": "{roleName}" + "{roleName} plural": "{roleName}", + "Launch process?": "Launch process?", + "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.": "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}." } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 4a17e0ed7..a2ec49226 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -918,5 +918,7 @@ "Delete criterion": "Delete criterion", "Are you sure you want to delete this criterion? This action cannot be undone.": "Are you sure you want to delete this criterion? This action cannot be undone.", "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", - "{roleName} plural": "{roleName}s" + "{roleName} plural": "{roleName}s", + "Launch process?": "Launch process?", + "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.": "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}." } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index c938a3463..43fb9ae0c 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -918,5 +918,7 @@ "Delete criterion": "Eliminar criterio", "Are you sure you want to delete this criterion? This action cannot be undone.": "¿Está seguro de que desea eliminar este criterio? Esta acción no se puede deshacer.", "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", - "{roleName} plural": "{roleName}s" + "{roleName} plural": "{roleName}s", + "Launch process?": "Launch process?", + "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.": "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}." } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 7ba8edd6f..9c923154a 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -918,5 +918,7 @@ "Delete criterion": "Supprimer le critère", "Are you sure you want to delete this criterion? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce critère ? Cette action est irréversible.", "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", - "{roleName} plural": "{roleName}s" + "{roleName} plural": "{roleName}s", + "Launch process?": "Launch process?", + "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.": "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}." } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 469be3287..f2781223d 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -918,5 +918,7 @@ "Delete criterion": "Excluir critério", "Are you sure you want to delete this criterion? This action cannot be undone.": "Tem certeza de que deseja excluir este critério? Esta ação não pode ser desfeita.", "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", - "{roleName} plural": "{roleName}s" + "{roleName} plural": "{roleName}s", + "Launch process?": "Launch process?", + "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.": "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}." } From cfb022a0ae97b4806bb594dd72508ff560c8b26f Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 17:36:25 +0100 Subject: [PATCH 17/24] Show how many will be notified --- .../decisions/ProcessBuilder/LaunchProcessModal.tsx | 11 +++++++---- apps/app/src/lib/i18n/dictionaries/bn.json | 3 ++- apps/app/src/lib/i18n/dictionaries/en.json | 3 ++- apps/app/src/lib/i18n/dictionaries/es.json | 3 ++- apps/app/src/lib/i18n/dictionaries/fr.json | 3 ++- apps/app/src/lib/i18n/dictionaries/pt.json | 3 ++- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx b/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx index f9ca3356a..9640333e2 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx @@ -70,10 +70,13 @@ export const LaunchProcessModal = ({ {pendingNotificationCount > 0 ? (

- {t( - 'Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.', - { count: pendingNotificationCount }, - )} + {t('Launching your process will notify')}{' '} + + {t( + '{count, plural, =1 {1 participant} other {# participants}}.', + { count: pendingNotificationCount }, + )} +

) : (

diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index bef2024b8..dee96cded 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -927,5 +927,6 @@ "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", "{roleName} plural": "{roleName}", "Launch process?": "Launch process?", - "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.": "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}." + "Launching your process will notify": "Launching your process will notify", + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index a2ec49226..2f57c28b2 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -920,5 +920,6 @@ "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", "{roleName} plural": "{roleName}s", "Launch process?": "Launch process?", - "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.": "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}." + "Launching your process will notify": "Launching your process will notify", + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 43fb9ae0c..cd8c4deef 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -920,5 +920,6 @@ "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", "{roleName} plural": "{roleName}s", "Launch process?": "Launch process?", - "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.": "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}." + "Launching your process will notify": "Launching your process will notify", + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 9c923154a..61a033bfa 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -920,5 +920,6 @@ "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", "{roleName} plural": "{roleName}s", "Launch process?": "Launch process?", - "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.": "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}." + "Launching your process will notify": "Launching your process will notify", + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index f2781223d..20d662081 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -920,5 +920,6 @@ "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", "{roleName} plural": "{roleName}s", "Launch process?": "Launch process?", - "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}.": "Launching your process will notify {count, plural, =1 {1 participant} other {# participants}}." + "Launching your process will notify": "Launching your process will notify", + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." } From c5ada422b001bd5ee7f1069623026315583f4f9d Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 17:56:13 +0100 Subject: [PATCH 18/24] Use proper isDraft check --- .../ProcessBuilder/LaunchProcessModal.tsx | 14 +++++++++----- .../components/decisions/ProfileUsersAccess.tsx | 10 +++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx b/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx index 9640333e2..8438b0d52 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx @@ -4,6 +4,7 @@ import { trpc } from '@op/api/client'; import { ProcessStatus } from '@op/api/encoders'; import { Button } from '@op/ui/Button'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; +import { Skeleton } from '@op/ui/Skeleton'; import { toast } from '@op/ui/Toast'; import { useRouter } from 'next/navigation'; import { LuInfo } from 'react-icons/lu'; @@ -33,10 +34,11 @@ export const LaunchProcessModal = ({ (s) => s.instances[decisionProfileId], ); - const { data: invites } = trpc.profile.listProfileInvites.useQuery( - { profileId: decisionProfileId }, - { enabled: isOpen }, - ); + const { data: invites, isLoading: invitesLoading } = + trpc.profile.listProfileInvites.useQuery( + { profileId: decisionProfileId }, + { enabled: isOpen }, + ); const pendingNotificationCount = invites?.filter((i) => !i.notifiedAt).length ?? 0; @@ -68,7 +70,9 @@ export const LaunchProcessModal = ({ {t('Launch process?')} - {pendingNotificationCount > 0 ? ( + {invitesLoading ? ( + + ) : pendingNotificationCount > 0 ? (

{t('Launching your process will notify')}{' '} diff --git a/apps/app/src/components/decisions/ProfileUsersAccess.tsx b/apps/app/src/components/decisions/ProfileUsersAccess.tsx index 1bdd1ea84..5f1561705 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccess.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccess.tsx @@ -2,6 +2,7 @@ import { ClientOnly } from '@/utils/ClientOnly'; import { trpc } from '@op/api/client'; +import { ProcessStatus } from '@op/api/encoders'; import type { SortDir } from '@op/common'; import { useCursorPagination, useDebounce, useMediaQuery } from '@op/hooks'; import { screens } from '@op/styles/constants'; @@ -77,6 +78,13 @@ export const ProfileUsersAccess = ({ const { data: rolesData, isPending: rolesPending } = trpc.profile.listRoles.useQuery({ profileId }); + // Check if process is in draft status + const { data: instance } = trpc.decision.getInstance.useQuery( + { instanceId: instanceId! }, + { enabled: !!instanceId }, + ); + const isDraft = instance?.status === ProcessStatus.DRAFT; + // Fetch pending invites to show alongside accepted members const { data: invites } = trpc.profile.listProfileInvites.useQuery( { profileId }, @@ -99,7 +107,7 @@ export const ProfileUsersAccess = ({ {t('Participants')} - {invites?.some((invite) => !invite.notifiedAt) && ( + {isDraft && ( {t( 'This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.', From dc0d38454313c542bbc40a5557d10989b02542a7 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 18:07:15 +0100 Subject: [PATCH 19/24] Translations and better component --- .../src/components/decisions/ProfileUsersAccess.tsx | 5 +++-- apps/app/src/lib/i18n/dictionaries/bn.json | 10 +++++----- apps/app/src/lib/i18n/dictionaries/es.json | 12 ++++++------ apps/app/src/lib/i18n/dictionaries/fr.json | 10 +++++----- apps/app/src/lib/i18n/dictionaries/pt.json | 12 ++++++------ 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccess.tsx b/apps/app/src/components/decisions/ProfileUsersAccess.tsx index 5f1561705..97401f408 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccess.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccess.tsx @@ -8,6 +8,7 @@ import { useCursorPagination, useDebounce, useMediaQuery } from '@op/hooks'; import { screens } from '@op/styles/constants'; import { AlertBanner } from '@op/ui/AlertBanner'; import { Button } from '@op/ui/Button'; +import { Header2 } from '@op/ui/Header'; import { Pagination } from '@op/ui/Pagination'; import { SearchField } from '@op/ui/SearchField'; import { Skeleton } from '@op/ui/Skeleton'; @@ -103,9 +104,9 @@ export const ProfileUsersAccess = ({ return ( }>

-

+ {t('Participants')} -

+ {isDraft && ( diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index dee96cded..a507d8509 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -689,7 +689,7 @@ "No one has been invited yet": "এখনও কাউকে আমন্ত্রণ করা হয়নি", "Invited": "আমন্ত্রিত", "Pending launch": "লঞ্চ মুলতুবি", - "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "এই প্রক্রিয়াটি এখনও খসড়ায় আছে। সম্পাদনা অ্যাক্সেস সহ অংশগ্রহণকারীদের অবিলম্বে আমন্ত্রণ জানানো হবে। সম্পাদনা অ্যাক্সেস ছাড়া অংশগ্রহণকারীদের আমন্ত্রণ প্রক্রিয়া চালু হলে পাঠানো হবে।", + "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "এই প্রক্রিয়াটি এখনও খসড়া অবস্থায় আছে। সম্পাদনা অ্যাক্সেস সহ অংশগ্রহণকারীদের অবিলম্বে আমন্ত্রণ জানানো হবে, সম্পাদনা অ্যাক্সেস ছাড়া অংশগ্রহণকারীদের আমন্ত্রণ প্রক্রিয়া চালু হলে পাঠানো হবে।", "Failed to cancel invite": "আমন্ত্রণ বাতিল করতে ব্যর্থ", "Accepts PDF, DOCX, XLSX up to {size}MB": "PDF, DOCX, XLSX {size}MB পর্যন্ত গ্রহণ করে", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "এই প্রক্রিয়ায় প্রস্তাবগুলি যে বিভাগগুলি এগিয়ে নেওয়া উচিত তা সংজ্ঞায়িত করুন। প্রস্তাবকারীরা তাদের প্রস্তাব কোন বিভাগগুলি সমর্থন করে তা নির্বাচন করবেন।", @@ -924,9 +924,9 @@ "Score labels cannot be empty": "স্কোর লেবেল খালি রাখা যাবে না", "Delete criterion": "মানদণ্ড মুছুন", "Are you sure you want to delete this criterion? This action cannot be undone.": "আপনি কি নিশ্চিত যে এই মানদণ্ডটি মুছতে চান? এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।", - "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", + "This process is still in draft. Participant invites will be sent when the process launches.": "এই প্রক্রিয়াটি এখনও খসড়া অবস্থায় আছে। প্রক্রিয়া চালু হলে অংশগ্রহণকারীদের আমন্ত্রণ পাঠানো হবে।", "{roleName} plural": "{roleName}", - "Launch process?": "Launch process?", - "Launching your process will notify": "Launching your process will notify", - "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." + "Launch process?": "প্রক্রিয়া চালু করবেন?", + "Launching your process will notify": "আপনার প্রক্রিয়া চালু করলে বিজ্ঞপ্তি পাবেন", + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 জন অংশগ্রহণকারী} other {# জন অংশগ্রহণকারী}}।" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index cd8c4deef..6a472cf06 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -681,8 +681,8 @@ "Failed to remove user": "Error al eliminar usuario", "No one has been invited yet": "Nadie ha sido invitado todavía", "Invited": "Invitado", - "Pending launch": "Lanzamiento pendiente", - "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "Este proceso aún está en borrador. Los participantes con acceso de edición serán invitados inmediatamente. Las invitaciones de participantes sin acceso de edición se enviarán cuando se lance el proceso.", + "Pending launch": "Pendiente de lanzamiento", + "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "Este proceso aún está en borrador. Los participantes con acceso de edición serán invitados de inmediato, las invitaciones de participantes sin acceso de edición se enviarán cuando se lance el proceso.", "Failed to cancel invite": "Error al cancelar invitación", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Define las categorías que las propuestas en este proceso deben avanzar. Los proponentes seleccionarán qué categorías apoya su propuesta.", "No categories defined yet": "Aún no se han definido categorías", @@ -917,9 +917,9 @@ "Score labels cannot be empty": "Las etiquetas de puntuación no pueden estar vacías", "Delete criterion": "Eliminar criterio", "Are you sure you want to delete this criterion? This action cannot be undone.": "¿Está seguro de que desea eliminar este criterio? Esta acción no se puede deshacer.", - "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", + "This process is still in draft. Participant invites will be sent when the process launches.": "Este proceso aún está en borrador. Las invitaciones a los participantes se enviarán cuando se lance el proceso.", "{roleName} plural": "{roleName}s", - "Launch process?": "Launch process?", - "Launching your process will notify": "Launching your process will notify", - "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." + "Launch process?": "¿Lanzar proceso?", + "Launching your process will notify": "Al lanzar su proceso se notificará a", + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participante} other {# participantes}}." } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 61a033bfa..bdb36df7d 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -681,8 +681,8 @@ "Failed to remove user": "Échec de la suppression de l'utilisateur", "No one has been invited yet": "Personne n'a encore été invité", "Invited": "Invité", - "Pending launch": "Lancement en attente", - "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "Ce processus est encore en brouillon. Les participants ayant un accès en édition seront invités immédiatement. Les invitations des participants sans accès en édition seront envoyées au lancement du processus.", + "Pending launch": "En attente de lancement", + "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "Ce processus est encore en brouillon. Les participants ayant un accès en édition seront invités immédiatement, les invitations des participants sans accès en édition seront envoyées lors du lancement du processus.", "Failed to cancel invite": "Échec de l'annulation de l'invitation", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Définissez les catégories que les propositions de ce processus doivent faire avancer. Les proposants sélectionneront les catégories que leur proposition soutient.", "No categories defined yet": "Aucune catégorie définie pour le moment", @@ -917,9 +917,9 @@ "Score labels cannot be empty": "Les libellés des notes ne peuvent pas être vides", "Delete criterion": "Supprimer le critère", "Are you sure you want to delete this criterion? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce critère ? Cette action est irréversible.", - "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", + "This process is still in draft. Participant invites will be sent when the process launches.": "Ce processus est encore en brouillon. Les invitations aux participants seront envoyées lors du lancement du processus.", "{roleName} plural": "{roleName}s", - "Launch process?": "Launch process?", - "Launching your process will notify": "Launching your process will notify", + "Launch process?": "Lancer le processus ?", + "Launching your process will notify": "Le lancement de votre processus notifiera", "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 20d662081..63d5a3855 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -677,8 +677,8 @@ "Failed to remove user": "Falha ao remover usuário", "No one has been invited yet": "Ninguém foi convidado ainda", "Invited": "Convidado", - "Pending launch": "Lançamento pendente", - "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "Este processo ainda está em rascunho. Participantes com acesso de edição serão convidados imediatamente. Os convites de participantes sem acesso de edição serão enviados quando o processo for lançado.", + "Pending launch": "Pendente de lançamento", + "This process is still in draft. Participants with edit access will be invited immediately, Participant invites without edit access will be sent when the process launches.": "Este processo ainda está em rascunho. Os participantes com acesso de edição serão convidados imediatamente, os convites de participantes sem acesso de edição serão enviados quando o processo for lançado.", "Failed to cancel invite": "Falha ao cancelar convite", "Define the categories that proposals in this process should advance. Proposers will select which categories their proposal supports.": "Defina as categorias que as propostas neste processo devem avançar. Os proponentes selecionarão quais categorias sua proposta apoia.", "No categories defined yet": "Nenhuma categoria definida ainda", @@ -917,9 +917,9 @@ "Score labels cannot be empty": "Os rótulos das notas não podem estar vazios", "Delete criterion": "Excluir critério", "Are you sure you want to delete this criterion? This action cannot be undone.": "Tem certeza de que deseja excluir este critério? Esta ação não pode ser desfeita.", - "This process is still in draft. Participant invites will be sent when the process launches.": "This process is still in draft. Participant invites will be sent when the process launches.", + "This process is still in draft. Participant invites will be sent when the process launches.": "Este processo ainda está em rascunho. Os convites dos participantes serão enviados quando o processo for lançado.", "{roleName} plural": "{roleName}s", - "Launch process?": "Launch process?", - "Launching your process will notify": "Launching your process will notify", - "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." + "Launch process?": "Lançar processo?", + "Launching your process will notify": "O lançamento do seu processo notificará", + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participante} other {# participantes}}." } From 3bfb3bd2f88cf054777d04070679570cfbeb4b00 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 18:16:53 +0100 Subject: [PATCH 20/24] Simpler interfaces --- .../decisions/ProfileInviteModal.tsx | 18 +++++------------- .../decisions/ProfileUsersAccess.tsx | 2 +- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileInviteModal.tsx b/apps/app/src/components/decisions/ProfileInviteModal.tsx index 6d83c2941..6f758a3d8 100644 --- a/apps/app/src/components/decisions/ProfileInviteModal.tsx +++ b/apps/app/src/components/decisions/ProfileInviteModal.tsx @@ -3,7 +3,6 @@ import { getPublicUrl } from '@/utils'; import { trpc } from '@op/api/client'; import { EntityType } from '@op/api/encoders'; -import { ProcessStatus } from '@op/api/encoders'; import { useDebounce } from '@op/hooks'; import { AlertBanner } from '@op/ui/AlertBanner'; import { Avatar } from '@op/ui/Avatar'; @@ -51,12 +50,12 @@ type SelectedItemsByRole = Record; export const ProfileInviteModal = ({ profileId, - instanceId, + isDraft, isOpen, onOpenChange, }: { profileId: string; - instanceId?: string; + isDraft: boolean; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; }) => { @@ -90,7 +89,7 @@ export const ProfileInviteModal = ({ > @@ -101,11 +100,11 @@ export const ProfileInviteModal = ({ function ProfileInviteModalContent({ profileId, - instanceId, + isDraft, onOpenChange, }: { profileId: string; - instanceId?: string; + isDraft: boolean; onOpenChange: (isOpen: boolean) => void; }) { const t = useTranslations(); @@ -125,13 +124,6 @@ function ProfileInviteModalContent({ width: 0, }); - // Fetch process instance to check draft status - const { data: instance } = trpc.decision.getInstance.useQuery( - { instanceId: instanceId! }, - { enabled: !!instanceId }, - ); - const isDraft = instance?.status === ProcessStatus.DRAFT; - // Fetch roles with decisions zone permissions to identify admin roles const { data: rolesWithPerms } = trpc.profile.listRoles.useQuery( { profileId, zoneName: 'decisions' }, diff --git a/apps/app/src/components/decisions/ProfileUsersAccess.tsx b/apps/app/src/components/decisions/ProfileUsersAccess.tsx index 97401f408..8cc98ef1a 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccess.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccess.tsx @@ -155,7 +155,7 @@ export const ProfileUsersAccess = ({ From ce66f7b5bb308d9c03ecb9ef8cd34593a97c9b52 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 18:24:35 +0100 Subject: [PATCH 21/24] Remove non-null assertion --- apps/app/src/components/decisions/ProfileUsersAccess.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/decisions/ProfileUsersAccess.tsx b/apps/app/src/components/decisions/ProfileUsersAccess.tsx index 8cc98ef1a..0f956cfb8 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccess.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccess.tsx @@ -32,7 +32,7 @@ export const ProfileUsersAccess = ({ processName, }: { profileId: string; - instanceId?: string; + instanceId: string; processName?: string; }) => { const t = useTranslations(); @@ -80,10 +80,9 @@ export const ProfileUsersAccess = ({ trpc.profile.listRoles.useQuery({ profileId }); // Check if process is in draft status - const { data: instance } = trpc.decision.getInstance.useQuery( - { instanceId: instanceId! }, - { enabled: !!instanceId }, - ); + const { data: instance } = trpc.decision.getInstance.useQuery({ + instanceId, + }); const isDraft = instance?.status === ProcessStatus.DRAFT; // Fetch pending invites to show alongside accepted members From 2c161095471187ffaee344d962e6d963316a2a14 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 18:34:38 +0100 Subject: [PATCH 22/24] Pull out invite email message --- .../services/profile/inviteUsersToProfile.ts | 89 +++++++++---------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/packages/common/src/services/profile/inviteUsersToProfile.ts b/packages/common/src/services/profile/inviteUsersToProfile.ts index 9bbb2bd49..a16d779ee 100644 --- a/packages/common/src/services/profile/inviteUsersToProfile.ts +++ b/packages/common/src/services/profile/inviteUsersToProfile.ts @@ -198,37 +198,29 @@ export const inviteUsersToProfile = async ({ existingPendingInvites.map((invite) => invite.email.toLowerCase()), ); - // Collect data for batch insert + // Collect data for batch operations const allowListEntries: Array<{ email: string; organizationId: null; metadata: null; }> = []; - const profileInviteEntries: Array<{ + const preparedInvites: Array<{ email: string; - profileId: string; - profileEntityType: string; accessRoleId: string; - invitedBy: string; inviteeProfileId?: string; - message?: string; - }> = []; - - const emailsToInvite: Array<{ - email: string; authUserId?: string; - inviterName: string; - profileName: string; - inviteUrl: string; - personalMessage?: string; }> = []; // Process each invitation - validate and collect data for (const invitation of normalizedInvitations) { const { email, roleId } = invitation; const existingUser = usersByEmail.get(email); - const targetRole = rolesById.get(roleId)!; + const targetRole = rolesById.get(roleId); + + if (!targetRole) { + continue; + } // Check for pending invite (applies to both existing and new users) if (pendingInviteEmailsSet.has(email)) { @@ -262,25 +254,11 @@ export const inviteUsersToProfile = async ({ allowListEmailsSet.add(email); } - // Collect profile invite entry - profileInviteEntries.push({ + preparedInvites.push({ email, - profileId, - profileEntityType: profile.type, accessRoleId: targetRole.id, - invitedBy: requesterProfileId, inviteeProfileId: existingUser?.profileId ?? undefined, - message: personalMessage, - }); - - // Collect email data for event - emailsToInvite.push({ - email, authUserId: existingUser?.authUserId, - inviterName, - profileName, - inviteUrl, - personalMessage, }); } @@ -296,27 +274,35 @@ export const inviteUsersToProfile = async ({ // Batch insert and send event in a single transaction // If event.send fails, we rollback the DB inserts - if (profileInviteEntries.length > 0) { + if (preparedInvites.length > 0) { await db.transaction(async (tx) => { if (allowListEntries.length > 0) { await tx.insert(allowList).values(allowListEntries); } + const insertedInvites = await tx .insert(profileInvites) - .values(profileInviteEntries) + .values( + preparedInvites.map((inv) => ({ + email: inv.email, + profileId, + profileEntityType: profile.type, + accessRoleId: inv.accessRoleId, + invitedBy: requesterProfileId, + inviteeProfileId: inv.inviteeProfileId, + message: personalMessage, + })), + ) .returning({ id: profileInvites.id }); // Send email events only for invites that should be notified immediately - const notifiedInvites = profileInviteEntries.flatMap((entry, idx) => - !isDraft || getAdminRoleIds().has(entry.accessRoleId) - ? [ - { - inviteId: insertedInvites[idx]!.id, - email: emailsToInvite[idx]!, - }, - ] - : [], - ); + const notifiedInvites = preparedInvites + .map((inv, idx) => ({ inv, inviteId: insertedInvites[idx]?.id })) + .filter( + (entry): entry is typeof entry & { inviteId: string } => + !!entry.inviteId && + (!isDraft || getAdminRoleIds().has(entry.inv.accessRoleId)), + ); if (notifiedInvites.length > 0) { await event.send({ @@ -324,19 +310,28 @@ export const inviteUsersToProfile = async ({ data: { senderProfileId: requesterProfileId, inviteIds: notifiedInvites.map((n) => n.inviteId), - invitations: notifiedInvites.map((n) => n.email), + invitations: notifiedInvites.map((n) => ({ + email: n.inv.email, + authUserId: n.inv.authUserId, + inviterName, + profileName, + inviteUrl, + personalMessage, + })), }, }); } }); // Mark all as successful since transaction completed - results.successful.push(...emailsToInvite.map((e) => e.email)); + results.successful.push(...preparedInvites.map((inv) => inv.email)); // Collect auth user IDs for existing users (for cache invalidation) results.existingUserAuthIds.push( - ...emailsToInvite - .filter((e): e is typeof e & { authUserId: string } => !!e.authUserId) - .map((e) => e.authUserId), + ...preparedInvites + .filter( + (inv): inv is typeof inv & { authUserId: string } => !!inv.authUserId, + ) + .map((inv) => inv.authUserId), ); } From e20e99fbd0d74bec717935731eb4fe95130434cd Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 18:39:54 +0100 Subject: [PATCH 23/24] Extract function --- .../services/profile/inviteUsersToProfile.ts | 129 ++++++++++++------ 1 file changed, 84 insertions(+), 45 deletions(-) diff --git a/packages/common/src/services/profile/inviteUsersToProfile.ts b/packages/common/src/services/profile/inviteUsersToProfile.ts index a16d779ee..3d8ce6b91 100644 --- a/packages/common/src/services/profile/inviteUsersToProfile.ts +++ b/packages/common/src/services/profile/inviteUsersToProfile.ts @@ -10,6 +10,64 @@ import { getProfileAccessUser } from '../access'; import { assertProfile } from '../assert'; import { decisionPermission } from '../decision/permissions'; +/** + * Determines which invites should be notified immediately and sends the + * Inngest event. Draft processes queue non-admin invites until publish. + */ +const sendInviteNotifications = async ({ + preparedInvites, + insertedInviteIds, + isDraft, + adminRoleIds, + senderProfileId, + inviterName, + profileName, + inviteUrl, + personalMessage, +}: { + preparedInvites: Array<{ + email: string; + accessRoleId: string; + authUserId?: string; + }>; + insertedInviteIds: Array<{ id: string }>; + isDraft: boolean; + adminRoleIds: Set; + senderProfileId: string; + inviterName: string; + profileName: string; + inviteUrl: string; + personalMessage?: string; +}) => { + const notifiedInvites = preparedInvites + .map((inv, idx) => ({ inv, inviteId: insertedInviteIds[idx]?.id })) + .filter( + (entry): entry is typeof entry & { inviteId: string } => + !!entry.inviteId && + (!isDraft || adminRoleIds.has(entry.inv.accessRoleId)), + ); + + if (notifiedInvites.length === 0) { + return; + } + + await event.send({ + name: Events.profileInviteSent.name, + data: { + senderProfileId, + inviteIds: notifiedInvites.map((n) => n.inviteId), + invitations: notifiedInvites.map((n) => ({ + email: n.inv.email, + authUserId: n.inv.authUserId, + inviterName, + profileName, + inviteUrl, + personalMessage, + })), + }, + }); +}; + // Utility function to generate consistent result messages const generateInviteResultMessage = ( successCount: number, @@ -142,11 +200,19 @@ export const inviteUsersToProfile = async ({ ); } - // adminRoleIds is computed lazily — only needed when the process is in draft - let _adminRoleIds: Set | undefined; - const getAdminRoleIds = () => { - if (!_adminRoleIds) { - _adminRoleIds = new Set( + // Determine if emails should be sent immediately or queued. + // Invites to draft processes are queued (notifiedAt=null) until publish, + // UNLESS the role being assigned includes decisions: ADMIN. + // Check both proposal-level (via proposal -> processInstance) and + // decision-level (direct processInstance profile) relationships. + const processInstanceStatus = + proposalWithDecision?.processInstance?.status ?? + processInstanceForProfile?.status; + const isDraft = processInstanceStatus === ProcessStatus.DRAFT; + + // Only needed when the process is in draft to determine which invites to queue + const adminRoleIds = isDraft + ? new Set( targetRoles .filter((role) => role.zonePermissions.some( @@ -156,10 +222,8 @@ export const inviteUsersToProfile = async ({ ), ) .map((role) => role.id), - ); - } - return _adminRoleIds; - }; + ) + : new Set(); const results = { successful: [] as string[], @@ -262,16 +326,6 @@ export const inviteUsersToProfile = async ({ }); } - // Determine if emails should be sent immediately or queued. - // Invites to draft processes are queued (notifiedAt=null) until publish, - // UNLESS the role being assigned includes decisions: ADMIN. - // Check both proposal-level (via proposal -> processInstance) and - // decision-level (direct processInstance profile) relationships. - const processInstanceStatus = - proposalWithDecision?.processInstance?.status ?? - processInstanceForProfile?.status; - const isDraft = processInstanceStatus === ProcessStatus.DRAFT; - // Batch insert and send event in a single transaction // If event.send fails, we rollback the DB inserts if (preparedInvites.length > 0) { @@ -295,32 +349,17 @@ export const inviteUsersToProfile = async ({ ) .returning({ id: profileInvites.id }); - // Send email events only for invites that should be notified immediately - const notifiedInvites = preparedInvites - .map((inv, idx) => ({ inv, inviteId: insertedInvites[idx]?.id })) - .filter( - (entry): entry is typeof entry & { inviteId: string } => - !!entry.inviteId && - (!isDraft || getAdminRoleIds().has(entry.inv.accessRoleId)), - ); - - if (notifiedInvites.length > 0) { - await event.send({ - name: Events.profileInviteSent.name, - data: { - senderProfileId: requesterProfileId, - inviteIds: notifiedInvites.map((n) => n.inviteId), - invitations: notifiedInvites.map((n) => ({ - email: n.inv.email, - authUserId: n.inv.authUserId, - inviterName, - profileName, - inviteUrl, - personalMessage, - })), - }, - }); - } + await sendInviteNotifications({ + preparedInvites, + insertedInviteIds: insertedInvites, + isDraft, + adminRoleIds, + senderProfileId: requesterProfileId, + inviterName, + profileName, + inviteUrl, + personalMessage, + }); }); // Mark all as successful since transaction completed From ac0f324f97c665136b16f38ed9322d2fc935cef4 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Fri, 6 Mar 2026 18:49:01 +0100 Subject: [PATCH 24/24] Remove non-null assertion --- .../src/services/decision/updateDecisionInstance.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/common/src/services/decision/updateDecisionInstance.ts b/packages/common/src/services/decision/updateDecisionInstance.ts index feaa5c495..3ce1192c7 100644 --- a/packages/common/src/services/decision/updateDecisionInstance.ts +++ b/packages/common/src/services/decision/updateDecisionInstance.ts @@ -263,7 +263,7 @@ export const updateDecisionInstance = async ({ const invitations = queuedInvites.map((invite) => ({ email: invite.email, - inviterName: invite.inviter.name || 'A team member', + inviterName: invite.inviter?.name || 'A team member', profileName: invite.profile.name, inviteUrl: profile.slug ? `${baseUrl}/decisions/${profile.slug}` @@ -272,7 +272,11 @@ export const updateDecisionInstance = async ({ })); // Use the first invite's inviter as the sender - const senderProfileId = queuedInvites[0]!.invitedBy; + const firstInvite = queuedInvites[0]; + if (!firstInvite) { + return; + } + const senderProfileId = firstInvite.invitedBy; await event.send({ name: Events.profileInviteSent.name,