Skip to content

Feat/monthly reports #508

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
7787a1c
Storing transaction reports in s3
landonshumway-ia Jan 28, 2025
2e763be
update email service to get report zip from s3
landonshumway-ia Jan 28, 2025
38c1f3a
update email service to get jurisdiction report zip from s3
landonshumway-ia Jan 28, 2025
34c5e65
Fix path to jurisdiction report in S3
landonshumway-ia Jan 28, 2025
ca37624
Fix timestamps used to fetch transactions
landonshumway-ia Jan 29, 2025
b1dbf96
Add date range to file names
landonshumway-ia Jan 30, 2025
0b992f4
Initializing S3 client in email-notification-service
landonshumway-ia Jan 30, 2025
5690c51
handle quantity string case
landonshumway-ia Jan 30, 2025
75ca4d3
Fix email formatting to use markdown
landonshumway-ia Jan 31, 2025
f9bffeb
Update reporting cron schedules
landonshumway-ia Jan 31, 2025
2f41996
Formatting
landonshumway-ia Jan 31, 2025
723d82e
Update reporting email message
landonshumway-ia Jan 31, 2025
fc01312
Add test for monthly edge case
landonshumway-ia Jan 31, 2025
3496b4e
node linter
landonshumway-ia Jan 31, 2025
10e4c13
add transaction reports bucket
landonshumway-ia Jan 31, 2025
1a56e0a
formatting
landonshumway-ia Jan 31, 2025
279e9cc
correct comment about weekly report time
landonshumway-ia Jan 31, 2025
527d197
Explicitly set FRI as weekday for scheduled report
landonshumway-ia Feb 3, 2025
11e4766
Accounting for exclusive nature of BETWEEN with sk design
landonshumway-ia Feb 3, 2025
cb1a03c
Add time window edge case testing
jusdino Feb 3, 2025
03ac463
Formatting/linter
landonshumway-ia Feb 3, 2025
36e37fa
PR feedback
landonshumway-ia Feb 3, 2025
4602c70
Add comment about setting lifecycle policy on reports bucket
landonshumway-ia Feb 3, 2025
e366b92
Add edge case for weekly test times
landonshumway-ia Feb 3, 2025
b8b20ac
formatting / linter
landonshumway-ia Feb 3, 2025
faead1a
another linter
landonshumway-ia Feb 3, 2025
7bd84d7
PR feedback - separate display and query timestamp logic
landonshumway-ia Feb 5, 2025
87aa8a5
Renamed email tests for clarity
landonshumway-ia Feb 6, 2025
0aace72
PR feedback - clarify comments
landonshumway-ia Feb 6, 2025
1ed52ae
Remove .gz file storage logic
landonshumway-ia Feb 6, 2025
aff56ee
remove unused import
landonshumway-ia Feb 6, 2025
dcb20cb
PR Feedback - setting to first day of month
landonshumway-ia Feb 6, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { LambdaInterface } from '@aws-lambda-powertools/commons/lib/esm/typ
import { Logger } from '@aws-lambda-powertools/logger';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { SESClient } from '@aws-sdk/client-ses';
import { S3Client } from '@aws-sdk/client-s3';
import { Context } from 'aws-lambda';

import { EnvironmentVariablesService } from '../lib/environment-variables-service';
Expand All @@ -16,6 +17,7 @@ const logger = new Logger({ logLevel: environmentVariables.getLogLevel() });
interface LambdaProperties {
dynamoDBClient: DynamoDBClient;
sesClient: SESClient;
s3Client: S3Client;
}

