diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index e3f6d824..c799ad68 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -8,7 +8,7 @@ on: - main jobs: - test-unit: + test: runs-on: ubuntu-latest name: Run Unit Tests steps: @@ -20,7 +20,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22.x - cache: 'yarn' + cache: "yarn" - name: Restore Yarn Cache uses: actions/cache@v4 @@ -30,14 +30,47 @@ jobs: restore-keys: | yarn-modules-${{ runner.os }}- - - name: Set up Python 3.11 for testing - uses: actions/setup-python@v5 - with: - python-version: 3.11 - - name: Run unit testing run: make test_unit + build: + runs-on: ubuntu-latest + name: Build Application + steps: + - uses: actions/checkout@v4 + env: + HUSKY: "0" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: "yarn" + + - name: Restore Yarn Cache + uses: actions/cache@v4 + with: + path: node_modules + key: yarn-modules-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-dev + restore-keys: | + yarn-modules-${{ runner.os }}- + + - name: Run build + run: make build + env: + HUSKY: "0" + VITE_RUN_ENVIRONMENT: dev + + - name: Upload Build files + uses: actions/upload-artifact@v4 + with: + include-hidden-files: true + name: build + path: | + .aws-sam/ + dist/ + dist_ui/ + deploy-test-dev: runs-on: ubuntu-latest permissions: @@ -49,7 +82,8 @@ jobs: environment: "AWS DEV" name: Deploy to DEV and Run Tests needs: - - test-unit + - test + - build steps: - uses: actions/checkout@v4 env: @@ -59,7 +93,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22.x - cache: 'yarn' + cache: "yarn" - name: Restore Yarn Cache uses: actions/cache@v4 @@ -69,6 +103,11 @@ jobs: restore-keys: | yarn-modules-${{ runner.os }}- + - name: Download Build files + uses: actions/download-artifact@v4 + with: + name: build + - uses: aws-actions/setup-sam@v2 with: use-installer: true diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index cda9b08a..b251b172 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -7,23 +7,68 @@ on: branches: - main jobs: - test-unit: + test: runs-on: ubuntu-latest name: Run Unit Tests steps: + - uses: actions/checkout@v4 + env: + HUSKY: "0" + - name: Set up Node uses: actions/setup-node@v4 with: node-version: 22.x + cache: "yarn" + + - name: Restore Yarn Cache + uses: actions/cache@v4 + with: + path: node_modules + key: yarn-modules-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-dev + restore-keys: | + yarn-modules-${{ runner.os }}- + + - name: Run unit testing + run: make test_unit + + build: + runs-on: ubuntu-latest + name: Build Application + steps: - uses: actions/checkout@v4 env: HUSKY: "0" - - name: Set up Python 3.11 for testing - uses: actions/setup-python@v5 + + - name: Set up Node + uses: actions/setup-node@v4 with: - python-version: 3.11 - - name: Run unit testing - run: make test_unit + node-version: 22.x + cache: "yarn" + + - name: Restore Yarn Cache + uses: actions/cache@v4 + with: + path: node_modules + key: yarn-modules-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-prod + restore-keys: | + yarn-modules-${{ runner.os }}- + + - name: Run build + run: make build + env: + HUSKY: "0" + VITE_RUN_ENVIRONMENT: prod + + - name: Upload Build files + uses: actions/upload-artifact@v4 + with: + include-hidden-files: true + name: build-prod + path: | + .aws-sam/ + dist/ + dist_ui/ deploy-prod: runs-on: ubuntu-latest @@ -35,7 +80,8 @@ jobs: id-token: write contents: read needs: - - test-unit + - test + - build environment: "AWS PROD" steps: - name: Set up Node for testing @@ -53,6 +99,10 @@ jobs: uses: actions/setup-python@v5 with: python-version: 3.11 + - name: Download Build files + uses: actions/download-artifact@v4 + with: + name: build-prod - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::298118738376:role/GitHubActionsRole diff --git a/Makefile b/Makefile index 2079edd1..b24d8023 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,7 @@ clean: rm -rf dist/ rm -rf dist_ui/ rm -rf dist_devel/ + rm -rf coverage/ build: src/ cloudformation/ docs/ yarn -D @@ -65,14 +66,14 @@ build: src/ cloudformation/ docs/ local: VITE_BUILD_HASH=$(GIT_HASH) yarn run dev -deploy_prod: check_account_prod build +deploy_prod: check_account_prod @echo "Deploying CloudFormation stack..." sam deploy $(common_params) --parameter-overrides $(run_env)=prod $(set_application_prefix)=$(application_key) $(set_application_name)="$(application_name)" S3BucketPrefix="$(s3_bucket_prefix)" @echo "Syncing S3 bucket..." aws s3 sync $(dist_ui_directory_root) s3://$(ui_s3_bucket)/ --delete make invalidate_cloudfront -deploy_dev: check_account_dev build +deploy_dev: check_account_dev @echo "Deploying CloudFormation stack..." sam deploy $(common_params) --parameter-overrides $(run_env)=dev $(set_application_prefix)=$(application_key) $(set_application_name)="$(application_name)" S3BucketPrefix="$(s3_bucket_prefix)" @echo "Syncing S3 bucket..." diff --git a/src/api/package.json b/src/api/package.json index 3bd8a9f0..2b77c994 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -44,6 +44,7 @@ "fastify-plugin": "^5.0.1", "fastify-raw-body": "^5.0.0", "fastify-zod-openapi": "^4.1.1", + "handlebars": "^4.7.8", "ical-generator": "^8.1.1", "ioredis": "^5.6.1", "jsonwebtoken": "^9.0.2", diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index c64cd1cb..49d8f9ee 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -2,6 +2,7 @@ import { QueryCommand, ScanCommand, TransactWriteItemsCommand, + UpdateItemCommand, } from "@aws-sdk/client-dynamodb"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; import { withRoles, withTags } from "api/components/index.js"; @@ -190,16 +191,16 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { if (!request.rawBody) { throw new ValidationError({ message: "Could not get raw body." }); } + const secretApiConfig = + (await getSecretValue( + fastify.secretsManagerClient, + genericConfig.ConfigSecretName, + )) || {}; try { const sig = request.headers["stripe-signature"]; if (!sig || typeof sig !== "string") { throw new Error("Missing or invalid Stripe signature"); } - const secretApiConfig = - (await getSecretValue( - fastify.secretsManagerClient, - genericConfig.ConfigSecretName, - )) || {}; if (!secretApiConfig) { throw new InternalServerError({ message: "Could not connect to Stripe.", @@ -259,6 +260,9 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { const unmarshalledEntry = unmarshall(response.Items[0]) as { userId: string; invoiceId: string; + amount: number; + priceId: string; + productId: string; }; if (!unmarshalledEntry.userId || !unmarshalledEntry.invoiceId) { return reply.status(200).send({ @@ -266,6 +270,7 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { requestId: request.id, }); } + const paidInFull = paymentAmount === unmarshalledEntry.amount; const withCurrency = new Intl.NumberFormat("en-US", { style: "currency", currency: paymentCurrency.toUpperCase(), @@ -274,8 +279,10 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { .map((val) => val.value) .join(""); request.log.info( - `Registered payment of ${withCurrency} by ${name} (${email}) for payment link ${paymentLinkId} invoice ID ${unmarshalledEntry.invoiceId}).`, + `Registered payment of ${withCurrency} by ${name} (${email}) for payment link ${paymentLinkId} invoice ID ${unmarshalledEntry.invoiceId}). Invoice was paid ${paidInFull ? "in full." : "partially."}`, ); + // Notify link owner of payment + let queueId; if (unmarshalledEntry.userId.includes("@")) { request.log.info( `Sending email to ${unmarshalledEntry.userId}...`, @@ -290,7 +297,7 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { payload: { to: [unmarshalledEntry.userId], subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`, - content: `Received payment of ${withCurrency} by ${name} (${email}) for Invoice ${unmarshalledEntry.invoiceId}. Please contact treasurer@acm.illinois.edu with any questions.`, + content: `ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} by ${name}, ${email}).\n\nPlease contact Officer Board with any questions.`, }, }; if (!fastify.sqsClient) { @@ -304,15 +311,50 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { MessageBody: JSON.stringify(sqsPayload), }), ); - return reply.status(200).send({ - handled: true, - requestId: request.id, - queueId: result.MessageId, + queueId = result.MessageId || ""; + } + // If full payment is done, disable the link + if (paidInFull) { + request.log.debug("Paid in full, disabling link."); + const logStatement = buildAuditLogTransactPut({ + entry: { + module: Modules.STRIPE, + actor: eventId, + target: `Link ${paymentLinkId} | Invoice ${unmarshalledEntry.invoiceId}`, + message: + "Disabled Stripe payment link as payment was made in full.", + }, + }); + const dynamoCommand = new TransactWriteItemsCommand({ + TransactItems: [ + logStatement, + { + Update: { + TableName: genericConfig.StripeLinksDynamoTableName, + Key: { + userId: { S: unmarshalledEntry.userId }, + linkId: { S: paymentLinkId }, + }, + UpdateExpression: "SET active = :new_val", + ConditionExpression: "active = :old_val", + ExpressionAttributeValues: { + ":new_val": { BOOL: false }, + ":old_val": { BOOL: true }, + }, + }, + }, + ], + }); + await deactivateStripeLink({ + stripeApiKey: secretApiConfig.stripe_secret_key as string, + linkId: paymentLinkId, }); + await fastify.dynamoClient.send(dynamoCommand); } return reply.status(200).send({ handled: true, requestId: request.id, + queueId: queueId || "", }); } return reply diff --git a/src/api/sqs/handlers/emailNotifications.ts b/src/api/sqs/handlers/emailNotifications.ts index 36853e36..b4d3ae05 100644 --- a/src/api/sqs/handlers/emailNotifications.ts +++ b/src/api/sqs/handlers/emailNotifications.ts @@ -4,6 +4,16 @@ import { SendEmailCommand, SESClient } from "@aws-sdk/client-ses"; import { genericConfig } from "common/config.js"; import { createAuditLogEntry } from "api/functions/auditLog.js"; import { Modules } from "common/modules.js"; +import Handlebars from "handlebars"; +import emailTemplate from "./templates/notification.js"; + +Handlebars.registerHelper("nl2br", (text) => { + let nl2br = `${text}`.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, "$1
$2"); + nl2br = `

