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
32 changes: 32 additions & 0 deletions prisma/migrations/20260429000000_add_email_digest/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-- CreateEnum
CREATE TYPE "DigestFrequency" AS ENUM ('DAILY', 'WEEKLY');

-- CreateTable
CREATE TABLE "digest_preferences" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"frequency" "DigestFrequency" NOT NULL DEFAULT 'DAILY',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"unsubscribe_token" TEXT NOT NULL,
"last_sent_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "digest_preferences_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "digest_preferences_user_id_key" ON "digest_preferences"("user_id");

-- CreateIndex
CREATE UNIQUE INDEX "digest_preferences_unsubscribe_token_key" ON "digest_preferences"("unsubscribe_token");

-- CreateIndex
CREATE INDEX "digest_preferences_user_id_idx" ON "digest_preferences"("user_id");

-- CreateIndex
CREATE INDEX "digest_preferences_enabled_frequency_idx" ON "digest_preferences"("enabled", "frequency");

-- AddForeignKey
ALTER TABLE "digest_preferences" ADD CONSTRAINT "digest_preferences_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
23 changes: 23 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ enum BounceType {
SOFT
}

enum DigestFrequency {
DAILY
WEEKLY
}


// User model
model User {
Expand Down Expand Up @@ -192,6 +197,7 @@ model User {
linkClicks LinkClick[]
emailEngagements EmailEngagement[]
emailBounces EmailBounce[]
digestPreference DigestPreference?


@@index([email])
Expand Down Expand Up @@ -760,3 +766,20 @@ model EmailBounce {
@@map("email_bounces")
}


model DigestPreference {
id String @id @default(uuid())
userId String @unique @map("user_id")
frequency DigestFrequency @default(DAILY)
enabled Boolean @default(true)
unsubscribeToken String @unique @map("unsubscribe_token")
lastSentAt DateTime? @map("last_sent_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
@@index([enabled, frequency])
@@map("digest_preferences")
}
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { SearchModule } from './search/search.module';
import { BackupModule } from './backup/backup.module';
import { TrackingModule } from './tracking/tracking.module';
import { NotificationsModule } from './notifications/notifications.module';
import { EmailDigestModule } from './email-digest/email-digest.module';
@Module({
imports: [
ConfigModule.forRoot({
Expand Down Expand Up @@ -60,6 +61,7 @@ import { NotificationsModule } from './notifications/notifications.module';
BackupModule,
TrackingModule,
NotificationsModule,
EmailDigestModule,
],
controllers: [AppController],
})
Expand Down
25 changes: 25 additions & 0 deletions src/email-digest/digest.scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { EmailDigestService } from './email-digest.service';
import { DigestFrequency } from '@prisma/client';

@Injectable()
export class DigestScheduler {
private readonly logger = new Logger(DigestScheduler.name);

constructor(private readonly emailDigestService: EmailDigestService) {}

// Every day at 8:00 AM UTC
@Cron('0 8 * * *')
async runDailyDigest() {
this.logger.log('Running daily digest...');
await this.emailDigestService.sendDigestsForFrequency(DigestFrequency.DAILY);
}

// Every Monday at 8:00 AM UTC
@Cron('0 8 * * 1')
async runWeeklyDigest() {
this.logger.log('Running weekly digest...');
await this.emailDigestService.sendDigestsForFrequency(DigestFrequency.WEEKLY);
}
}
12 changes: 12 additions & 0 deletions src/email-digest/dto/update-digest-preference.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IsEnum, IsBoolean, IsOptional } from 'class-validator';
import { DigestFrequency } from '@prisma/client';

export class UpdateDigestPreferenceDto {
@IsOptional()
@IsEnum(DigestFrequency)
frequency?: DigestFrequency;

@IsOptional()
@IsBoolean()
enabled?: boolean;
}
35 changes: 35 additions & 0 deletions src/email-digest/email-digest.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Body, Controller, Get, Param, Patch, Query, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { EmailDigestService } from './email-digest.service';
import { UpdateDigestPreferenceDto } from './dto/update-digest-preference.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';

@Controller('email-digest')
export class EmailDigestController {
constructor(private readonly emailDigestService: EmailDigestService) {}

@UseGuards(JwtAuthGuard)
@Get('preference')
getPreference(@CurrentUser() user: { id: string }) {
return this.emailDigestService.getOrCreatePreference(user.id);
}

@UseGuards(JwtAuthGuard)
@Patch('preference')
updatePreference(
@CurrentUser() user: { id: string },
@Body() dto: UpdateDigestPreferenceDto,
) {
return this.emailDigestService.updatePreference(user.id, dto);
}

@Get('unsubscribe')
async unsubscribe(@Query('token') token: string, @Res() res: Response) {
const success = await this.emailDigestService.unsubscribeByToken(token);
const message = success
? 'You have been unsubscribed from PropChain email digests.'
: 'Invalid or expired unsubscribe link.';
return res.send(`<html><body style="font-family:Arial;text-align:center;padding:40px"><h2>${message}</h2></body></html>`);
}
}
14 changes: 14 additions & 0 deletions src/email-digest/email-digest.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { EmailDigestService } from './email-digest.service';
import { EmailDigestController } from './email-digest.controller';
import { DigestScheduler } from './digest.scheduler';
import { PrismaModule } from '../database/prisma.module';
import { EmailModule } from '../email/email.module';

@Module({
imports: [PrismaModule, EmailModule],
controllers: [EmailDigestController],
providers: [EmailDigestService, DigestScheduler],
exports: [EmailDigestService],
})
export class EmailDigestModule {}
157 changes: 157 additions & 0 deletions src/email-digest/email-digest.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../database/prisma.service';
import { EmailService } from '../email/email.service';
import { v4 as uuidv4 } from 'uuid';
import { DigestFrequency } from '@prisma/client';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class EmailDigestService {
private readonly logger = new Logger(EmailDigestService.name);

constructor(
private readonly prisma: PrismaService,
private readonly emailService: EmailService,
private readonly configService: ConfigService,
) {}

async getOrCreatePreference(userId: string) {
return this.prisma.digestPreference.upsert({
where: { userId },
update: {},
create: {
userId,
frequency: DigestFrequency.DAILY,
enabled: true,
unsubscribeToken: uuidv4(),
},
});
}

async updatePreference(
userId: string,
data: { frequency?: DigestFrequency; enabled?: boolean },
) {
return this.prisma.digestPreference.upsert({
where: { userId },
update: data,
create: {
userId,
frequency: data.frequency ?? DigestFrequency.DAILY,
enabled: data.enabled ?? true,
unsubscribeToken: uuidv4(),
},
});
}

async unsubscribeByToken(token: string): Promise<boolean> {
const pref = await this.prisma.digestPreference.findUnique({
where: { unsubscribeToken: token },
});
if (!pref) return false;

await this.prisma.digestPreference.update({
where: { unsubscribeToken: token },
data: { enabled: false },
});
return true;
}

async sendDigestsForFrequency(frequency: DigestFrequency): Promise<void> {
const prefs = await this.prisma.digestPreference.findMany({
where: { frequency, enabled: true },
include: { user: { select: { id: true, email: true, firstName: true, emailStatus: true } } },
});

const since = this.getSinceDate(frequency);

for (const pref of prefs) {
if (pref.user.emailStatus === 'INVALID') continue;

try {
await this.sendDigestForUser(pref.user, since, pref.unsubscribeToken);
await this.prisma.digestPreference.update({
where: { id: pref.id },
data: { lastSentAt: new Date() },
});
} catch (err) {
this.logger.error(`Failed to send digest to ${pref.user.email}: ${err.message}`);
}
}
}

private async sendDigestForUser(
user: { id: string; email: string; firstName: string },
since: Date,
unsubscribeToken: string,
): Promise<void> {
const notifications = await this.prisma.notification.findMany({
where: { userId: user.id, createdAt: { gte: since } },
orderBy: { createdAt: 'desc' },
take: 50,
});

if (notifications.length === 0) return;

const apiUrl = this.configService.get<string>('API_URL', 'http://localhost:3000/api');
const unsubscribeUrl = `${apiUrl}/email-digest/unsubscribe?token=${unsubscribeToken}`;

const html = this.buildDigestHtml(user.firstName, notifications, unsubscribeUrl);

await this.emailService['sendEmail']({
to: user.email,
subject: `Your PropChain Digest – ${notifications.length} update${notifications.length > 1 ? 's' : ''}`,
html,
userId: user.id,
emailType: 'digest',
});
}

private buildDigestHtml(
firstName: string,
notifications: Array<{ title: string; message: string; type: string; createdAt: Date }>,
unsubscribeUrl: string,
): string {
const rows = notifications
.map(
(n) => `
<tr>
<td style="padding:12px 0;border-bottom:1px solid #eee;">
<strong style="color:#333;">${n.title}</strong>
<span style="display:inline-block;margin-left:8px;padding:2px 8px;background:#f0f4ff;color:#4a6cf7;border-radius:12px;font-size:12px;">${n.type}</span>
<p style="margin:4px 0 0;color:#666;font-size:14px;">${n.message}</p>
<small style="color:#999;">${new Date(n.createdAt).toLocaleString()}</small>
</td>
</tr>`,
)
.join('');

return `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;color:#333;">
<div style="background:#4a6cf7;padding:24px;border-radius:8px 8px 0 0;">
<h1 style="color:#fff;margin:0;font-size:22px;">PropChain Digest</h1>
</div>
<div style="padding:24px;background:#fff;border:1px solid #eee;border-top:none;">
<p>Hi ${firstName},</p>
<p>Here's a summary of your recent notifications:</p>
<table style="width:100%;border-collapse:collapse;">${rows}</table>
</div>
<div style="padding:16px;background:#f9f9f9;border:1px solid #eee;border-top:none;border-radius:0 0 8px 8px;text-align:center;">
<small style="color:#999;">
You're receiving this because you subscribed to PropChain digests.
<a href="${unsubscribeUrl}" style="color:#4a6cf7;">Unsubscribe</a>
</small>
</div>
</div>`;
}

private getSinceDate(frequency: DigestFrequency): Date {
const now = new Date();
if (frequency === DigestFrequency.WEEKLY) {
now.setDate(now.getDate() - 7);
} else {
now.setDate(now.getDate() - 1);
}
return now;
}
}
Loading