Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 20 additions & 1 deletion apps/backend/lambdas/expenditures/controllers/expenditures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ExpenseResource } from '@branch/rbac';
import { ExpenditureValidationUtils } from '../validation-utils';
import * as expendituresService from '../services/expenditures';
import { expenditureScope } from '../services/scope';

import { sendExpenseStatusEmail } from '../mailer';
// Authentication and each route's declared permission are enforced by dispatch
// before any of these run — see routes.ts. What is left here is the part the
// routing layer cannot do: checks that need the row in hand.
Expand Down Expand Up @@ -357,6 +357,25 @@ export const patchExpenditureStatus: RouteHandler = async ({ event, params }) =>
return json(404, { message: 'Expenditure not found' });
}

// Email on approve/denial — best-effort, never blocks the response.
if (updated.entered_by && (updated.status === 'approved' || updated.status === 'denied')) {
const submitter = await expendituresService.getUserContact(updated.entered_by);
if (submitter?.email) {
try {
await sendExpenseStatusEmail({
to: submitter.email,
submitterName: submitter.name,
status: updated.status,
amount: Number(updated.amount),
category: updated.category,
adminNotes: updated.admin_notes,
});
} catch (err) {
console.error('Failed to send status email:', err);
}
}
}

return json(200, {
ok: true,
route: 'PATCH /expenditures/{id}/status',
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/lambdas/expenditures/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ import { resolveAuth } from './auth';
import { routes } from './routes';

export const handler = (event: any) =>
dispatch(event, { prefix: 'expenditures', routes, resolveAuth });
dispatch(event, { prefix: 'expenditures', routes, resolveAuth });
70 changes: 70 additions & 0 deletions apps/backend/lambdas/expenditures/mailer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';

const ses = new SESClient({ region: process.env.AWS_REGION ?? 'us-east-2' });
const FROM_ADDRESS = process.env.SES_FROM_ADDRESS ?? 'no-reply@branch.org';

export async function sendExpenseStatusEmail(opts: {
to: string;
submitterName: string;
status: 'approved' | 'denied';
amount: number;
category: string | null;
adminNotes?: string | null;
}) {
const { subject, body } = buildCopy(opts);
await ses.send(new SendEmailCommand({
Source: FROM_ADDRESS,
Destination: { ToAddresses: [opts.to] },
Message: {
Subject: { Data: subject },
Body: { Text: { Data: body } },
},
}));
}

function buildCopy({
submitterName,
status,
amount,
category,
adminNotes,
}: {
submitterName: string;
status: 'approved' | 'denied';
amount: number;
category: string | null;
adminNotes?: string | null;
}): { subject: string; body: string } {
const formattedAmount = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(Number(amount));

const categoryLine = category ? ` (${category})` : '';
const isApproved = status === 'approved';

const subject = `Your expense of ${formattedAmount} was ${isApproved ? 'approved' : 'not approved'}`;

const statusLine = isApproved
? `Your expense of ${formattedAmount}${categoryLine} has been approved.`
: `Your expense of ${formattedAmount}${categoryLine} was not approved.`;

const notesLine = isApproved
? (adminNotes ? `Note from the reviewer: ${adminNotes}` : null)
: (adminNotes
? `Reason: ${adminNotes}`
: 'No additional reason was provided. Reach out to your project admin if you have questions.');

const body = [
`Hi ${submitterName},`,
'',
statusLine,
...(notesLine ? ['', notesLine] : []),
'',
'You can view the full details in Branch.',
'',
'— The Branch Team',
].join('\n');

return { subject, body };
}
Loading
Loading