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
+
|
+ |||
+
|
+