Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion apps/backend/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Donation } from './donations/donation.entity';
import { User } from './users/user.entity';
import * as dotenv from 'dotenv';
import { Goal } from './donations/goal.entity';
import { EmailTemplate } from './emails/email-template.entity';
import { EmailSubscriber } from './emails/email-subscriber.entity';

dotenv.config();

Expand All @@ -13,7 +15,7 @@ const AppDataSource = new DataSource({
username: process.env.NX_DB_USERNAME,
password: process.env.NX_DB_PASSWORD,
database: process.env.NX_DB_DATABASE,
entities: [User, Donation, Goal],
entities: [User, Donation, Goal, EmailTemplate, EmailSubscriber],
migrations: [],
// Setting synchronize: true shouldn't be used in production - otherwise you can lose production data
synchronize: true,
Expand Down
8 changes: 6 additions & 2 deletions apps/backend/src/donations/donations.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Donation } from './donation.entity';
import { DonationsController } from './donations.controller';
Expand All @@ -9,9 +9,13 @@ import { AuthService } from '../auth/auth.service';
import { UsersService } from '../users/users.service';
import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor';
import { Goal } from './goal.entity';
import { EmailsModule } from '../emails/emails.module';

@Module({
imports: [TypeOrmModule.forFeature([Donation, Goal, User])],
imports: [
TypeOrmModule.forFeature([Donation, Goal, User]),
forwardRef(() => EmailsModule),
],
controllers: [DonationsController],
providers: [
DonationsService,
Expand Down
27 changes: 27 additions & 0 deletions apps/backend/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Readable } from 'stream';
import { DonationsRepository } from './donations.repository';
import { Goal } from './goal.entity';
import { UpdateGoalDto } from './dtos';
import { EmailsService } from '../emails/emails.service';

interface PaymentIntentSyncPayload {
donationId?: number;
Expand All @@ -39,6 +40,7 @@ export class DonationsService {
private goalRepository: Repository<Goal>,

private readonly donationsRepository: DonationsRepository,
private readonly emailsService: EmailsService,
) {}

async create(
Expand Down Expand Up @@ -278,12 +280,37 @@ export class DonationsService {
return;
}

const previousStatus = donation.status;
donation.status = status;
if (transactionId) {
donation.transactionId = transactionId;
}

await this.donationRepository.save(donation);

// Send donation response email if status changed to succeeded
if (
previousStatus !== DonationStatus.SUCCEEDED &&
status === DonationStatus.SUCCEEDED
) {
try {
const donorName = `${donation.firstName} ${donation.lastName}`;
await this.emailsService.sendDonationResponseEmail(
donation.email,
donorName,
donation.amount,
);
this.logger.log(
`Donation Response email sent to ${donation.email} for donation ${donation.id}`,
);
} catch (error) {
this.logger.error(
`Failed to send Donation Response email for donation ${donation.id}`,
error,
);
// caught error cause we dont want email failure to break the payment sync
}
}
}

async getLapsedDonors(numMonths = 6): Promise<{ emails: string[] }> {
Expand Down
17 changes: 17 additions & 0 deletions apps/backend/src/emails/bulk-send.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { IsEnum, IsString } from 'class-validator';

export enum EmailTargetGroup {
RELAPSED_DONORS = 'relapsed_donors',
EMAIL_SUBSCRIBERS = 'email_subscribers',
}

export class BulkSendDto {
@IsEnum(EmailTargetGroup)
targetGroup: EmailTargetGroup;

@IsString()
subject: string;

@IsString()
bodyHtml: string;
}
36 changes: 36 additions & 0 deletions apps/backend/src/emails/email-subscriber.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';

@Entity('email_subscribers')
export class EmailSubscriber {
@PrimaryGeneratedColumn('identity', {
generatedIdentity: 'ALWAYS',
})
id: number;

@Column({ unique: true })
email: string;

@Column({ nullable: true })
firstName: string | null;

@Column({ nullable: true })
lastName: string | null;

@Column({ default: true })
isSubscribed: boolean;

@Column({ nullable: true })
unsubscribedAt: Date | null;

@CreateDateColumn({ type: 'timestamp', default: () => 'now()' })
createdAt: Date;

@UpdateDateColumn({ type: 'timestamp', default: () => 'now()' })
updatedAt: Date;
}
39 changes: 39 additions & 0 deletions apps/backend/src/emails/email-template.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';

export enum TemplateType {
DONATION_RESPONSE = 'donation_response',
RELAPSED_DONOR = 'relapsed_donor',
EMAIL_SUBSCRIBERS = 'email_subscribers',
}

@Entity('email_templates')
export class EmailTemplate {
@PrimaryGeneratedColumn('identity', {
generatedIdentity: 'ALWAYS',
})
id: number;

@Column({ type: 'varchar', unique: true })
type: TemplateType;

@Column()
subject: string;

@Column({ type: 'text' })
bodyHtml: string;

@Column({ default: true })
isActive: boolean;

@CreateDateColumn({ type: 'timestamp', default: () => 'now()' })
createdAt: Date;

@UpdateDateColumn({ type: 'timestamp', default: () => 'now()' })
updatedAt: Date;
}
91 changes: 88 additions & 3 deletions apps/backend/src/emails/emails.controller.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import {
Controller,
Post,
Get,
Body,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { EmailsService } from './emails.service';
import { CreateEmailDto } from './create-email.dto';
import { SaveTemplateDto } from './save-template.dto';
import { BulkSendDto, EmailTargetGroup } from './bulk-send.dto';
import { DonationsService } from '../donations/donations.service';

@Controller('emails')
export class EmailsController {
constructor(private readonly emailService: EmailsService) {}
constructor(
private readonly emailService: EmailsService,
private readonly donationsService: DonationsService,
) {}

@Post('send-email')
// @UseGuards(JwtAuthGuard) (should use auth, not implemented rn)
// TODO: re-enable auth guard temp disabled for local debugging
// @UseGuards(AuthGuard('jwt'))
async sendVerificationEmail(@Body() body: CreateEmailDto) {
await this.emailService.sendEmail(
body.email,
Expand All @@ -16,4 +31,74 @@ export class EmailsController {
);
return { message: 'email sent' };
}

@Get('template')
async getTemplates() {
return this.emailService.getAllTemplates();
}

@Get('subscribers')
// TODO: re-enable auth guard temp disabled for local debugging
// @UseGuards(AuthGuard('jwt'))
async getSubscribers() {
const emails = await this.emailService.getSubscribers();
return { emails, count: emails.length };
}

@Post('template')
// TODO: re-enable auth guard temp disabled for local debugging
// @UseGuards(AuthGuard('jwt'))
async saveTemplate(@Body() body: SaveTemplateDto) {
const template = await this.emailService.saveTemplate(
body.type,
body.subject,
body.bodyHtml,
);
return {
message: 'Template saved successfully',
template: {
id: template.id,
type: template.type,
subject: template.subject,
updatedAt: template.updatedAt,
},
};
}

@Post('bulk-send')
// TODO: re-enable auth guard temp disabled for local debugging
// @UseGuards(AuthGuard('jwt'))
async bulkSend(@Body() body: BulkSendDto) {
let recipientEmails: string[] = [];

if (body.targetGroup === EmailTargetGroup.RELAPSED_DONORS) {
const lapsedResult = await this.donationsService.getLapsedDonors(6);
recipientEmails = lapsedResult.emails;
} else if (body.targetGroup === EmailTargetGroup.EMAIL_SUBSCRIBERS) {
recipientEmails = await this.emailService.getSubscribers();
} else {
throw new BadRequestException('Invalid target group');
}

if (recipientEmails.length === 0) {
return {
message: 'No recipients found for the target group',
sent: 0,
targetGroup: body.targetGroup,
};
}

// Send bulk emails
const result = await this.emailService.sendBulkEmail(
recipientEmails,
body.subject,
body.bodyHtml,
);

return {
message: 'Bulk email campaign sent successfully',
sent: result.sent,
targetGroup: body.targetGroup,
};
}
}
12 changes: 10 additions & 2 deletions apps/backend/src/emails/emails.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EmailsController } from './emails.controller';
import { EmailsService } from './emails.service';
import { AmazonSESWrapper, AMAZON_SES_WRAPPER } from './amazon-ses.wrapper';
import { amazonSESClientFactory } from './amazon-ses-client.factory';
import { UsersModule } from '../users/users.module';
import { DonationsModule } from '../donations/donations.module';
import { EmailTemplate } from './email-template.entity';
import { EmailSubscriber } from './email-subscriber.entity';

@Module({
imports: [UsersModule],
imports: [
TypeOrmModule.forFeature([EmailTemplate, EmailSubscriber]),
UsersModule,
forwardRef(() => DonationsModule),
],
controllers: [EmailsController],
providers: [
EmailsService,
Expand Down
Loading
Loading