Skip to content
Merged
9 changes: 9 additions & 0 deletions services/api/src/routers/decision/proposals/submit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { submitProposal } from '@op/common';
import { Events, inngest } from '@op/events';
import { waitUntil } from '@vercel/functions';
import { z } from 'zod';

Expand Down Expand Up @@ -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);
}),
});
6 changes: 5 additions & 1 deletion services/api/src/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
});
Expand Down
66 changes: 66 additions & 0 deletions services/emails/emails/ProposalSubmittedEmail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<EmailTemplate
previewText={
processTitle
? `Your proposal "${displayName}" has been submitted to ${processTitle}`
: `Your proposal "${displayName}" has been submitted`
}
>
<Heading className="mx-0 !my-0 p-0 text-left font-serif text-[28px] font-light tracking-[-0.02625rem] text-[#222D38]">
Proposal Submitted
</Heading>
<Text className="my-8 text-lg">
Your proposal <strong>{displayName}</strong> has been submitted
{processTitle ? (
<>
{' '}
to <strong>{processTitle}</strong>
</>
) : null}
.
</Text>

<Section className="pb-0">
<Button
href={proposalUrl}
className="rounded-lg bg-primary-teal px-4 py-3 text-white no-underline hover:bg-primary-teal/90"
style={{
fontSize: '0.875rem',
textAlign: 'center',
textDecoration: 'none',
}}
>
View proposal
</Button>
</Section>
</EmailTemplate>
);
};

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;
1 change: 1 addition & 0 deletions services/emails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,4 @@ export * from './emails/OPInvitationEmail';
export * from './emails/OPRelationshipRequestEmail';
export * from './emails/CommentNotificationEmail';
export * from './emails/ReactionNotificationEmail';
export * from './emails/ProposalSubmittedEmail';
6 changes: 6 additions & 0 deletions services/events/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,10 @@ export const Events = {
),
}),
},
proposalSubmitted: {
name: 'proposal/submitted' as const,
schema: z.object({
proposalId: z.string().uuid(),
}),
},
} as const;
1 change: 1 addition & 0 deletions services/workflows/src/functions/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './sendReactionNotification';
export * from './sendProfileInviteEmails';
export * from './sendProposalSubmittedNotification';
Original file line number Diff line number Diff line change
@@ -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`,
};
},
);
Loading