From 20001dc4a112587c1eb9221c58b026cf53541e31 Mon Sep 17 00:00:00 2001 From: omonxooo-commits Date: Wed, 29 Apr 2026 01:13:41 +0000 Subject: [PATCH] feat: implement email digest (#371) - Add DigestFrequency enum (DAILY/WEEKLY) and DigestPreference model to schema - Create migration for digest_preferences table with unsubscribe token - EmailDigestService: aggregate notifications, build HTML template, send digest - DigestScheduler: daily cron (8AM UTC) and weekly cron (Monday 8AM UTC) - EmailDigestController: GET/PATCH /email-digest/preference, GET /email-digest/unsubscribe - Wire EmailDigestModule into AppModule --- .../migration.sql | 32 ++++ prisma/schema.prisma | 23 +++ src/app.module.ts | 2 + src/email-digest/digest.scheduler.ts | 25 +++ .../dto/update-digest-preference.dto.ts | 12 ++ src/email-digest/email-digest.controller.ts | 35 ++++ src/email-digest/email-digest.module.ts | 14 ++ src/email-digest/email-digest.service.ts | 157 ++++++++++++++++++ 8 files changed, 300 insertions(+) create mode 100644 prisma/migrations/20260429000000_add_email_digest/migration.sql create mode 100644 src/email-digest/digest.scheduler.ts create mode 100644 src/email-digest/dto/update-digest-preference.dto.ts create mode 100644 src/email-digest/email-digest.controller.ts create mode 100644 src/email-digest/email-digest.module.ts create mode 100644 src/email-digest/email-digest.service.ts diff --git a/prisma/migrations/20260429000000_add_email_digest/migration.sql b/prisma/migrations/20260429000000_add_email_digest/migration.sql new file mode 100644 index 00000000..7178038a --- /dev/null +++ b/prisma/migrations/20260429000000_add_email_digest/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11e5b6e9..9c601c9a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -127,6 +127,11 @@ enum BounceType { SOFT } +enum DigestFrequency { + DAILY + WEEKLY +} + // User model model User { @@ -192,6 +197,7 @@ model User { linkClicks LinkClick[] emailEngagements EmailEngagement[] emailBounces EmailBounce[] + digestPreference DigestPreference? @@index([email]) @@ -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") +} diff --git a/src/app.module.ts b/src/app.module.ts index 2b379b33..19f7c55d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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({ @@ -60,6 +61,7 @@ import { NotificationsModule } from './notifications/notifications.module'; BackupModule, TrackingModule, NotificationsModule, + EmailDigestModule, ], controllers: [AppController], }) diff --git a/src/email-digest/digest.scheduler.ts b/src/email-digest/digest.scheduler.ts new file mode 100644 index 00000000..ec437cac --- /dev/null +++ b/src/email-digest/digest.scheduler.ts @@ -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); + } +} diff --git a/src/email-digest/dto/update-digest-preference.dto.ts b/src/email-digest/dto/update-digest-preference.dto.ts new file mode 100644 index 00000000..0fe0fc1a --- /dev/null +++ b/src/email-digest/dto/update-digest-preference.dto.ts @@ -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; +} diff --git a/src/email-digest/email-digest.controller.ts b/src/email-digest/email-digest.controller.ts new file mode 100644 index 00000000..8153221c --- /dev/null +++ b/src/email-digest/email-digest.controller.ts @@ -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(`

${message}

`); + } +} diff --git a/src/email-digest/email-digest.module.ts b/src/email-digest/email-digest.module.ts new file mode 100644 index 00000000..c399c0a2 --- /dev/null +++ b/src/email-digest/email-digest.module.ts @@ -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 {} diff --git a/src/email-digest/email-digest.service.ts b/src/email-digest/email-digest.service.ts new file mode 100644 index 00000000..e462879e --- /dev/null +++ b/src/email-digest/email-digest.service.ts @@ -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 { + 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 { + 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 { + 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('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) => ` + + + ${n.title} + ${n.type} +

${n.message}

+ ${new Date(n.createdAt).toLocaleString()} + + `, + ) + .join(''); + + return ` +
+
+

PropChain Digest

+
+
+

Hi ${firstName},

+

Here's a summary of your recent notifications:

+ ${rows}
+
+
+ + You're receiving this because you subscribed to PropChain digests. + Unsubscribe + +
+
`; + } + + 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; + } +}