export class Lambda implements LambdaInterface {
Expand All @@ -35,6 +37,7 @@ export class Lambda implements LambdaInterface {
this.emailService = new EmailService({
logger: logger,
sesClient: props.sesClient,
s3Client: props.s3Client,
compactConfigurationClient: compactConfigurationClient,
jurisdictionClient: jurisdictionClient
});
Expand Down Expand Up @@ -71,27 +74,31 @@ export class Lambda implements LambdaInterface {
);
break;
case 'CompactTransactionReporting':
if (!event.templateVariables?.compactFinancialSummaryReportCSV ||
!event.templateVariables?.compactTransactionReportCSV) {
if (!event.templateVariables?.reportS3Path) {
throw new Error('Missing required template variables for CompactTransactionReporting template');
}
await this.emailService.sendCompactTransactionReportEmail(
event.compact,
event.templateVariables.compactFinancialSummaryReportCSV,
event.templateVariables.compactTransactionReportCSV
event.templateVariables.reportS3Path,
event.templateVariables.reportingCycle,
event.templateVariables.startDate,
event.templateVariables.endDate
);
break;
case 'JurisdictionTransactionReporting':
if (!event.jurisdiction) {
throw new Error('Missing required jurisdiction field for JurisdictionTransactionReporting template');
}
if (!event.templateVariables?.jurisdictionTransactionReportCSV) {
if (!event.templateVariables?.reportS3Path) {
throw new Error('Missing required template variables for JurisdictionTransactionReporting template');
}
await this.emailService.sendJurisdictionTransactionReportEmail(
event.compact,
event.jurisdiction,
event.templateVariables.jurisdictionTransactionReportCSV
event.templateVariables.reportS3Path,
event.templateVariables.reportingCycle,
event.templateVariables.startDate,
event.templateVariables.endDate
);
break;
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { SESClient } from '@aws-sdk/client-ses';
import { S3Client } from '@aws-sdk/client-s3';
import { Lambda } from './email-notification-service-lambda';


const lambda = new Lambda({
dynamoDBClient: new DynamoDBClient(),
s3Client: new S3Client(),
sesClient: new SESClient(),
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LambdaInterface } from '@aws-lambda-powertools/commons/lib/esm/types';
import { Logger } from '@aws-lambda-powertools/logger';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { S3Client } from '@aws-sdk/client-s3';
import { SESClient } from '@aws-sdk/client-ses';
import { Context } from 'aws-lambda';

Expand All @@ -18,6 +19,7 @@ const logger = new Logger({ logLevel: environmentVariables.getLogLevel() });
interface LambdaProperties {
dynamoDBClient: DynamoDBClient;
sesClient: SESClient;
s3Client: S3Client;
}

/*
Expand Down Expand Up @@ -46,6 +48,7 @@ export class Lambda implements LambdaInterface {
this.emailService = new EmailService({
logger: logger,
sesClient: props.sesClient,
s3Client: props.s3Client,
compactConfigurationClient: compactConfigurationClient,
jurisdictionClient: this.jurisdictionClient
});
Expand Down
131 changes: 95 additions & 36 deletions backend/compact-connect/lambdas/nodejs/lib/email-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as nodemailer from 'nodemailer';

import { Logger } from '@aws-lambda-powertools/logger';
import { SendEmailCommand, SendRawEmailCommand, SESClient } from '@aws-sdk/client-ses';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { renderToStaticMarkup, TReaderDocument } from '@usewaypoint/email-builder';
import { CompactConfigurationClient } from './compact-configuration-client';
import { JurisdictionClient } from './jurisdiction-client';
Expand All @@ -21,6 +22,7 @@ interface IIngestEvents {
interface EmailServiceProperties {
logger: Logger;
sesClient: SESClient;
s3Client: S3Client;
compactConfigurationClient: CompactConfigurationClient;
jurisdictionClient: JurisdictionClient;
}
Expand All @@ -37,6 +39,7 @@ const getEmailImageBaseUrl = () => {
export class EmailService {
private readonly logger: Logger;
private readonly sesClient: SESClient;
private readonly s3Client: S3Client;
private readonly compactConfigurationClient: CompactConfigurationClient;
private readonly jurisdictionClient: JurisdictionClient;
private readonly emailTemplate: TReaderDocument = {
Expand All @@ -55,6 +58,7 @@ export class EmailService {
public constructor(props: EmailServiceProperties) {
this.logger = props.logger;
this.sesClient = props.sesClient;
this.s3Client = props.s3Client;
this.compactConfigurationClient = props.compactConfigurationClient;
this.jurisdictionClient = props.jurisdictionClient;
}
Expand Down Expand Up @@ -101,7 +105,7 @@ export class EmailService {
subject: string;
recipients: string[];
errorMessage: string;
attachments: { filename: string; content: string; contentType: string; }[];
attachments: { filename: string; content: string | Buffer; contentType: string; }[];
}) {
try {
// Create a nodemailer transport that generates raw MIME messages
Expand Down Expand Up @@ -764,7 +768,7 @@ export class EmailService {
}
},
'props': {
'text': '© 2024 CompactConnect'
'text': '© 2025 CompactConnect'
}
}
};
Expand Down Expand Up @@ -815,6 +819,7 @@ export class EmailService {
},
'style': {
'textAlign': 'center',
'color': '#242424',
'padding': {
'top': 28,
'bottom': 12,
Expand Down Expand Up @@ -844,6 +849,40 @@ export class EmailService {
'style': {
'fontSize': 16,
'fontWeight': 'normal',
'color': '#A3A3A3',
'padding': {
'top': 24,
'bottom': 24,
'right': 40,
'left': 40
}
},
'props': {
'text': bodyText
}
}
};

report['root']['data']['childrenIds'].push(blockId);
}

/**
* Inserts a body text block into the email that can be formatted using markdown.
*
* @param report The report object to insert the block into.
* @param bodyText The text to insert into the block.
*/
private insertMarkdownBody(report: TReaderDocument, bodyText: string) {
const blockId = `block-markdown-body`;

report[blockId] = {
'type': 'Text',
'data': {
'style': {
'fontSize': 16,
'fontWeight': 'normal',
'textAlign': 'left',
'color': '#A3A3A3',
'padding': {
'top': 24,
'bottom': 24,
Expand All @@ -852,6 +891,7 @@ export class EmailService {
}
},
'props': {
'markdown': true,
'text': bodyText
}
}
Expand All @@ -862,8 +902,10 @@ export class EmailService {

public async sendCompactTransactionReportEmail(
compact: string,
compactFinancialSummaryReportCSV: string,
compactTransactionReportCSV: string
reportS3Path: string,
reportingCycle: string,
startDate: string,
endDate: string
): Promise<void> {
this.logger.info('Sending compact transaction report email', { compact: compact });
const recipients = await this.getRecipients(compact, 'COMPACT_SUMMARY_REPORT');
Expand All @@ -872,14 +914,26 @@ export class EmailService {
throw new Error(`No recipients found for compact ${compact} with recipient type COMPACT_SUMMARY_REPORT`);
}

// Get the report zip file from S3
const reportZipResponse = await this.s3Client.send(new GetObjectCommand({
Bucket: environmentVariableService.getTransactionReportsBucketName(),
Key: reportS3Path
}));

if (!reportZipResponse.Body) {
throw new Error(`Failed to retrieve report from S3: ${reportS3Path}`);
}

const reportZipBuffer = Buffer.from(await reportZipResponse.Body.transformToByteArray());

const report = JSON.parse(JSON.stringify(this.emailTemplate));
const subject = `Weekly Report for Compact ${compact.toUpperCase()}`;
const bodyText = 'Please find attached the weekly transaction reports for your compact:\n\n' +
'1. Financial Summary Report - A summary of all transactions and fees\n' +
'2. Transaction Detail Report - A detailed list of all transactions';
const subject = `${reportingCycle === 'weekly' ? 'Weekly' : 'Monthly'} Report for Compact ${compact.toUpperCase()}`;
const bodyText = `Please find attached the ${reportingCycle} settled transaction reports for the compact for the period ${startDate} to ${endDate}:\n\n` +
'- Financial Summary Report - A summary of all settled transactions and fees\n' +
'- Transaction Detail Report - A detailed list of all settled transactions';

this.insertHeader(report, subject);
this.insertBody(report, bodyText);
this.insertMarkdownBody(report, bodyText);
this.insertFooter(report);

const htmlContent = renderToStaticMarkup(report, { rootBlockId: 'root' });
Expand All @@ -891,44 +945,49 @@ export class EmailService {
errorMessage: 'Unable to send compact transaction report email',
attachments: [
{
filename: 'financial-summary-report.csv',
content: compactFinancialSummaryReportCSV,
contentType: 'text/csv'
},
{
filename: 'transaction-detail-report.csv',
content: compactTransactionReportCSV,
contentType: 'text/csv'
filename: `${compact}-settled-transaction-report.zip`,
content: reportZipBuffer,
contentType: 'application/zip'
}
]
});
}

public async sendJurisdictionTransactionReportEmail(
compact: string,
jurisdictionPostalAbbreviation: string,
jurisdictionTransactionReportCSV: string
jurisdiction: string,
reportS3Path: string,
reportingCycle: string,
startDate: string,
endDate: string
): Promise<void> {
this.logger.info('Sending jurisdiction transaction report email', { compact: compact, jurisdiction: jurisdictionPostalAbbreviation });
// Get jurisdiction configuration to get the jurisdiction name and recipients
const jurisdiction = await this.jurisdictionClient.getJurisdictionConfiguration(
compact, jurisdictionPostalAbbreviation);
this.logger.info('Sending jurisdiction transaction report email', {
compact: compact,
jurisdiction: jurisdiction
});

const recipients = jurisdiction.jurisdictionSummaryReportNotificationEmails;
const jurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration(compact, jurisdiction);
const recipients = jurisdictionConfig.jurisdictionSummaryReportNotificationEmails;

if (recipients.length === 0) {
throw new Error(`No recipients found for jurisdiction ${jurisdictionPostalAbbreviation} in compact ${compact}`);
throw new Error(`No recipients found for jurisdiction ${jurisdiction} in compact ${compact}`);
}

// Get compact configuration to get the compact name
const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact);
const compactName = compactConfig.compactName.toUpperCase();
const jurisdictionName = jurisdiction.jurisdictionName;
// Get the report zip file from S3
const reportZipResponse = await this.s3Client.send(new GetObjectCommand({
Bucket: environmentVariableService.getTransactionReportsBucketName(),
Key: reportS3Path
}));

if (!reportZipResponse.Body) {
throw new Error(`Failed to retrieve report from S3: ${reportS3Path}`);
}

const reportZipBuffer = Buffer.from(await reportZipResponse.Body.transformToByteArray());

const report = JSON.parse(JSON.stringify(this.emailTemplate));
const subject = `${jurisdictionName} Weekly Report for Compact ${compactName}`;
const bodyText = `Please find attached the weekly transaction report for your jurisdiction.\n\n` +
`This report contains all transactions that purchased a privilege within ${jurisdictionName} during the previous week.`;
const subject = `${jurisdictionConfig.jurisdictionName} ${reportingCycle === 'weekly' ? 'Weekly' : 'Monthly'} Report for Compact ${compact.toUpperCase()}`;
const bodyText = `Please find attached the ${reportingCycle} settled transaction report for your jurisdiction for the period ${startDate} to ${endDate}.`;

this.insertHeader(report, subject);
this.insertBody(report, bodyText);
Expand All @@ -940,12 +999,12 @@ export class EmailService {
htmlContent,
subject,
recipients,
errorMessage: 'Unable to send jurisdiction weekly transaction report email',
errorMessage: 'Unable to send jurisdiction transaction report email',
attachments: [
{
filename: `${jurisdictionPostalAbbreviation.toLowerCase()}-transaction-report.csv`,
content: jurisdictionTransactionReportCSV,
contentType: 'text/csv'
filename: `${jurisdiction}-settled-transaction-report.zip`,
content: reportZipBuffer,
contentType: 'application/zip'
}
]
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export class EnvironmentVariablesService {
private readonly uiBasePathUrlVariable = 'UI_BASE_PATH_URL';
private readonly fromAddressVariable = 'FROM_ADDRESS';
private readonly debugVariable = 'DEBUG';

private readonly transactionReportsBucketNameVariable = 'TRANSACTION_REPORTS_BUCKET_NAME';

public getEnvVar(name: string): string {
return process.env[name]?.trim() || '';
Expand Down Expand Up @@ -34,4 +34,8 @@ export class EnvironmentVariablesService {
public getFromAddress() {
return this.getEnvVar(this.fromAddressVariable);
}

public getTransactionReportsBucketName() {
return this.getEnvVar(this.transactionReportsBucketNameVariable);
}
}
Loading