diff --git a/services/api/src/routers/decision/proposals/submit.ts b/services/api/src/routers/decision/proposals/submit.ts index a43679a74..57801f9cd 100644 --- a/services/api/src/routers/decision/proposals/submit.ts +++ b/services/api/src/routers/decision/proposals/submit.ts @@ -1,4 +1,5 @@ import { submitProposal } from '@op/common'; +import { Events, inngest } from '@op/events'; import { waitUntil } from '@vercel/functions'; import { z } from 'zod'; @@ -29,6 +30,14 @@ export const submitProposalRouter = router({ }), ); + // Send proposal submitted event for notification workflow + waitUntil( + inngest.send({ + name: Events.proposalSubmitted.name, + data: { proposalId: proposal.id }, + }), + ); + return proposalEncoder.parse(proposal); }), }); diff --git a/services/api/src/test/setup.ts b/services/api/src/test/setup.ts index 2abe600f6..0976d6588 100644 --- a/services/api/src/test/setup.ts +++ b/services/api/src/test/setup.ts @@ -60,10 +60,14 @@ const mockPlatformAdminEmails = { // Mock the event system to avoid Inngest API calls in tests vi.mock('@op/events', async () => { const actual = await vi.importActual('@op/events'); + const mockSend = vi.fn().mockResolvedValue({ ids: ['mock-event-id'] }); return { ...actual, + inngest: { + send: mockSend, + }, event: { - send: vi.fn().mockResolvedValue({ ids: ['mock-event-id'] }), + send: mockSend, }, }; }); diff --git a/services/emails/emails/ProposalSubmittedEmail.tsx b/services/emails/emails/ProposalSubmittedEmail.tsx new file mode 100644 index 000000000..5730f9bd6 --- /dev/null +++ b/services/emails/emails/ProposalSubmittedEmail.tsx @@ -0,0 +1,66 @@ +import { Button, Heading, Section, Text } from '@react-email/components'; + +import EmailTemplate from '../components/EmailTemplate'; + +export const ProposalSubmittedEmail = ({ + proposalName, + processTitle, + proposalUrl = 'https://common.oneproject.org/', +}: { + proposalName?: string | null; + processTitle?: string | null; + proposalUrl: string; +}) => { + const displayName = proposalName || 'Your proposal'; + + return ( + + + Proposal Submitted + + + Your proposal {displayName} has been submitted + {processTitle ? ( + <> + {' '} + to {processTitle} + + ) : null} + . + + +
+ +
+
+ ); +}; + +ProposalSubmittedEmail.subject = ( + proposalName?: string | null, + processTitle?: string | null, +) => { + const displayName = proposalName || 'Your proposal'; + if (processTitle) { + return `Your proposal "${displayName}" has been submitted to ${processTitle}`; + } + return `Your proposal "${displayName}" has been submitted`; +}; + +export default ProposalSubmittedEmail; diff --git a/services/emails/index.tsx b/services/emails/index.tsx index caaa38363..28e159df8 100644 --- a/services/emails/index.tsx +++ b/services/emails/index.tsx @@ -115,3 +115,4 @@ export * from './emails/OPInvitationEmail'; export * from './emails/OPRelationshipRequestEmail'; export * from './emails/CommentNotificationEmail'; export * from './emails/ReactionNotificationEmail'; +export * from './emails/ProposalSubmittedEmail'; diff --git a/services/events/src/types.ts b/services/events/src/types.ts index d69427dec..ef60fd608 100644 --- a/services/events/src/types.ts +++ b/services/events/src/types.ts @@ -42,4 +42,10 @@ export const Events = { ), }), }, + proposalSubmitted: { + name: 'proposal/submitted' as const, + schema: z.object({ + proposalId: z.string().uuid(), + }), + }, } as const; diff --git a/services/workflows/src/functions/notifications/index.ts b/services/workflows/src/functions/notifications/index.ts index c84004652..8d4d4f4f6 100644 --- a/services/workflows/src/functions/notifications/index.ts +++ b/services/workflows/src/functions/notifications/index.ts @@ -1,2 +1,3 @@ export * from './sendReactionNotification'; export * from './sendProfileInviteEmails'; +export * from './sendProposalSubmittedNotification'; diff --git a/services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts b/services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts new file mode 100644 index 000000000..b5d54ed66 --- /dev/null +++ b/services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts @@ -0,0 +1,118 @@ +import { OPURLConfig } from '@op/core'; +import { db } from '@op/db/client'; +import { + processInstances, + profileUsers, + profiles, + proposals, +} from '@op/db/schema'; +import { OPBatchSend, ProposalSubmittedEmail } from '@op/emails'; +import { Events, inngest } from '@op/events'; +import { eq } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; + +const { proposalSubmitted } = Events; + +export const sendProposalSubmittedNotification = inngest.createFunction( + { + id: 'sendProposalSubmittedNotification', + debounce: { + key: 'event.data.proposalId', + period: '1m', + timeout: '3m', + }, + }, + { event: proposalSubmitted.name }, + async ({ event, step }) => { + const { proposalId } = proposalSubmitted.schema.parse(event.data); + + const proposalProfile = alias(profiles, 'proposal_profile'); + const processProfile = alias(profiles, 'process_profile'); + + // Step 1: Get proposal, its profile name, and the process instance profile (name + slug) + const proposalData = await step.run('get-proposal-data', async () => { + const result = await db + .select({ + proposalProfileId: proposals.profileId, + proposalProfileName: proposalProfile.name, + processProfileName: processProfile.name, + processProfileSlug: processProfile.slug, + }) + .from(proposals) + .innerJoin(proposalProfile, eq(proposals.profileId, proposalProfile.id)) + .innerJoin( + processInstances, + eq(proposals.processInstanceId, processInstances.id), + ) + .innerJoin( + processProfile, + eq(processInstances.profileId, processProfile.id), + ) + .where(eq(proposals.id, proposalId)) + .limit(1); + + return result[0]; + }); + + if (!proposalData) { + console.log('No proposal data found for proposal:', proposalId); + return; + } + + // Step 2: Get all collaborator emails + const collaborators = await step.run('get-collaborators', async () => { + return db + .select({ + email: profileUsers.email, + }) + .from(profileUsers) + .where(eq(profileUsers.profileId, proposalData.proposalProfileId)); + }); + + if (collaborators.length === 0) { + console.log('No collaborators found for proposal:', proposalId); + return; + } + + const proposalUrl = `${OPURLConfig('APP').ENV_URL}/decisions/${proposalData.processProfileSlug}/proposal/${proposalData.proposalProfileId}`; + + // Step 3: Send notification emails to all collaborators + const result = await step.run('send-emails', async () => { + try { + const emails = collaborators.map(({ email }) => ({ + to: email, + subject: ProposalSubmittedEmail.subject( + proposalData.proposalProfileName, + proposalData.processProfileName, + ), + component: () => + ProposalSubmittedEmail({ + proposalName: proposalData.proposalProfileName, + processTitle: proposalData.processProfileName, + proposalUrl, + }), + })); + + const { data, errors } = await OPBatchSend(emails); + + if (errors.length > 0) { + throw Error(`Email batch failed: ${JSON.stringify(errors)}`); + } + + return { + sent: data.length, + }; + } catch (error) { + console.error('Failed to send proposal submitted notifications:', { + error, + proposalId, + }); + throw error; + } + }); + + return { + message: `${result.sent} proposal submitted notification(s) sent`, + }; + }, +);