diff --git a/apps/backend/lambdas/expenditures/controllers/expenditures.ts b/apps/backend/lambdas/expenditures/controllers/expenditures.ts index 43916c30..78e33068 100644 --- a/apps/backend/lambdas/expenditures/controllers/expenditures.ts +++ b/apps/backend/lambdas/expenditures/controllers/expenditures.ts @@ -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. @@ -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', diff --git a/apps/backend/lambdas/expenditures/handler.ts b/apps/backend/lambdas/expenditures/handler.ts index f51f4a5f..1d933824 100644 --- a/apps/backend/lambdas/expenditures/handler.ts +++ b/apps/backend/lambdas/expenditures/handler.ts @@ -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 }); \ No newline at end of file diff --git a/apps/backend/lambdas/expenditures/mailer.ts b/apps/backend/lambdas/expenditures/mailer.ts new file mode 100644 index 00000000..455a32c7 --- /dev/null +++ b/apps/backend/lambdas/expenditures/mailer.ts @@ -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 }; + } \ No newline at end of file diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index a2f7f024..bce77b8f 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@aws-sdk/client-s3": "^3.995.0", + "@aws-sdk/client-ses": "^3.1113.0", "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", @@ -123,14 +124,33 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-ses": { + "version": "3.1113.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.1113.0.tgz", + "integrity": "sha512-qak5FM1tKWLUQsLcOoRG1tDhNkEdjVJbfxxbNykE2FM9WYcbi5cNVPQV/qWLPaEMMnHj+VQHSY/FyQ/9eics1w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-node": "^3.972.80", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { - "version": "3.977.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", - "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "version": "3.977.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", + "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", - "@aws-sdk/xml-builder": "^3.972.38", + "@aws-sdk/types": "^3.974.4", + "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", @@ -143,13 +163,13 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", - "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", + "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -159,13 +179,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.70", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", - "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", + "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -177,20 +197,20 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", - "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", + "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/credential-provider-env": "^3.972.68", - "@aws-sdk/credential-provider-http": "^3.972.70", - "@aws-sdk/credential-provider-login": "^3.972.75", - "@aws-sdk/credential-provider-process": "^3.972.68", - "@aws-sdk/credential-provider-sso": "^3.973.12", - "@aws-sdk/credential-provider-web-identity": "^3.972.74", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-login": "^3.972.76", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -201,14 +221,14 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.75", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", - "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz", + "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -218,18 +238,18 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.79", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", - "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", + "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.68", - "@aws-sdk/credential-provider-http": "^3.972.70", - "@aws-sdk/credential-provider-ini": "^3.973.13", - "@aws-sdk/credential-provider-process": "^3.972.68", - "@aws-sdk/credential-provider-sso": "^3.973.12", - "@aws-sdk/credential-provider-web-identity": "^3.972.74", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-ini": "^3.973.14", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -240,13 +260,13 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", - "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", + "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -256,15 +276,15 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", - "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", + "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/token-providers": "3.1108.0", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/token-providers": "3.1111.0", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -274,14 +294,14 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.74", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", - "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", + "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -308,14 +328,14 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", - "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "version": "3.997.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz", + "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -344,12 +364,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", - "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "version": "3.996.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz", + "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -359,14 +379,14 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1108.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", - "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", + "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -376,9 +396,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", - "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", + "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -389,9 +409,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", - "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz", + "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -2002,12 +2022,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.32.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.32.0.tgz", - "integrity": "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==", + "version": "3.33.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.2.tgz", + "integrity": "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.17.0", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2015,13 +2035,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", - "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.32.0", - "@smithy/types": "^4.17.0", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2071,9 +2091,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", - "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index c4e8b81b..8ebdfdfd 100644 --- a/apps/backend/lambdas/expenditures/package.json +++ b/apps/backend/lambdas/expenditures/package.json @@ -13,12 +13,12 @@ "devDependencies": { "@branch/types": "file:../../../../shared/types", "@jest/globals": "^30.2.0", - "esbuild": "^0.25.12", - "jest": "^30.2.0", "@types/aws-lambda": "^8.10.131", "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.15.6", + "esbuild": "^0.25.12", + "jest": "^30.2.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", @@ -27,6 +27,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.995.0", + "@aws-sdk/client-ses": "^3.1113.0", "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", diff --git a/apps/backend/lambdas/expenditures/services/expenditures.ts b/apps/backend/lambdas/expenditures/services/expenditures.ts index 9717f5c5..593427bc 100644 --- a/apps/backend/lambdas/expenditures/services/expenditures.ts +++ b/apps/backend/lambdas/expenditures/services/expenditures.ts @@ -153,6 +153,14 @@ export async function updateExpenditure(id: number, values: ExpenditureEdit) { .executeTakeFirst(); } +export async function getUserContact(userId: number) { + return db + .selectFrom('branch.users') + .where('user_id', '=', userId) + .select(['name', 'email']) + .executeTakeFirst(); +} + /** Returns the updated row, or `undefined` when no row carries that id. */ export async function updateExpenditureStatus( id: number, diff --git a/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts index 60e1150f..b15094e5 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts @@ -33,6 +33,10 @@ import { authenticateRequest } from '../auth'; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; +jest.mock('../mailer'); +import { sendExpenseStatusEmail } from '../mailer'; +const mockSendExpenseStatusEmail = sendExpenseStatusEmail as jest.MockedFunction; + const pool = new Pool({ host: 'localhost', port: Number(5432), @@ -736,5 +740,27 @@ describe('Expenditures integration tests', () => { expect(res.statusCode).toBe(400); }); + + test('sends an approval email to the seeded submitter', async () => { + mockAuthenticateRequest.mockResolvedValue(adminUser); + const id = await firstExpenditureId(1); // entered_by = 4 → sam@branch.org + + const res = await handler(patchStatusEvent(id, { status: 'approved' })); + + expect(res.statusCode).toBe(200); + expect(mockSendExpenseStatusEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'sam@branch.org', status: 'approved' }), + ); + }); + + test('does not send an email on needs_more_info', async () => { + mockAuthenticateRequest.mockResolvedValue(adminUser); + const id = await firstExpenditureId(1); + + const res = await handler(patchStatusEvent(id, { status: 'needs_more_info' })); + + expect(res.statusCode).toBe(200); + expect(mockSendExpenseStatusEmail).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts index 223fecc0..6405e5bf 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts @@ -44,12 +44,18 @@ jest.mock('@aws-sdk/client-s3', () => ({ .mockImplementation((params: unknown) => ({ __type: 'DeleteObject', ...(params as object) })), })); +// Nor must the status-change notification. Mocked as a module (not just the +// SES client) since callers only ever need to assert on the composed args. +jest.mock('../mailer'); + import { handler } from '../handler'; import db from '../db'; import { authenticateRequest } from '../auth'; +import { sendExpenseStatusEmail } from '../mailer'; const mockDb = db as any; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; +const mockSendExpenseStatusEmail = sendExpenseStatusEmail as jest.MockedFunction; // Helper function to create a POST event @@ -228,7 +234,6 @@ describe('POST /expenditures unit tests', () => { isAdmin: false, }, }); - // Mock: no membership found for this user on this project mockDb.selectFrom.mockReturnValue({ where: jest.fn().mockReturnValue({ @@ -239,14 +244,12 @@ describe('POST /expenditures unit tests', () => { }), }), }); - const res = await handler( postEvent({ projectID: 1, amount: 1000, }) ); - expect(res.statusCode).toBe(403); const json = JSON.parse(res.body); expect(json.message).toContain('not a member of this project'); @@ -262,7 +265,6 @@ describe('POST /expenditures unit tests', () => { isAdmin: false, }, }); - // Mock: user has Student role mockDb.selectFrom.mockReturnValue({ where: jest.fn().mockReturnValue({ @@ -273,14 +275,12 @@ describe('POST /expenditures unit tests', () => { }), }), }); - const res = await handler( postEvent({ projectID: 1, amount: 1000, }) ); - expect(res.statusCode).toBe(403); }); }); @@ -292,7 +292,6 @@ describe('POST /expenditures unit tests', () => { amount: 1000, }) ); - expect(res.statusCode).toBe(400); const json = JSON.parse(res.body); expect(json.message).toBeDefined(); @@ -305,7 +304,6 @@ describe('POST /expenditures unit tests', () => { projectID: 1, }) ); - expect(res.statusCode).toBe(400); const json = JSON.parse(res.body); expect(json.message).toBeDefined(); @@ -319,7 +317,6 @@ describe('POST /expenditures unit tests', () => { amount: 1000, }) ); - expect(res.statusCode).toBe(400); const json = JSON.parse(res.body); expect(json.message).toBeDefined(); @@ -333,7 +330,6 @@ describe('POST /expenditures unit tests', () => { amount: 'one thousand', }) ); - expect(res.statusCode).toBe(400); const json = JSON.parse(res.body); expect(json.message).toBeDefined(); @@ -930,7 +926,7 @@ describe('DELETE /expenditures/{id} unit tests', () => { }; beforeEach(() => { - mockMemberships.length = 0; + mockMemberships.length = 0; process.env.REPORTS_BUCKET_NAME = 'bucket'; mockDb.selectFrom.mockReturnValueOnce(mockSelectExpenditure(withReceipt)); mockDb.deleteFrom.mockReturnValue(mockDelete(1n)); @@ -1027,8 +1023,15 @@ describe('PATCH /expenditures/{id}/status unit tests', () => { } // The route is one UPDATE ... RETURNING: no row back means no such id, so - // `existing: null` is how this expresses "nothing to update". - function mockExpenditureForPatch(existing: Record | null, updated?: Record) { + // `existing: null` is how this expresses "nothing to update". `submitter` + // backs the separate branch.users lookup that fires only when the update + // succeeds and lands on approved/denied -- see getUserContact in + // services/expenditures.ts. + function mockExpenditureForPatch( + existing: Record | null, + updated?: Record, + submitter?: { name: string; email: string } | null, + ) { mockDb.updateTable.mockReturnValue({ set: jest.fn().mockReturnValue({ where: jest.fn().mockReturnValue({ @@ -1040,6 +1043,19 @@ describe('PATCH /expenditures/{id}/status unit tests', () => { }), }), }); + + mockDb.selectFrom.mockImplementation((table: string) => { + if (table === 'branch.users') { + return { + where: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + executeTakeFirst: (jest.fn() as any).mockResolvedValue(submitter ?? undefined), + }), + }), + }; + } + throw new Error(`mockExpenditureForPatch: unexpected table "${table}"`); + }); } beforeEach(() => { @@ -1154,6 +1170,88 @@ describe('PATCH /expenditures/{id}/status unit tests', () => { expect(res.statusCode).toBe(400); expect(JSON.parse(res.body).message).toContain('adminNotes'); }); + + test('sends an approval email to the submitter', async () => { + mockExpenditureForPatch( + { expenditure_id: 5, status: 'pending', entered_by: 2 }, + { expenditure_id: 5, status: 'approved', amount: '1200', category: 'Travel', admin_notes: null, entered_by: 2 }, + { name: 'Student User', email: 'student@example.com' }, + ); + + const res = await handler(patchStatusEvent(5, { status: 'approved' })); + + expect(res.statusCode).toBe(200); + expect(mockSendExpenseStatusEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'student@example.com', submitterName: 'Student User', status: 'approved' }), + ); + }); + + test('sends a denial email with adminNotes passed through', async () => { + mockExpenditureForPatch( + { expenditure_id: 5, status: 'pending', entered_by: 2 }, + { expenditure_id: 5, status: 'denied', amount: '1200', category: 'Travel', admin_notes: 'Missing receipt', entered_by: 2 }, + { name: 'Student User', email: 'student@example.com' }, + ); + + const res = await handler(patchStatusEvent(5, { status: 'denied', adminNotes: 'Missing receipt' })); + + expect(res.statusCode).toBe(200); + expect(mockSendExpenseStatusEmail).toHaveBeenCalledWith( + expect.objectContaining({ status: 'denied', adminNotes: 'Missing receipt' }), + ); + }); + + test('does not send an email when status is needs_more_info', async () => { + mockExpenditureForPatch( + { expenditure_id: 5, status: 'pending', entered_by: 2 }, + { expenditure_id: 5, status: 'needs_more_info', entered_by: 2 }, + { name: 'Student User', email: 'student@example.com' }, + ); + + const res = await handler(patchStatusEvent(5, { status: 'needs_more_info' })); + + expect(res.statusCode).toBe(200); + expect(mockSendExpenseStatusEmail).not.toHaveBeenCalled(); + }); + + test('does not send an email when the expenditure has no entered_by', async () => { + mockExpenditureForPatch( + { expenditure_id: 5, status: 'pending', entered_by: null }, + { expenditure_id: 5, status: 'approved', entered_by: null }, + ); + + const res = await handler(patchStatusEvent(5, { status: 'approved' })); + + expect(res.statusCode).toBe(200); + expect(mockSendExpenseStatusEmail).not.toHaveBeenCalled(); + }); + + test('skips sending when the submitter has no email on file', async () => { + mockExpenditureForPatch( + { expenditure_id: 5, status: 'pending', entered_by: 2 }, + { expenditure_id: 5, status: 'approved', entered_by: 2 }, + { name: 'Student User', email: null } as any, + ); + + const res = await handler(patchStatusEvent(5, { status: 'approved' })); + + expect(res.statusCode).toBe(200); + expect(mockSendExpenseStatusEmail).not.toHaveBeenCalled(); + }); + + test('200: still succeeds even if the email send throws', async () => { + mockExpenditureForPatch( + { expenditure_id: 5, status: 'pending', entered_by: 2 }, + { expenditure_id: 5, status: 'approved', entered_by: 2 }, + { name: 'Student User', email: 'student@example.com' }, + ); + mockSendExpenseStatusEmail.mockRejectedValueOnce(new Error('SES unavailable')); + + const res = await handler(patchStatusEvent(5, { status: 'approved' })); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).body.status).toBe('approved'); + }); }); describe('GET /expenditures/upload-url unit tests', () => { diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index eb92cc07..04f07bde 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -65,6 +65,7 @@ | [aws_iam_role_policy.ci_preview](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.lambda_cognito_admin](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.lambda_s3_objects](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.lambda_ses_send](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy_attachment.ci_apply_admin](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.ci_plan_readonly](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.lambda_basic](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | diff --git a/infrastructure/aws/lambda.tf b/infrastructure/aws/lambda.tf index 158b01bf..feb82edd 100644 --- a/infrastructure/aws/lambda.tf +++ b/infrastructure/aws/lambda.tf @@ -89,6 +89,26 @@ resource "aws_iam_role_policy" "lambda_s3_objects" { }) } +# The role had no SES permissions at all, so mailer.ts's SendEmailCommand would +# throw AccessDeniedException on every approve/decline -- caught by the PATCH +# /expenditures/{id}/status handler's try/catch, so it fails silently rather +# than surfacing as a request error. Scoped to SendEmail only: this role never +# needs to manage identities, templates, or receipt rules. +resource "aws_iam_role_policy" "lambda_ses_send" { + name = "branch-lambda-ses-send" + role = aws_iam_role.lambda_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "LambdaSesSendEmail" + Effect = "Allow" + Action = ["ses:SendEmail", "ses:SendRawEmail"] + Resource = "*" + }] + }) +} + # Get AWS account ID for unique bucket naming data "aws_caller_identity" "current" {}