From 7e2b2369ab423f3c39a01e8c28aef3925715c10e Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 2 Mar 2026 16:25:01 +0100 Subject: [PATCH 01/10] feat: [US-001] - Define proposal submission event schema --- services/events/src/types.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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; From 05a648fadeeb58978324d41083388fd1fc312eae Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 2 Mar 2026 16:26:04 +0100 Subject: [PATCH 02/10] feat: [US-002] - Create proposal submission email template --- .../emails/emails/ProposalSubmittedEmail.tsx | 45 +++++++++++++++++++ services/emails/index.tsx | 1 + 2 files changed, 46 insertions(+) create mode 100644 services/emails/emails/ProposalSubmittedEmail.tsx diff --git a/services/emails/emails/ProposalSubmittedEmail.tsx b/services/emails/emails/ProposalSubmittedEmail.tsx new file mode 100644 index 000000000..6dadb4b75 --- /dev/null +++ b/services/emails/emails/ProposalSubmittedEmail.tsx @@ -0,0 +1,45 @@ +import { Button, Section, Text } from '@react-email/components'; + +import EmailTemplate from '../components/EmailTemplate'; + +export const ProposalSubmittedEmail = ({ + proposalName = 'Your proposal', + processTitle = 'a decision process', + proposalUrl = 'https://common.oneproject.org/', +}: { + proposalName: string; + processTitle: string; + proposalUrl: string; +}) => { + return ( + + + Your proposal {proposalName} has been submitted to{' '} + {processTitle}. + + +
+ +
+
+ ); +}; + +ProposalSubmittedEmail.subject = ( + proposalName: string, + processTitle: string, +) => `Your proposal "${proposalName}" has been submitted to ${processTitle}`; + +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'; From b0a2be64a8e33e5b65b7e1169da3cca653a2535d Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 2 Mar 2026 16:28:17 +0100 Subject: [PATCH 03/10] feat: [US-003] - Create workflow function to send submission notifications --- .../src/functions/notifications/index.ts | 1 + .../sendProposalSubmittedNotification.ts | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts 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..a1c5e7985 --- /dev/null +++ b/services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts @@ -0,0 +1,120 @@ +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, + failed: errors.length, + errors, + }; + } catch (error) { + console.error('Failed to send proposal submitted notifications:', { + error, + proposalId, + }); + throw error; + } + }); + + return { + message: `${result.sent} proposal submitted notification(s) sent, ${result.failed} failed`, + }; + }, +); From c191e630f8a73d35ad4f6b719de4ad483b81f5fd Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 2 Mar 2026 16:29:04 +0100 Subject: [PATCH 04/10] feat: [US-004] - Emit proposal submitted event from tRPC mutation --- services/api/src/routers/decision/proposals/submit.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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); }), }); From 395ea90c40e87ccff0af5d89429e81cfc71604c3 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 2 Mar 2026 16:29:43 +0100 Subject: [PATCH 05/10] chore: update PRD and progress log - all stories complete --- scripts/prd.json | 73 ++++++++++++++++++++++++++++++++++++++++++++ scripts/progress.txt | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 scripts/prd.json create mode 100644 scripts/progress.txt diff --git a/scripts/prd.json b/scripts/prd.json new file mode 100644 index 000000000..72fb4446a --- /dev/null +++ b/scripts/prd.json @@ -0,0 +1,73 @@ +{ + "project": "Common", + "branchName": "ralph/proposal-submission-notifications", + "description": "Proposal Submission Notification Emails - Send email notifications to all proposal collaborators when a proposal is submitted to a decision process", + "userStories": [ + { + "id": "US-001", + "title": "Define proposal submission event schema", + "description": "As a developer, I need an Inngest event for proposal submission so the workflow system can trigger notifications.", + "acceptanceCriteria": [ + "Add `proposalSubmitted` event to `services/events/src/types.ts` with schema containing `proposalId` (z.string().uuid())", + "Event name is `proposal/submitted` following existing naming convention (e.g., `proposal/export-requested`, `profile/invites-sent`)", + "Typecheck passes" + ], + "priority": 1, + "passes": true, + "notes": "" + }, + { + "id": "US-002", + "title": "Create proposal submission email template", + "description": "As a collaborator on a proposal, I want to receive a well-styled email so I know my proposal was submitted to the decision process.", + "acceptanceCriteria": [ + "New email component at `services/emails/emails/ProposalSubmittedEmail.tsx`", + "Uses `EmailTemplate` wrapper component for consistent branding (import from `../components/EmailTemplate`)", + "Props: `proposalName` (string), `processTitle` (string), `proposalUrl` (string)", + "Static `.subject` method that returns: `Your proposal \"{proposalName}\" has been submitted to {processTitle}`", + "Body text: bold proposal name and process title, followed by a 'View proposal' CTA button", + "Button styled identically to CommentNotificationEmail: `rounded-lg bg-primary-teal px-4 py-3 text-white no-underline hover:bg-primary-teal/90`", + "Export the component from `services/emails/index.tsx`", + "Typecheck passes" + ], + "priority": 2, + "passes": true, + "notes": "" + }, + { + "id": "US-003", + "title": "Create workflow function to send submission notifications", + "description": "As a developer, I need an Inngest function that listens for the proposal/submitted event, queries all collaborators, and sends them the notification email.", + "acceptanceCriteria": [ + "New function at `services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts`", + "Uses `inngest.createFunction` listening for the `proposal/submitted` event (import `Events` and `inngest` from `@op/events`)", + "Debounce config: `key: 'event.data.proposalId'`, `period: '1m'`, `timeout: '3m'`", + "Step 1: Query proposal with relations to get proposal `profileId`, proposal profile `name`, process instance with its profile `name` (process title) and `slug`", + "Step 2: Query all `profileUsers` where `profileId` matches the proposal's `profileId` to get collaborator emails", + "Build proposal URL: `OPURLConfig('APP').ENV_URL + '/decisions/{slug}/proposal/{profileId}'`", + "Use `OPBatchSend` (from `@op/emails`) to send emails to all collaborators", + "Each email uses `ProposalSubmittedEmail` component with correct props", + "Log errors and re-throw for Inngest retries, following pattern from `sendReactionNotification.ts`", + "Export from `services/workflows/src/functions/notifications/index.ts`", + "Typecheck passes" + ], + "priority": 3, + "passes": true, + "notes": "" + }, + { + "id": "US-004", + "title": "Emit proposal submitted event from tRPC mutation", + "description": "As a developer, I need the submit proposal tRPC mutation to fire the Inngest event so the workflow triggers.", + "acceptanceCriteria": [ + "In `services/api/src/routers/decision/proposals/submit.ts`, import `inngest` from `@op/events` and `Events`", + "After the successful `submitProposal` call, send the `proposal/submitted` event with `{ proposalId: proposal.id }`", + "Use `waitUntil` pattern (from `@vercel/functions`) consistent with existing analytics tracking in the same file", + "Typecheck passes" + ], + "priority": 4, + "passes": true, + "notes": "" + } + ] +} diff --git a/scripts/progress.txt b/scripts/progress.txt new file mode 100644 index 000000000..d012e7292 --- /dev/null +++ b/scripts/progress.txt @@ -0,0 +1,54 @@ +# Ralph Progress Log +Started: Mon Mar 2 16:23:28 CET 2026 +## Codebase Patterns +- Events are defined in `services/events/src/types.ts` as a `const` object with `name` (string literal) and `schema` (zod) per event +- Event naming convention: `domain/action` e.g. `proposal/submitted`, `profile/invites-sent` +- Use `z.string().uuid()` for ID fields in event schemas +- Email templates: use `EmailTemplate` wrapper, `@react-email/components` for `Button`/`Section`/`Text`, static `.subject` method pattern +- Workflow functions: use `inngest.createFunction` with steps, `OPBatchSend` for batch emails, `alias` from drizzle for self-joins +- tRPC event emission: use `waitUntil` from `@vercel/functions` to fire inngest events without blocking the response +- DB schema: proposals → processInstances → profiles chain; profileUsers table links users (with email) to profiles +--- + +## 2026-03-02 - US-001 +- Added `proposalSubmitted` event to `services/events/src/types.ts` +- Schema: `{ proposalId: z.string().uuid() }`, name: `proposal/submitted` +- Files changed: `services/events/src/types.ts` +- **Learnings for future iterations:** + - Events object uses `as const` for literal types - add new events before the closing `} as const` + - Follow alphabetical-ish ordering by domain (post, profile, proposal) +--- + +## 2026-03-02 - US-002 +- Created `ProposalSubmittedEmail` component at `services/emails/emails/ProposalSubmittedEmail.tsx` +- Props: `proposalName`, `processTitle`, `proposalUrl`; static `.subject()` method +- Button styled to match CommentNotificationEmail CTA pattern +- Exported from `services/emails/index.tsx` +- Files changed: `services/emails/emails/ProposalSubmittedEmail.tsx`, `services/emails/index.tsx` +- **Learnings for future iterations:** + - Email components follow a consistent pattern: `EmailTemplate` wrapper, static `.subject` method, default prop values + - Button styling is standardized: `rounded-lg bg-primary-teal px-4 py-3 text-white no-underline hover:bg-primary-teal/90` +--- + +## 2026-03-02 - US-003 +- Created `sendProposalSubmittedNotification` at `services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts` +- Workflow: parse event → query proposal + process profile → query collaborators → batch send emails +- Uses debounce (`key: proposalId`, `period: 1m`, `timeout: 3m`) +- URL pattern: `/decisions/{slug}/proposal/{profileId}` +- Exported from notifications index +- Files changed: `services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts`, `services/workflows/src/functions/notifications/index.ts` +- **Learnings for future iterations:** + - Use `alias` from `drizzle-orm/pg-core` for self-joins on the profiles table + - `processInstances.profileId` can be nullable — use `innerJoin` to filter out nulls + - `OPBatchSend` returns `{ data, errors }` — throw on errors for Inngest retries +--- + +## 2026-03-02 - US-004 +- Added inngest event emission to `services/api/src/routers/decision/proposals/submit.ts` +- Uses `waitUntil` pattern consistent with existing analytics tracking +- Sends `proposal/submitted` event with `{ proposalId: proposal.id }` after successful submission +- Files changed: `services/api/src/routers/decision/proposals/submit.ts` +- **Learnings for future iterations:** + - `waitUntil` from `@vercel/functions` is the standard pattern for fire-and-forget async work in tRPC mutations + - Import both `Events` and `inngest` from `@op/events` for event emission +--- From b3807019b4731a823e8f2173fbe5fc22ba27be30 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 2 Mar 2026 16:42:17 +0100 Subject: [PATCH 06/10] format --- services/emails/emails/ProposalSubmittedEmail.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/services/emails/emails/ProposalSubmittedEmail.tsx b/services/emails/emails/ProposalSubmittedEmail.tsx index 6dadb4b75..9a0736b91 100644 --- a/services/emails/emails/ProposalSubmittedEmail.tsx +++ b/services/emails/emails/ProposalSubmittedEmail.tsx @@ -37,9 +37,7 @@ export const ProposalSubmittedEmail = ({ ); }; -ProposalSubmittedEmail.subject = ( - proposalName: string, - processTitle: string, -) => `Your proposal "${proposalName}" has been submitted to ${processTitle}`; +ProposalSubmittedEmail.subject = (proposalName: string, processTitle: string) => + `Your proposal "${proposalName}" has been submitted to ${processTitle}`; export default ProposalSubmittedEmail; From 67b826d740a5b9c3b225666589eafe459e46d6ff Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 2 Mar 2026 16:51:36 +0100 Subject: [PATCH 07/10] Remove unnecessary files --- scripts/prd.json | 73 -------------------------------------------- scripts/progress.txt | 54 -------------------------------- 2 files changed, 127 deletions(-) delete mode 100644 scripts/prd.json delete mode 100644 scripts/progress.txt diff --git a/scripts/prd.json b/scripts/prd.json deleted file mode 100644 index 72fb4446a..000000000 --- a/scripts/prd.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "project": "Common", - "branchName": "ralph/proposal-submission-notifications", - "description": "Proposal Submission Notification Emails - Send email notifications to all proposal collaborators when a proposal is submitted to a decision process", - "userStories": [ - { - "id": "US-001", - "title": "Define proposal submission event schema", - "description": "As a developer, I need an Inngest event for proposal submission so the workflow system can trigger notifications.", - "acceptanceCriteria": [ - "Add `proposalSubmitted` event to `services/events/src/types.ts` with schema containing `proposalId` (z.string().uuid())", - "Event name is `proposal/submitted` following existing naming convention (e.g., `proposal/export-requested`, `profile/invites-sent`)", - "Typecheck passes" - ], - "priority": 1, - "passes": true, - "notes": "" - }, - { - "id": "US-002", - "title": "Create proposal submission email template", - "description": "As a collaborator on a proposal, I want to receive a well-styled email so I know my proposal was submitted to the decision process.", - "acceptanceCriteria": [ - "New email component at `services/emails/emails/ProposalSubmittedEmail.tsx`", - "Uses `EmailTemplate` wrapper component for consistent branding (import from `../components/EmailTemplate`)", - "Props: `proposalName` (string), `processTitle` (string), `proposalUrl` (string)", - "Static `.subject` method that returns: `Your proposal \"{proposalName}\" has been submitted to {processTitle}`", - "Body text: bold proposal name and process title, followed by a 'View proposal' CTA button", - "Button styled identically to CommentNotificationEmail: `rounded-lg bg-primary-teal px-4 py-3 text-white no-underline hover:bg-primary-teal/90`", - "Export the component from `services/emails/index.tsx`", - "Typecheck passes" - ], - "priority": 2, - "passes": true, - "notes": "" - }, - { - "id": "US-003", - "title": "Create workflow function to send submission notifications", - "description": "As a developer, I need an Inngest function that listens for the proposal/submitted event, queries all collaborators, and sends them the notification email.", - "acceptanceCriteria": [ - "New function at `services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts`", - "Uses `inngest.createFunction` listening for the `proposal/submitted` event (import `Events` and `inngest` from `@op/events`)", - "Debounce config: `key: 'event.data.proposalId'`, `period: '1m'`, `timeout: '3m'`", - "Step 1: Query proposal with relations to get proposal `profileId`, proposal profile `name`, process instance with its profile `name` (process title) and `slug`", - "Step 2: Query all `profileUsers` where `profileId` matches the proposal's `profileId` to get collaborator emails", - "Build proposal URL: `OPURLConfig('APP').ENV_URL + '/decisions/{slug}/proposal/{profileId}'`", - "Use `OPBatchSend` (from `@op/emails`) to send emails to all collaborators", - "Each email uses `ProposalSubmittedEmail` component with correct props", - "Log errors and re-throw for Inngest retries, following pattern from `sendReactionNotification.ts`", - "Export from `services/workflows/src/functions/notifications/index.ts`", - "Typecheck passes" - ], - "priority": 3, - "passes": true, - "notes": "" - }, - { - "id": "US-004", - "title": "Emit proposal submitted event from tRPC mutation", - "description": "As a developer, I need the submit proposal tRPC mutation to fire the Inngest event so the workflow triggers.", - "acceptanceCriteria": [ - "In `services/api/src/routers/decision/proposals/submit.ts`, import `inngest` from `@op/events` and `Events`", - "After the successful `submitProposal` call, send the `proposal/submitted` event with `{ proposalId: proposal.id }`", - "Use `waitUntil` pattern (from `@vercel/functions`) consistent with existing analytics tracking in the same file", - "Typecheck passes" - ], - "priority": 4, - "passes": true, - "notes": "" - } - ] -} diff --git a/scripts/progress.txt b/scripts/progress.txt deleted file mode 100644 index d012e7292..000000000 --- a/scripts/progress.txt +++ /dev/null @@ -1,54 +0,0 @@ -# Ralph Progress Log -Started: Mon Mar 2 16:23:28 CET 2026 -## Codebase Patterns -- Events are defined in `services/events/src/types.ts` as a `const` object with `name` (string literal) and `schema` (zod) per event -- Event naming convention: `domain/action` e.g. `proposal/submitted`, `profile/invites-sent` -- Use `z.string().uuid()` for ID fields in event schemas -- Email templates: use `EmailTemplate` wrapper, `@react-email/components` for `Button`/`Section`/`Text`, static `.subject` method pattern -- Workflow functions: use `inngest.createFunction` with steps, `OPBatchSend` for batch emails, `alias` from drizzle for self-joins -- tRPC event emission: use `waitUntil` from `@vercel/functions` to fire inngest events without blocking the response -- DB schema: proposals → processInstances → profiles chain; profileUsers table links users (with email) to profiles ---- - -## 2026-03-02 - US-001 -- Added `proposalSubmitted` event to `services/events/src/types.ts` -- Schema: `{ proposalId: z.string().uuid() }`, name: `proposal/submitted` -- Files changed: `services/events/src/types.ts` -- **Learnings for future iterations:** - - Events object uses `as const` for literal types - add new events before the closing `} as const` - - Follow alphabetical-ish ordering by domain (post, profile, proposal) ---- - -## 2026-03-02 - US-002 -- Created `ProposalSubmittedEmail` component at `services/emails/emails/ProposalSubmittedEmail.tsx` -- Props: `proposalName`, `processTitle`, `proposalUrl`; static `.subject()` method -- Button styled to match CommentNotificationEmail CTA pattern -- Exported from `services/emails/index.tsx` -- Files changed: `services/emails/emails/ProposalSubmittedEmail.tsx`, `services/emails/index.tsx` -- **Learnings for future iterations:** - - Email components follow a consistent pattern: `EmailTemplate` wrapper, static `.subject` method, default prop values - - Button styling is standardized: `rounded-lg bg-primary-teal px-4 py-3 text-white no-underline hover:bg-primary-teal/90` ---- - -## 2026-03-02 - US-003 -- Created `sendProposalSubmittedNotification` at `services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts` -- Workflow: parse event → query proposal + process profile → query collaborators → batch send emails -- Uses debounce (`key: proposalId`, `period: 1m`, `timeout: 3m`) -- URL pattern: `/decisions/{slug}/proposal/{profileId}` -- Exported from notifications index -- Files changed: `services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts`, `services/workflows/src/functions/notifications/index.ts` -- **Learnings for future iterations:** - - Use `alias` from `drizzle-orm/pg-core` for self-joins on the profiles table - - `processInstances.profileId` can be nullable — use `innerJoin` to filter out nulls - - `OPBatchSend` returns `{ data, errors }` — throw on errors for Inngest retries ---- - -## 2026-03-02 - US-004 -- Added inngest event emission to `services/api/src/routers/decision/proposals/submit.ts` -- Uses `waitUntil` pattern consistent with existing analytics tracking -- Sends `proposal/submitted` event with `{ proposalId: proposal.id }` after successful submission -- Files changed: `services/api/src/routers/decision/proposals/submit.ts` -- **Learnings for future iterations:** - - `waitUntil` from `@vercel/functions` is the standard pattern for fire-and-forget async work in tRPC mutations - - Import both `Events` and `inngest` from `@op/events` for event emission ---- From a8b4d3a69560c2554073cf761a606598d0be0627 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 2 Mar 2026 16:57:00 +0100 Subject: [PATCH 08/10] Use header in email --- services/emails/emails/ProposalSubmittedEmail.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/emails/emails/ProposalSubmittedEmail.tsx b/services/emails/emails/ProposalSubmittedEmail.tsx index 9a0736b91..fe7351f9a 100644 --- a/services/emails/emails/ProposalSubmittedEmail.tsx +++ b/services/emails/emails/ProposalSubmittedEmail.tsx @@ -1,4 +1,4 @@ -import { Button, Section, Text } from '@react-email/components'; +import { Button, Heading, Section, Text } from '@react-email/components'; import EmailTemplate from '../components/EmailTemplate'; @@ -15,6 +15,9 @@ export const ProposalSubmittedEmail = ({ + + Proposal Submitted + Your proposal {proposalName} has been submitted to{' '} {processTitle}. From 80e2525b923ba2d0d177acb095603dd3d029705d Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 2 Mar 2026 17:12:49 +0100 Subject: [PATCH 09/10] Fix mockSend --- services/api/src/test/setup.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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, }, }; }); From 95c375dbb4028423b26f9d72c6c81d70c188bf7a Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 18:13:37 +0100 Subject: [PATCH 10/10] Fallbacks when there are issues with names --- .../emails/emails/ProposalSubmittedEmail.tsx | 38 ++++++++++++++----- .../sendProposalSubmittedNotification.ts | 4 +- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/services/emails/emails/ProposalSubmittedEmail.tsx b/services/emails/emails/ProposalSubmittedEmail.tsx index fe7351f9a..5730f9bd6 100644 --- a/services/emails/emails/ProposalSubmittedEmail.tsx +++ b/services/emails/emails/ProposalSubmittedEmail.tsx @@ -3,24 +3,36 @@ import { Button, Heading, Section, Text } from '@react-email/components'; import EmailTemplate from '../components/EmailTemplate'; export const ProposalSubmittedEmail = ({ - proposalName = 'Your proposal', - processTitle = 'a decision process', + proposalName, + processTitle, proposalUrl = 'https://common.oneproject.org/', }: { - proposalName: string; - processTitle: string; + proposalName?: string | null; + processTitle?: string | null; proposalUrl: string; }) => { + const displayName = proposalName || 'Your proposal'; + return ( Proposal Submitted - Your proposal {proposalName} has been submitted to{' '} - {processTitle}. + Your proposal {displayName} has been submitted + {processTitle ? ( + <> + {' '} + to {processTitle} + + ) : null} + .
@@ -40,7 +52,15 @@ export const ProposalSubmittedEmail = ({ ); }; -ProposalSubmittedEmail.subject = (proposalName: string, processTitle: string) => - `Your proposal "${proposalName}" has been submitted to ${processTitle}`; +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/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts b/services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts index a1c5e7985..b5d54ed66 100644 --- a/services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts +++ b/services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts @@ -101,8 +101,6 @@ export const sendProposalSubmittedNotification = inngest.createFunction( return { sent: data.length, - failed: errors.length, - errors, }; } catch (error) { console.error('Failed to send proposal submitted notifications:', { @@ -114,7 +112,7 @@ export const sendProposalSubmittedNotification = inngest.createFunction( }); return { - message: `${result.sent} proposal submitted notification(s) sent, ${result.failed} failed`, + message: `${result.sent} proposal submitted notification(s) sent`, }; }, );