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 0000000..7178038 --- /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 11e5b6e..9c601c9 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 2b379b3..19f7c55 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 0000000..ec437ca --- /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 0000000..0fe0fc1 --- /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 0000000..8153221 --- /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(`
${n.message}
+ ${new Date(n.createdAt).toLocaleString()} +Hi ${firstName},
+Here's a summary of your recent notifications:
+