Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
31b908d
feat: [US-002] - Suppress email sending for invites to draft processes
scazan Mar 4, 2026
c874b74
feat: [US-003] - Send queued invite emails on process publish
scazan Mar 4, 2026
8356de5
feat: [US-004] - Update Inngest workflow to mark invites as notified
scazan Mar 4, 2026
59b8229
feat: [US-005] - Hide unnotified invites from invitee personal invite…
scazan Mar 4, 2026
67ab7a3
feat: [US-006] - Show notified status in admin participant list
scazan Mar 4, 2026
70f9201
fix: remove premature notified flag update in updateDecisionInstance
scazan Mar 4, 2026
91f8f92
Bypass draft queueing for admins
scazan Mar 4, 2026
e9ec222
Update tests
scazan Mar 5, 2026
8f3a539
Use proper color for pending
scazan Mar 5, 2026
87d32ec
Add in banner
scazan Mar 5, 2026
737f0e3
Add in alert to top of invites for non-notified
scazan Mar 5, 2026
7e7d514
Pull in AlertBanner from dev and rebase
scazan Mar 6, 2026
927439b
Switch to notified_at
scazan Mar 6, 2026
2e02c03
pluralize tab labels
scazan Mar 6, 2026
973d0c2
Update positioning of AlertBanners
scazan Mar 6, 2026
67e23d4
Show modal in the case of pending notifications
scazan Mar 6, 2026
cfb022a
Show how many will be notified
scazan Mar 6, 2026
c5ada42
Use proper isDraft check
scazan Mar 6, 2026
dc0d384
Translations and better component
scazan Mar 6, 2026
3bfb3bd
Simpler interfaces
scazan Mar 6, 2026
ce66f7b
Remove non-null assertion
scazan Mar 6, 2026
2c16109
Pull out invite email message
scazan Mar 6, 2026
e20e99f
Extract function
scazan Mar 6, 2026
ac0f324
Remove non-null assertion
scazan Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -33,6 +34,14 @@ export const LaunchProcessModal = ({
(s) => s.instances[decisionProfileId],
);

const { data: invites, isLoading: invitesLoading } =
trpc.profile.listProfileInvites.useQuery(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to note this is a temporary thing until guided UX is in place

{ 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;
Expand All @@ -59,14 +68,28 @@ export const LaunchProcessModal = ({

return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
<ModalHeader>{t('Launch Process')}</ModalHeader>
<ModalHeader>{t('Launch process?')}</ModalHeader>
<ModalBody className="flex flex-col gap-4">
<p className="text-neutral-charcoal">
{t(
'This will open {processName} for proposal submissions. Participants will be notified and can begin submitting proposals.',
{ processName },
)}
</p>
{invitesLoading ? (
<Skeleton className="h-6 w-full" />
) : pendingNotificationCount > 0 ? (
<p className="text-neutral-charcoal">
{t('Launching your process will notify')}{' '}
<span className="font-bold">
{t(
'{count, plural, =1 {1 participant} other {# participants}}.',
{ count: pendingNotificationCount },
)}
</span>
</p>
) : (
<p className="text-neutral-charcoal">
{t(
'This will open {processName} for proposal submissions. Participants will be notified and can begin submitting proposals.',
{ processName },
)}
</p>
)}

{/* Summary Section */}
<div className="flex flex-col gap-2 rounded-lg border border-neutral-gray1 p-4">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import type { SectionProps } from '../../contentRegistry';

export default function ParticipantsSection({
decisionProfileId,
instanceId,
decisionName,
}: SectionProps) {
return (
<div className="px-4 md:px-24 md:py-16">
<div className="mx-auto max-w-5xl">
<ProfileUsersAccess
profileId={decisionProfileId}
instanceId={instanceId}
processName={decisionName}
/>
</div>
Expand Down
30 changes: 30 additions & 0 deletions apps/app/src/components/decisions/ProfileInviteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getPublicUrl } from '@/utils';
import { trpc } from '@op/api/client';
import { EntityType } 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';
Expand Down Expand Up @@ -49,10 +50,12 @@ type SelectedItemsByRole = Record<string, SelectedItem[]>;

export const ProfileInviteModal = ({
profileId,
isDraft,
isOpen,
onOpenChange,
}: {
profileId: string;
isDraft: boolean;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}) => {
Expand Down Expand Up @@ -86,6 +89,7 @@ export const ProfileInviteModal = ({
>
<ProfileInviteModalContent
profileId={profileId}
isDraft={isDraft}
onOpenChange={onOpenChange}
/>
</Suspense>
Expand All @@ -96,9 +100,11 @@ export const ProfileInviteModal = ({

function ProfileInviteModalContent({
profileId,
isDraft,
onOpenChange,
}: {
profileId: string;
isDraft: boolean;
onOpenChange: (isOpen: boolean) => void;
}) {
const t = useTranslations();
Expand All @@ -118,6 +124,22 @@ function ProfileInviteModalContent({
width: 0,
});

// 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<string>();
}
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,
Expand Down Expand Up @@ -419,6 +441,14 @@ function ProfileInviteModalContent({
onRoleNameChange={setSelectedRoleName}
/>

{showDraftBanner && (
<AlertBanner variant="banner" intent="warning">
{t(
'This process is still in draft. Participant invites will be sent when the process launches.',
)}
</AlertBanner>
)}

{/* Search Input */}
<div ref={searchContainerRef} onPaste={handlePaste}>
<SearchField
Expand Down
44 changes: 32 additions & 12 deletions apps/app/src/components/decisions/ProfileUsersAccess.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

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';
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';
Expand All @@ -25,9 +28,11 @@ const ITEMS_PER_PAGE = 25;

export const ProfileUsersAccess = ({
profileId,
instanceId,
processName,
}: {
profileId: string;
instanceId: string;
processName?: string;
}) => {
const t = useTranslations();
Expand Down Expand Up @@ -74,6 +79,12 @@ 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,
});
const isDraft = instance?.status === ProcessStatus.DRAFT;

// Fetch pending invites to show alongside accepted members
const { data: invites } = trpc.profile.listProfileInvites.useQuery(
{ profileId },
Expand All @@ -92,10 +103,26 @@ export const ProfileUsersAccess = ({
return (
<ClientOnly fallback={<Skeleton className="h-64 w-full" />}>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="font-serif text-title-sm font-light text-neutral-black">
{t('Participants')}
</h2>
<Header2 className="font-serif text-title-sm">
{t('Participants')}
</Header2>

{isDraft && (
<AlertBanner variant="banner" intent="warning">
{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.',
)}
</AlertBanner>
)}

<div className="flex items-center justify-between gap-4">
<SearchField
placeholder={t('Search')}
value={searchQuery}
onChange={setSearchQuery}
size={isMobile ? 'small' : undefined}
className="w-full md:max-w-96"
/>
<Button
color="secondary"
size="small"
Expand All @@ -106,14 +133,6 @@ export const ProfileUsersAccess = ({
</Button>
</div>

<SearchField
placeholder={t('Search')}
value={searchQuery}
onChange={setSearchQuery}
size={isMobile ? 'small' : undefined}
className="w-full md:max-w-96"
/>

<ProfileUsersAccessTable
profileUsers={profileUsers}
profileId={profileId}
Expand All @@ -135,6 +154,7 @@ export const ProfileUsersAccess = ({

<ProfileInviteModal
profileId={profileId}
isDraft={isDraft}
isOpen={isInviteModalOpen}
onOpenChange={setIsInviteModalOpen}
/>
Expand Down
40 changes: 21 additions & 19 deletions apps/app/src/components/decisions/ProfileUsersAccessTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,16 @@ export const ProfileUsersAccessTable = ({
);
}

if (isMobile) {
return (
<MobileProfileUsersContent
profileUsers={profileUsers}
profileId={profileId}
isLoading={isLoading}
roles={roles}
invites={invites}
processName={processName}
/>
);
}

return (
return isMobile ? (
<MobileProfileUsersContent
profileUsers={profileUsers}
profileId={profileId}
isLoading={isLoading}
roles={roles}
invites={invites}
processName={processName}
/>
) : (
<ProfileUsersAccessTableContent
profileUsers={profileUsers}
profileId={profileId}
Expand All @@ -99,6 +95,15 @@ export const ProfileUsersAccessTable = ({
);
};

const InviteStatusLabel = ({ notifiedAt }: { notifiedAt: string | null }) => {
const t = useTranslations();
return (
<span className="text-sm text-neutral-gray4">
{notifiedAt ? t('Invited') : t('Pending launch')}
</span>
);
};

const getProfileUserStatus = (): string => {
// TODO: We need this logic in the backend
// Default to "Active" for existing profile users
Expand Down Expand Up @@ -424,7 +429,6 @@ const MobileInviteCard = ({
roles: { id: string; name: string }[];
processName?: string;
}) => {
const t = useTranslations();
const displayName = invite.inviteeProfile?.name ?? invite.email;

return (
Expand All @@ -434,7 +438,7 @@ const MobileInviteCard = ({
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-col">
<span className="text-base text-neutral-black">{displayName}</span>
<span className="text-sm text-neutral-gray4">{t('Invited')}</span>
<InviteStatusLabel notifiedAt={invite.notifiedAt} />
</div>
<span className="truncate text-base text-neutral-black">
{invite.email}
Expand Down Expand Up @@ -557,9 +561,7 @@ const ProfileUsersAccessTableContent = ({
<span className="text-base text-neutral-black">
{displayName}
</span>
<span className="text-sm text-neutral-gray4">
{t('Invited')}
</span>
<InviteStatusLabel notifiedAt={invite.notifiedAt} />
</div>
</div>
</TableCell>
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/components/decisions/RoleSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const RoleSelector = ({
return (
<Tab key={role.id} id={role.id}>
<span className="flex items-center gap-1">
{role.name}
{t('{roleName} plural', { roleName: role.name })}
{count > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary-teal px-1 text-xs text-neutral-offWhite">
{count}
Expand Down
9 changes: 8 additions & 1 deletion apps/app/src/lib/i18n/dictionaries/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,8 @@
"Failed to remove user": "ব্যবহারকারী সরাতে ব্যর্থ",
"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.": "এই প্রক্রিয়াটি এখনও খসড়া অবস্থায় আছে। সম্পাদনা অ্যাক্সেস সহ অংশগ্রহণকারীদের অবিলম্বে আমন্ত্রণ জানানো হবে, সম্পাদনা অ্যাক্সেস ছাড়া অংশগ্রহণকারীদের আমন্ত্রণ প্রক্রিয়া চালু হলে পাঠানো হবে।",
"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.": "এই প্রক্রিয়ায় প্রস্তাবগুলি যে বিভাগগুলি এগিয়ে নেওয়া উচিত তা সংজ্ঞায়িত করুন। প্রস্তাবকারীরা তাদের প্রস্তাব কোন বিভাগগুলি সমর্থন করে তা নির্বাচন করবেন।",
Expand Down Expand Up @@ -921,5 +923,10 @@
"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.": "এই প্রক্রিয়াটি এখনও খসড়া অবস্থায় আছে। প্রক্রিয়া চালু হলে অংশগ্রহণকারীদের আমন্ত্রণ পাঠানো হবে।",
"{roleName} plural": "{roleName}",
"Launch process?": "প্রক্রিয়া চালু করবেন?",
"Launching your process will notify": "আপনার প্রক্রিয়া চালু করলে বিজ্ঞপ্তি পাবেন",
"{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 জন অংশগ্রহণকারী} other {# জন অংশগ্রহণকারী}}।"
}
9 changes: 8 additions & 1 deletion apps/app/src/lib/i18n/dictionaries/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +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 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",
Expand Down Expand Up @@ -914,5 +916,10 @@
"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",
"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}}."
}
9 changes: 8 additions & 1 deletion apps/app/src/lib/i18n/dictionaries/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +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": "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",
Expand Down Expand Up @@ -914,5 +916,10 @@
"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.": "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?": "¿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}}."
}
Loading
Loading