${nl2br.replace(/
/g, "

")}

`; + return new Handlebars.SafeString(nl2br); +}); + +const compiledTemplate = Handlebars.compile(emailTemplate); const stripHtml = (html: string): string => { return html @@ -17,7 +27,8 @@ export const emailNotificationsHandler: SQSHandlerFunction< AvailableSQSFunctions.EmailNotifications > = async (payload, metadata, logger) => { const { to, cc, bcc, content, subject } = payload; - const senderEmail = `ACM @ UIUC `; + const senderEmailAddress = `notifications@${currentEnvironmentConfig.EmailDomain}`; + const senderEmail = `ACM @ UIUC <${senderEmailAddress}>`; logger.info("Constructing email..."); const command = new SendEmailCommand({ Source: senderEmail, @@ -33,7 +44,12 @@ export const emailNotificationsHandler: SQSHandlerFunction< }, Body: { Html: { - Data: content, + Data: compiledTemplate({ + ...payload, + id: metadata.reqId, + from: senderEmailAddress, + currentYear: new Date().getFullYear(), + }), Charset: "UTF-8", }, Text: { diff --git a/src/api/sqs/handlers/templates/notification.ts b/src/api/sqs/handlers/templates/notification.ts new file mode 100644 index 00000000..7cb3ea3d --- /dev/null +++ b/src/api/sqs/handlers/templates/notification.ts @@ -0,0 +1,68 @@ +const template = /*html*/ ` + + + + + {{ subject }} + + + + + + + + + + + + + + +
+ + + + + + + + + + +
ACM UIUC Logo
{{nl2br content}}
+

ACM @ UIUC Homepage Email ACM @ UIUC

+
+
+ + + + +
+
+

You cannot unsubscribe from transactional + emails. To ensure delivery, add {{from}} to your address book.

+

Please do not respond to this message, as + emails to this address are not monitored.

+

© {{currentYear}} ACM @ UIUC. + All trademarks are the property of their respective owners.

+

{{id}}

+
+
+ + +`; +export default template; diff --git a/yarn.lock b/yarn.lock index b1b4704c..b80600ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6131,6 +6131,18 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +handlebars@^4.7.8: + version "4.7.8" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" + integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.2" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + har-schema@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz" @@ -7552,6 +7564,11 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz" @@ -9088,7 +9105,7 @@ source-map-js@^1.0.1, source-map-js@^1.2.0, source-map-js@^1.2.1: resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== -source-map@~0.6.1: +source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -9928,6 +9945,11 @@ typescript@^5.8.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== +uglify-js@^3.1.4: + version "3.19.3" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" + integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== + uid-safe@^2.1.5: version "2.1.5" resolved "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz" @@ -10332,6 +10354,11 @@ word-wrap@^1.2.5, word-wrap@~1.2.3: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"