constructor()

in cdk/lib/discount-expiry-notifier.ts [35:405]


	constructor(scope: App, id: string, props: GuStackProps) {
		super(scope, id, props);

		const appName = 'discount-expiry-notifier';

		const role = new Role(this, 'query-lambda-role', {
			// Set the name of the role rather than using an autogenerated name.
			// This is because if the ARN is too long then it breaks the authentication request to GCP
			roleName: `disc-exp-alert-${this.stage}`,
			assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
		});
		role.addToPolicy(
			new PolicyStatement({
				actions: ['ssm:GetParameter'],
				resources: [
					`arn:aws:ssm:${this.region}:${this.account}:parameter/discount-expiry-notifier/${this.stage}/gcp-credentials-config`,
				],
			}),
		);
		role.addToPolicy(
			new PolicyStatement({
				actions: [
					'logs:CreateLogGroup',
					'logs:CreateLogStream',
					'logs:PutLogEvents',
				],
				resources: ['*'],
			}),
		);

		const allowPutMetric = new PolicyStatement({
			effect: Effect.ALLOW,
			actions: ['cloudwatch:PutMetricData'],
			resources: ['*'],
		});

		const bucket = new Bucket(this, 'Bucket', {
			bucketName: `${appName}-${this.stage.toLowerCase()}`,
		});

		const getExpiringDiscountsLambda = new GuLambdaFunction(
			this,
			'get-expiring-discounts-lambda',
			{
				app: appName,
				functionName: `${appName}-get-expiring-discounts-${this.stage}`,
				runtime: nodeVersion,
				environment: {
					Stage: this.stage,
					DAYS_UNTIL_DISCOUNT_EXPIRY_DATE: '32',
				},
				handler: 'getExpiringDiscounts.handler',
				fileName: `${appName}.zip`,
				architecture: Architecture.ARM_64,
				initialPolicy: [allowPutMetric],
				timeout: Duration.seconds(300),
				role,
			},
		);

		const filterRecordsLambda = new GuLambdaFunction(
			this,
			'filter-records-lambda',
			{
				app: appName,
				functionName: `${appName}-filter-records-${this.stage}`,
				runtime: nodeVersion,
				environment: {
					Stage: this.stage,
					FILTER_BY_REGIONS: 'US,USA,United States,United States of America',
				},
				handler: 'filterRecords.handler',
				fileName: `${appName}.zip`,
				architecture: Architecture.ARM_64,
				initialPolicy: [allowPutMetric],
			},
		);

		const getSubStatusLambda = new GuLambdaFunction(
			this,
			'get-sub-status-lambda',
			{
				app: appName,
				functionName: `${appName}-get-sub-status-${this.stage}`,
				runtime: nodeVersion,
				environment: {
					Stage: this.stage,
				},
				handler: 'getSubStatus.handler',
				fileName: `${appName}.zip`,
				architecture: Architecture.ARM_64,
				initialPolicy: [
					new PolicyStatement({
						actions: ['secretsmanager:GetSecretValue'],
						resources: [
							`arn:aws:secretsmanager:${this.region}:${this.account}:secret:${this.stage}/Zuora-OAuth/SupportServiceLambdas-*`,
						],
					}),
				],
			},
		);

		const getOldPaymentAmountLambda = new GuLambdaFunction(
			this,
			'get-old-payment-amount-lambda',
			{
				app: appName,
				functionName: `${appName}-get-old-payment-amount-${this.stage}`,
				runtime: nodeVersion,
				environment: {
					Stage: this.stage,
				},
				handler: 'getOldPaymentAmount.handler',
				fileName: `${appName}.zip`,
				architecture: Architecture.ARM_64,
				initialPolicy: [
					new PolicyStatement({
						actions: ['secretsmanager:GetSecretValue'],
						resources: [
							`arn:aws:secretsmanager:${this.region}:${this.account}:secret:${this.stage}/Zuora-OAuth/SupportServiceLambdas-*`,
						],
					}),
				],
			},
		);
		const getNewPaymentAmountLambda = new GuLambdaFunction(
			this,
			'get-new-payment-amount-lambda',
			{
				app: appName,
				functionName: `${appName}-get-new-payment-amount-${this.stage}`,
				runtime: nodeVersion,
				environment: {
					Stage: this.stage,
				},
				handler: 'getNewPaymentAmount.handler',
				fileName: `${appName}.zip`,
				architecture: Architecture.ARM_64,
				initialPolicy: [
					new PolicyStatement({
						actions: ['secretsmanager:GetSecretValue'],
						resources: [
							`arn:aws:secretsmanager:${this.region}:${this.account}:secret:${this.stage}/Zuora-OAuth/SupportServiceLambdas-*`,
						],
					}),
				],
			},
		);

		const sendEmailLambda = new GuLambdaFunction(this, 'send-email-lambda', {
			app: appName,
			functionName: `${appName}-send-email-${this.stage}`,
			runtime: nodeVersion,
			environment: {
				Stage: this.stage,
				S3_BUCKET: bucket.bucketName,
			},
			handler: 'sendEmail.handler',
			fileName: `${appName}.zip`,
			architecture: Architecture.ARM_64,
		});

		const saveResultsLambda = new GuLambdaFunction(
			this,
			'save-results-lambda',
			{
				app: appName,
				functionName: `${appName}-save-results-${this.stage}`,
				runtime: nodeVersion,
				environment: {
					Stage: this.stage,
					S3_BUCKET: bucket.bucketName,
				},
				handler: 'saveResults.handler',
				fileName: `${appName}.zip`,
				architecture: Architecture.ARM_64,
				initialPolicy: [
					new PolicyStatement({
						actions: ['s3:GetObject', 's3:PutObject'],
						resources: [bucket.arnForObjects('*')],
					}),
					allowPutMetric,
				],
			},
		);

		const alarmOnFailuresLambda = new GuLambdaFunction(
			this,
			'alarm-on-failures-lambda',
			{
				app: appName,
				functionName: `${appName}-alarm-on-failures-${this.stage}`,
				runtime: nodeVersion,
				environment: {
					Stage: this.stage,
				},
				handler: 'alarmOnFailures.handler',
				fileName: `${appName}.zip`,
				architecture: Architecture.ARM_64,
				initialPolicy: [allowPutMetric],
			},
		);

		const getExpiringDiscountsLambdaTask = new LambdaInvoke(
			this,
			'Get expiring discounts',
			{
				lambdaFunction: getExpiringDiscountsLambda,
				outputPath: '$.Payload',
			},
		).addRetry({
			errors: ['States.ALL'],
			interval: Duration.seconds(10),
			maxAttempts: 2, // Retry only once (1 initial attempt + 1 retry)
		});

		const filterRecordsLambdaTask = new LambdaInvoke(
			this,
			'Filter records by region',
			{
				lambdaFunction: filterRecordsLambda,
				outputPath: '$.Payload',
			},
		);

		const getSubStatusLambdaTask = new LambdaInvoke(this, 'Get sub status', {
			lambdaFunction: getSubStatusLambda,
			outputPath: '$.Payload',
		});

		const getOldPaymentAmountLambdaTask = new LambdaInvoke(
			this,
			'Get old payment amount',
			{
				lambdaFunction: getOldPaymentAmountLambda,
				outputPath: '$.Payload',
			},
		);

		const getNewPaymentAmountLambdaTask = new LambdaInvoke(
			this,
			'Get new payment amount',
			{
				lambdaFunction: getNewPaymentAmountLambda,
				outputPath: '$.Payload',
			},
		);

		const saveResultsLambdaTask = new LambdaInvoke(this, 'Save results', {
			lambdaFunction: saveResultsLambda,
			outputPath: '$.Payload',
		});

		const alarmOnFailuresLambdaTask = new LambdaInvoke(
			this,
			'Alarm on errors',
			{
				lambdaFunction: alarmOnFailuresLambda,
				outputPath: '$.Payload',
			},
		);

		const sendEmailLambdaTask = new LambdaInvoke(this, 'Send email', {
			lambdaFunction: sendEmailLambda,
			outputPath: '$.Payload',
		});

		const subStatusFetcherMap = new Map(this, 'Sub status fetcher map', {
			maxConcurrency: 10,
			itemsPath: JsonPath.stringAt('$.recordsForEmailSend'),
			resultPath: '$.discountProcessingAttempts',
		});

		const expiringDiscountProcessorMap = new Map(
			this,
			'Expiring discount processor map',
			{
				maxConcurrency: 10,
				itemsPath: JsonPath.stringAt('$.discountProcessingAttempts'),
				resultPath: '$.discountProcessingAttempts',
			},
		);

		subStatusFetcherMap.iterator(
			getSubStatusLambdaTask
				.next(getOldPaymentAmountLambdaTask)
				.next(getNewPaymentAmountLambdaTask),
		);
		expiringDiscountProcessorMap.iterator(sendEmailLambdaTask);

		const definitionBody = DefinitionBody.fromChainable(
			getExpiringDiscountsLambdaTask
				.next(filterRecordsLambdaTask)
				.next(subStatusFetcherMap)
				.next(expiringDiscountProcessorMap)
				.next(saveResultsLambdaTask)
				.next(alarmOnFailuresLambdaTask),
		);

		const sqsInlinePolicy: Policy = new Policy(this, 'sqs-inline-policy', {
			statements: [
				new PolicyStatement({
					effect: Effect.ALLOW,
					actions: ['sqs:GetQueueUrl', 'sqs:SendMessage'],
					resources: [
						`arn:aws:sqs:${this.region}:${this.account}:braze-emails-${this.stage}`,
					],
				}),
			],
		});

		sendEmailLambda.role?.attachInlinePolicy(sqsInlinePolicy);

		const stateMachine = new StateMachine(
			this,
			`${appName}-state-machine-${this.stage}`,
			{
				stateMachineName: `${appName}-${this.stage}`,
				definitionBody: definitionBody,
			},
		);

		const cronEveryDayAtNoon = { minute: '0', hour: '12' };
		const cronOncePerYear = { minute: '0', hour: '0', day: '1', month: '1' };

		const executionFrequency =
			this.stage === 'PROD' ? cronEveryDayAtNoon : cronOncePerYear;

		new Rule(this, 'ScheduleStateMachineRule', {
			schedule: Schedule.cron(executionFrequency),
			targets: [new SfnStateMachine(stateMachine)],
			enabled: true,
		});

		const topic = Topic.fromTopicArn(
			this,
			'Topic',
			`arn:aws:sns:${this.region}:${this.account}:alarms-handler-topic-${this.stage}`,
		);

		const lambdaFunctionsToAlarmOn = [
			getExpiringDiscountsLambda,
			filterRecordsLambda,
			alarmOnFailuresLambda,
		];

		lambdaFunctionsToAlarmOn.forEach((lambdaFunction, index) => {
			const alarm = new Alarm(this, `alarm-${index}`, {
				alarmName: `Discount Expiry Notifier - ${lambdaFunction.functionName} - something went wrong - ${this.stage}`,
				alarmDescription:
					'Something went wrong when executing the Discount Expiry Notifier. See Cloudwatch logs for more information on the error.',
				datapointsToAlarm: 1,
				evaluationPeriods: 1,
				actionsEnabled: true,
				comparisonOperator:
					aws_cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
				metric: new Metric({
					metricName: 'Errors',
					namespace: 'AWS/Lambda',
					statistic: Stats.SUM,
					period: Duration.seconds(60),
					dimensionsMap: {
						FunctionName: lambdaFunction.functionName,
					},
				}),
				threshold: 0,
				treatMissingData: TreatMissingData.MISSING,
			});
			alarm.addAlarmAction(new SnsAction(topic));
		});
	}