diff --git a/.env.example b/.env.example index 517f41b..4d2a916 100644 --- a/.env.example +++ b/.env.example @@ -103,3 +103,12 @@ REFERRAL_MAX_REWARDS_PER_REFERRER=50 AFRICASTALKING_USERNAME=sandbox AFRICASTALKING_API_KEY=your-africastalking-api-key AFRICASTALKING_SENDER_ID=Hamplard + +# Email Verification +EMAIL_VERIFICATION_EXPIRES_IN=86400 +EMAIL_VERIFICATION_BASE_URL=http://localhost:3000/verify-email + +# GDPR Data Export +DATA_EXPORT_DIR=./data-exports +DATA_EXPORT_RATE_LIMIT_WINDOW_MINUTES=60 +DATA_EXPORT_RATE_LIMIT_MAX=5 diff --git a/package-lock.json b/package-lock.json index 36483e6..512133e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6264,6 +6264,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/prisma/migrations/20260827000000_add_email_verification_and_data_export/migration.sql b/prisma/migrations/20260827000000_add_email_verification_and_data_export/migration.sql new file mode 100644 index 0000000..cd1022f --- /dev/null +++ b/prisma/migrations/20260827000000_add_email_verification_and_data_export/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable: Add emailVerifiedAt to users and create data_export_jobs +ALTER TABLE "users" ADD COLUMN "emailVerifiedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "data_export_jobs" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "status" "DataExportStatus" NOT NULL DEFAULT 'PENDING', + "filePath" TEXT, + "fileSize" INTEGER, + "errorMessage" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + + CONSTRAINT "data_export_jobs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "data_export_jobs_userId_idx" ON "data_export_jobs"("userId"); + +-- CreateIndex +CREATE INDEX "data_export_jobs_status_idx" ON "data_export_jobs"("status"); + +-- AddForeignKey +ALTER TABLE "data_export_jobs" ADD CONSTRAINT "data_export_jobs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..99e4f20 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d895353..b543c99 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -249,8 +249,9 @@ model User { bio String? avatarUrl String? role UserRole @default(STUDENT) - isVerified Boolean @default(false) - isBanned Boolean @default(false) + isVerified Boolean @default(false) + emailVerifiedAt DateTime? + isBanned Boolean @default(false) bannedAt DateTime? banReason String? isSuspended Boolean @default(false) @@ -301,6 +302,7 @@ model User { questionsAsked Question[] @relation("QuestionsAsked") answersGiven Answer[] @relation("AnswersGiven") courseDrafts CourseDraft[] @relation("InstructorCourseDrafts") + dataExports DataExportJob[] @@map("users") } @@ -1359,3 +1361,31 @@ model CourseDraft { @@map("course_drafts") } +// ============================================================ +// GDPR DATA EXPORT +// ============================================================ + +enum DataExportStatus { + PENDING + PROCESSING + COMPLETED + FAILED +} + +model DataExportJob { + id String @id @default(uuid()) + userId String + status DataExportStatus @default(PENDING) + filePath String? + fileSize Int? + errorMessage String? + createdAt DateTime @default(now()) + completedAt DateTime? + + user User @relation(fields: [userId], references: [id]) + + @@index([userId]) + @@index([status]) + @@map("data_export_jobs") +} + diff --git a/src/app.module.ts b/src/app.module.ts index 701ee23..8afca74 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -33,6 +33,7 @@ import { AnalyticsModule } from './modules/analytics/analytics.module'; import { BackupsModule } from './modules/backups/backups.module'; import { UploadsModule } from './modules/uploads/uploads.module'; import { TagsModule } from './modules/tags/tags.module'; +import { PrivacyModule } from './modules/privacy/privacy.module'; @Module({ imports: [ @@ -80,6 +81,7 @@ import { TagsModule } from './modules/tags/tags.module'; BackupsModule, UploadsModule, TagsModule, + PrivacyModule, ], }) export class AppModule {} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index c09baac..d01fe04 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -10,7 +10,11 @@ import { GoogleAuthController } from './google-auth.controller'; import { GoogleAuthService } from './google-auth.service'; import { GoogleStrategy } from './google.strategy'; import { GoogleAuthGuard } from './google-auth.guard'; +import { EmailVerificationController } from './email-verification.controller'; +import { EmailVerificationService } from './email-verification.service'; import { ReferralsModule } from '../referrals/referrals.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { PrismaModule } from '../../common/prisma/prisma.module'; @Module({ imports: [ @@ -24,9 +28,22 @@ import { ReferralsModule } from '../referrals/referrals.module'; }), }), ReferralsModule, + NotificationsModule, + PrismaModule, + ], + controllers: [ + AuthController, + GoogleAuthController, + EmailVerificationController, + ], + providers: [ + AuthService, + JwtStrategy, + GoogleAuthService, + GoogleStrategy, + GoogleAuthGuard, + EmailVerificationService, ], - controllers: [AuthController, GoogleAuthController], - providers: [AuthService, JwtStrategy, GoogleAuthService, GoogleStrategy, GoogleAuthGuard], exports: [AuthService], }) export class AuthModule {} diff --git a/src/modules/auth/email-verification.controller.ts b/src/modules/auth/email-verification.controller.ts new file mode 100644 index 0000000..7916a58 --- /dev/null +++ b/src/modules/auth/email-verification.controller.ts @@ -0,0 +1,45 @@ +import { + Controller, + Get, + Post, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { EmailVerificationService } from './email-verification.service'; + +@ApiTags('auth') +@Controller('auth/email-verification') +export class EmailVerificationController { + constructor( + private readonly verificationService: EmailVerificationService, + ) {} + + @Post('request') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Request email verification link' }) + requestVerification(@CurrentUser('id') userId: string) { + return this.verificationService.requestVerification(userId); + } + + @Get('confirm') + @ApiOperation({ summary: 'Confirm email verification via token' }) + @ApiQuery({ name: 'token', description: 'Signed verification token' }) + confirmVerification(@Query('token') token: string) { + return this.verificationService.confirmVerification(token); + } + + @Get('status') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get email verification status' }) + getVerificationStatus(@CurrentUser('id') userId: string) { + return this.verificationService.getVerificationStatus(userId); + } +} diff --git a/src/modules/auth/email-verification.service.spec.ts b/src/modules/auth/email-verification.service.spec.ts new file mode 100644 index 0000000..534f6d9 --- /dev/null +++ b/src/modules/auth/email-verification.service.spec.ts @@ -0,0 +1,322 @@ +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { EmailVerificationService } from './email-verification.service'; + +describe('EmailVerificationService', () => { + const prisma = { + user: { + findUnique: jest.fn(), + update: jest.fn(), + }, + dataExportJob: { + findFirst: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + + const jwt = { + sign: jest.fn().mockReturnValue('signed-token'), + verify: jest.fn(), + }; + + const config = { + get: jest.fn((key: string, defaultVal?: any) => { + const map: Record = { + JWT_SECRET: 'test-secret', + EMAIL_VERIFICATION_EXPIRES_IN: '86400', + EMAIL_VERIFICATION_BASE_URL: 'http://localhost:3000/verify-email', + PLATFORM_NAME: 'Hamplard', + EMAIL_FROM: 'noreply@hamplard.com', + }; + return map[key] ?? defaultVal; + }), + }; + + const notifications = { + transporter: { + sendMail: jest.fn().mockResolvedValue(true), + }, + }; + + beforeEach(() => jest.clearAllMocks()); + + describe('requestVerification', () => { + it('sends verification email for unverified user with email', async () => { + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + name: 'Test', + emailVerifiedAt: null, + }); + notifications.transporter.sendMail.mockResolvedValue(true); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + const result = await service.requestVerification('user-1'); + + expect(result.message).toBe('Verification email sent'); + expect(jwt.sign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: 'user-1', + purpose: 'email_verification', + email: 'test@example.com', + }), + expect.objectContaining({ secret: 'test-secret' }), + ); + expect(notifications.transporter.sendMail).toHaveBeenCalled(); + }); + + it('rejects user without email', async () => { + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: null, + emailVerifiedAt: null, + }); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + await expect(service.requestVerification('user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('rejects already verified user', async () => { + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + emailVerifiedAt: new Date(), + }); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + const result = await service.requestVerification('user-1'); + expect(result.message).toBe('Email is already verified'); + }); + + it('enforces rate limiting on resend', async () => { + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + emailVerifiedAt: null, + }); + notifications.transporter.sendMail.mockResolvedValue(true); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + await service.requestVerification('user-1'); + + await expect(service.requestVerification('user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('throws for non-existent user', async () => { + prisma.user.findUnique.mockResolvedValue(null); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + await expect(service.requestVerification('nonexistent')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + }); + + describe('confirmVerification', () => { + it('verifies email with valid token', async () => { + jwt.verify.mockReturnValue({ + sub: 'user-1', + purpose: 'email_verification', + email: 'test@example.com', + }); + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + emailVerifiedAt: null, + }); + prisma.user.update.mockResolvedValue({}); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + const result = await service.confirmVerification('valid-token'); + + expect(result.verified).toBe(true); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { emailVerifiedAt: expect.any(Date), isVerified: true }, + }); + }); + + it('rejects token with wrong purpose', async () => { + jwt.verify.mockReturnValue({ + sub: 'user-1', + purpose: 'login', + email: 'test@example.com', + }); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + await expect(service.confirmVerification('wrong-purpose-token')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('rejects expired or invalid token', async () => { + jwt.verify.mockImplementation(() => { + throw new Error('jwt expired'); + }); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + await expect(service.confirmVerification('expired-token')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('handles already verified user gracefully', async () => { + jwt.verify.mockReturnValue({ + sub: 'user-1', + purpose: 'email_verification', + email: 'test@example.com', + }); + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + emailVerifiedAt: new Date(), + }); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + const result = await service.confirmVerification('valid-token'); + + expect(result.verified).toBe(true); + expect(result.message).toBe('Email is already verified'); + }); + + it('rejects when email has changed', async () => { + jwt.verify.mockReturnValue({ + sub: 'user-1', + purpose: 'email_verification', + email: 'old@example.com', + }); + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'new@example.com', + emailVerifiedAt: null, + }); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + await expect(service.confirmVerification('valid-token')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('rejects token for non-existent user', async () => { + jwt.verify.mockReturnValue({ + sub: 'nonexistent', + purpose: 'email_verification', + email: 'test@example.com', + }); + prisma.user.findUnique.mockResolvedValue(null); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + await expect(service.confirmVerification('valid-token')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + }); + + describe('getVerificationStatus', () => { + it('returns verification status', async () => { + const verifiedDate = new Date('2026-01-01'); + prisma.user.findUnique.mockResolvedValue({ + emailVerifiedAt: verifiedDate, + }); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + const result = await service.getVerificationStatus('user-1'); + expect(result.emailVerified).toBe(true); + expect(result.emailVerifiedAt).toBe(verifiedDate); + }); + + it('returns unverified status', async () => { + prisma.user.findUnique.mockResolvedValue({ + emailVerifiedAt: null, + }); + + const service = new EmailVerificationService( + prisma as any, + jwt as any, + config as any, + notifications as any, + ); + + const result = await service.getVerificationStatus('user-1'); + expect(result.emailVerified).toBe(false); + expect(result.emailVerifiedAt).toBeNull(); + }); + }); +}); diff --git a/src/modules/auth/email-verification.service.ts b/src/modules/auth/email-verification.service.ts new file mode 100644 index 0000000..384cfff --- /dev/null +++ b/src/modules/auth/email-verification.service.ts @@ -0,0 +1,206 @@ +import { + Injectable, + Logger, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; + +export interface VerificationTokenPayload { + sub: string; + purpose: 'email_verification'; + email: string; +} + +const VERIFICATION_PURPOSE = 'email_verification'; + +@Injectable() +export class EmailVerificationService { + private readonly logger = new Logger(EmailVerificationService.name); + private readonly resendTimestamps = new Map(); + + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly notifications: NotificationsService, + ) {} + + async requestVerification(userId: string): Promise<{ message: string }> { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new BadRequestException('User not found'); + } + + if (!user.email) { + throw new BadRequestException('No email address associated with this account'); + } + + if (user.emailVerifiedAt) { + return { message: 'Email is already verified' }; + } + + const now = Date.now(); + const lastResent = this.resendTimestamps.get(userId); + const cooldownMinutes = this.config.get( + 'DATA_EXPORT_RATE_LIMIT_WINDOW_MINUTES', + 60, + ); + const cooldownMs = cooldownMinutes * 60 * 1000; + + if (lastResent && now - lastResent < cooldownMs) { + const waitSeconds = Math.ceil((cooldownMs - (now - lastResent)) / 1000); + throw new BadRequestException( + `Please wait ${waitSeconds} seconds before requesting another verification email`, + ); + } + + const token = this.generateVerificationToken(user.id, user.email); + const baseUrl = this.config.get( + 'EMAIL_VERIFICATION_BASE_URL', + 'http://localhost:3000/verify-email', + ); + const verificationUrl = `${baseUrl}?token=${token}`; + + try { + await this.sendVerificationEmail( + user.email, + user.name ?? 'there', + verificationUrl, + ); + this.resendTimestamps.set(userId, now); + this.logger.log(`Verification email sent to ${user.email}`); + } catch (error) { + this.logger.error( + `Failed to send verification email to ${user.email}`, + error.message, + ); + throw new BadRequestException('Failed to send verification email'); + } + + return { message: 'Verification email sent' }; + } + + async confirmVerification(token: string): Promise<{ message: string; verified: boolean }> { + const payload = this.verifyToken(token); + + if (payload.purpose !== VERIFICATION_PURPOSE) { + throw new UnauthorizedException('Invalid verification token'); + } + + const user = await this.prisma.user.findUnique({ where: { id: payload.sub } }); + if (!user) { + throw new UnauthorizedException('User not found'); + } + + if (user.emailVerifiedAt) { + return { message: 'Email is already verified', verified: true }; + } + + if (user.email?.toLowerCase() !== payload.email.toLowerCase()) { + throw new UnauthorizedException('Email address has changed since verification was requested'); + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { emailVerifiedAt: new Date(), isVerified: true }, + }); + + this.logger.log(`Email verified for user ${user.id}`); + return { message: 'Email verified successfully', verified: true }; + } + + generateVerificationToken(userId: string, email: string): string { + const expiresIn = this.config.get( + 'EMAIL_VERIFICATION_EXPIRES_IN', + '86400', + ); + + return this.jwt.sign( + { + sub: userId, + purpose: VERIFICATION_PURPOSE, + email, + } satisfies VerificationTokenPayload, + { + secret: this.config.get('JWT_SECRET'), + expiresIn: parseInt(expiresIn, 10), + }, + ); + } + + verifyToken(token: string): VerificationTokenPayload { + try { + const payload = this.jwt.verify(token, { + secret: this.config.get('JWT_SECRET'), + }); + + if (payload.purpose !== VERIFICATION_PURPOSE) { + throw new UnauthorizedException('Invalid token purpose'); + } + + return payload; + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + throw new UnauthorizedException('Invalid or expired verification token'); + } + } + + async getVerificationStatus(userId: string): Promise<{ emailVerified: boolean; emailVerifiedAt: Date | null }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { emailVerifiedAt: true }, + }); + + if (!user) { + throw new BadRequestException('User not found'); + } + + return { + emailVerified: !!user.emailVerifiedAt, + emailVerifiedAt: user.emailVerifiedAt, + }; + } + + private async sendVerificationEmail( + to: string, + name: string, + verificationUrl: string, + ): Promise { + const platformName = this.config.get('PLATFORM_NAME', 'Hamplard'); + + await (this.notifications as any).transporter.sendMail({ + from: this.config.get('EMAIL_FROM', 'noreply@hamplard.com'), + to, + subject: `Verify your email address — ${platformName}`, + text: `Hi ${name}, please verify your email by visiting: ${verificationUrl}`, + html: ` +
+

${platformName}

+

Hi ${name},

+

+ Please verify your email address by clicking the link below: +

+

+ + Verify Email Address + +

+

+ This link will expire in 24 hours. If you did not request this verification, you can safely ignore this email. +

+
+ + You're receiving this because you have an account on ${platformName}. + +
+ `, + }); + } +} diff --git a/src/modules/privacy/data-export.controller.ts b/src/modules/privacy/data-export.controller.ts new file mode 100644 index 0000000..524273e --- /dev/null +++ b/src/modules/privacy/data-export.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Get, + Post, + Param, + UseGuards, + Res, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Response } from 'express'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { DataExportService } from './data-export.service'; + +@ApiTags('privacy') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('privacy/data-export') +export class DataExportController { + constructor(private readonly exportService: DataExportService) {} + + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Request a GDPR data export' }) + requestExport(@CurrentUser('id') userId: string) { + return this.exportService.requestExport(userId); + } + + @Get(':jobId/status') + @ApiOperation({ summary: 'Get export job status' }) + getJobStatus( + @CurrentUser('id') userId: string, + @Param('jobId') jobId: string, + ) { + return this.exportService.getJobStatus(userId, jobId); + } + + @Get(':jobId/download') + @ApiOperation({ summary: 'Download completed export file' }) + async downloadExport( + @CurrentUser('id') userId: string, + @Param('jobId') jobId: string, + @Res() res: Response, + ) { + const { filePath, contentType, fileName } = + await this.exportService.downloadExport(userId, jobId); + + res.set({ + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${fileName}"`, + }); + + res.sendFile(filePath); + } +} diff --git a/src/modules/privacy/data-export.service.spec.ts b/src/modules/privacy/data-export.service.spec.ts new file mode 100644 index 0000000..fb11fae --- /dev/null +++ b/src/modules/privacy/data-export.service.spec.ts @@ -0,0 +1,222 @@ +import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; +import { DataExportService } from './data-export.service'; + +jest.mock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(true), + mkdirSync: jest.fn(), + writeFileSync: jest.fn(), + statSync: jest.fn().mockReturnValue({ size: 1024 }), +})); + +describe('DataExportService', () => { + const prisma = { + user: { + findUnique: jest.fn(), + }, + dataExportJob: { + findFirst: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + enrollment: { findMany: jest.fn().mockResolvedValue([]) }, + certificate: { findMany: jest.fn().mockResolvedValue([]) }, + assignmentSubmission: { findMany: jest.fn().mockResolvedValue([]) }, + notification: { findMany: jest.fn().mockResolvedValue([]) }, + examAttempt: { findMany: jest.fn().mockResolvedValue([]) }, + courseReview: { findMany: jest.fn().mockResolvedValue([]) }, + discussionComment: { findMany: jest.fn().mockResolvedValue([]) }, + refund: { findMany: jest.fn().mockResolvedValue([]) }, + dispute: { findMany: jest.fn().mockResolvedValue([]) }, + kycSubmission: { findMany: jest.fn().mockResolvedValue([]) }, + invoice: { findMany: jest.fn().mockResolvedValue([]) }, + couponRedemption: { findMany: jest.fn().mockResolvedValue([]) }, + referral: { findMany: jest.fn().mockResolvedValue([]) }, + referralReward: { findMany: jest.fn().mockResolvedValue([]) }, + wishlistItem: { findMany: jest.fn().mockResolvedValue([]) }, + userPoints: { findUnique: jest.fn().mockResolvedValue(null) }, + pointsAward: { findMany: jest.fn().mockResolvedValue([]) }, + question: { findMany: jest.fn().mockResolvedValue([]) }, + answer: { findMany: jest.fn().mockResolvedValue([]) }, + smsMessage: { findMany: jest.fn().mockResolvedValue([]) }, + }; + + const config = { + get: jest.fn((key: string, defaultVal?: any) => { + const map: Record = { + DATA_EXPORT_DIR: '/tmp/exports', + }; + return map[key] ?? defaultVal; + }), + }; + + beforeEach(() => jest.clearAllMocks()); + + describe('requestExport', () => { + it('creates a new export job', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'user-1' }); + prisma.dataExportJob.findFirst.mockResolvedValue(null); + prisma.dataExportJob.create.mockResolvedValue({ + id: 'job-1', + userId: 'user-1', + status: 'PENDING', + }); + + const service = new DataExportService(prisma as any, config as any); + + const result = await service.requestExport('user-1'); + + expect(result.jobId).toBe('job-1'); + expect(result.status).toBe('PENDING'); + expect(prisma.dataExportJob.create).toHaveBeenCalledWith({ + data: { userId: 'user-1', status: 'PENDING' }, + }); + }); + + it('returns existing pending job if one exists', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'user-1' }); + prisma.dataExportJob.findFirst.mockResolvedValue({ + id: 'existing-job', + status: 'PROCESSING', + }); + + const service = new DataExportService(prisma as any, config as any); + + const result = await service.requestExport('user-1'); + + expect(result.jobId).toBe('existing-job'); + expect(result.status).toBe('PROCESSING'); + expect(prisma.dataExportJob.create).not.toHaveBeenCalled(); + }); + + it('throws for non-existent user', async () => { + prisma.user.findUnique.mockResolvedValue(null); + + const service = new DataExportService(prisma as any, config as any); + + await expect(service.requestExport('nonexistent')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + }); + + describe('getJobStatus', () => { + it('returns job status for owner', async () => { + prisma.dataExportJob.findUnique.mockResolvedValue({ + id: 'job-1', + userId: 'user-1', + status: 'COMPLETED', + createdAt: new Date(), + completedAt: new Date(), + fileSize: 2048, + errorMessage: null, + }); + + const service = new DataExportService(prisma as any, config as any); + + const result = await service.getJobStatus('user-1', 'job-1'); + + expect(result.status).toBe('COMPLETED'); + expect(result.fileSize).toBe(2048); + }); + + it('throws for non-owner', async () => { + prisma.dataExportJob.findUnique.mockResolvedValue({ + id: 'job-1', + userId: 'other-user', + status: 'COMPLETED', + }); + + const service = new DataExportService(prisma as any, config as any); + + await expect(service.getJobStatus('user-1', 'job-1')).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('throws for non-existent job', async () => { + prisma.dataExportJob.findUnique.mockResolvedValue(null); + + const service = new DataExportService(prisma as any, config as any); + + await expect(service.getJobStatus('user-1', 'nonexistent')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('hides error message for non-failed jobs', async () => { + prisma.dataExportJob.findUnique.mockResolvedValue({ + id: 'job-1', + userId: 'user-1', + status: 'PENDING', + createdAt: new Date(), + completedAt: null, + fileSize: null, + errorMessage: 'some internal error', + }); + + const service = new DataExportService(prisma as any, config as any); + + const result = await service.getJobStatus('user-1', 'job-1'); + expect(result.errorMessage).toBeUndefined(); + }); + }); + + describe('downloadExport', () => { + it('returns file details for completed job owner', async () => { + prisma.dataExportJob.findUnique.mockResolvedValue({ + id: 'job-1', + userId: 'user-1', + status: 'COMPLETED', + filePath: '/tmp/exports/job-1.json', + }); + + const service = new DataExportService(prisma as any, config as any); + + const result = await service.downloadExport('user-1', 'job-1'); + + expect(result.contentType).toBe('application/json'); + expect(result.fileName).toBe('data-export-job-1.json'); + }); + + it('throws for non-owner', async () => { + prisma.dataExportJob.findUnique.mockResolvedValue({ + id: 'job-1', + userId: 'other-user', + status: 'COMPLETED', + filePath: '/tmp/exports/job-1.json', + }); + + const service = new DataExportService(prisma as any, config as any); + + await expect(service.downloadExport('user-1', 'job-1')).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('throws for non-completed job', async () => { + prisma.dataExportJob.findUnique.mockResolvedValue({ + id: 'job-1', + userId: 'user-1', + status: 'PROCESSING', + filePath: null, + }); + + const service = new DataExportService(prisma as any, config as any); + + await expect(service.downloadExport('user-1', 'job-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('throws for non-existent job', async () => { + prisma.dataExportJob.findUnique.mockResolvedValue(null); + + const service = new DataExportService(prisma as any, config as any); + + await expect(service.downloadExport('user-1', 'nonexistent')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + }); +}); diff --git a/src/modules/privacy/data-export.service.ts b/src/modules/privacy/data-export.service.ts new file mode 100644 index 0000000..f15ddc4 --- /dev/null +++ b/src/modules/privacy/data-export.service.ts @@ -0,0 +1,422 @@ +import { + Injectable, + Logger, + NotFoundException, + ForbiddenException, + BadRequestException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { DataExportStatus } from '@prisma/client'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Injectable() +export class DataExportService { + private readonly logger = new Logger(DataExportService.name); + private readonly exportDir: string; + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + ) { + this.exportDir = this.config.get('DATA_EXPORT_DIR', './data-exports'); + if (!fs.existsSync(this.exportDir)) { + fs.mkdirSync(this.exportDir, { recursive: true }); + } + } + + async requestExport(userId: string): Promise<{ jobId: string; status: DataExportStatus }> { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new BadRequestException('User not found'); + } + + const recentJob = await this.prisma.dataExportJob.findFirst({ + where: { + userId, + status: { in: ['PENDING', 'PROCESSING'] }, + }, + }); + + if (recentJob) { + return { jobId: recentJob.id, status: recentJob.status }; + } + + const job = await this.prisma.dataExportJob.create({ + data: { userId, status: 'PENDING' }, + }); + + this.processExport(job.id, userId).catch((error) => { + this.logger.error(`Export processing failed for job ${job.id}`, error.message); + }); + + return { jobId: job.id, status: 'PENDING' }; + } + + async getJobStatus(userId: string, jobId: string) { + const job = await this.prisma.dataExportJob.findUnique({ where: { id: jobId } }); + if (!job) { + throw new NotFoundException('Export job not found'); + } + + if (job.userId !== userId) { + throw new ForbiddenException('You do not have access to this export job'); + } + + return { + jobId: job.id, + status: job.status, + createdAt: job.createdAt, + completedAt: job.completedAt, + fileSize: job.fileSize, + errorMessage: job.status === 'FAILED' ? 'Export generation failed' : undefined, + }; + } + + async downloadExport(userId: string, jobId: string): Promise<{ filePath: string; contentType: string; fileName: string }> { + const job = await this.prisma.dataExportJob.findUnique({ where: { id: jobId } }); + if (!job) { + throw new NotFoundException('Export job not found'); + } + + if (job.userId !== userId) { + throw new ForbiddenException('You do not have access to this export'); + } + + if (job.status !== 'COMPLETED') { + throw new BadRequestException('Export is not ready for download'); + } + + if (!job.filePath) { + throw new BadRequestException('Export file not found'); + } + + const resolvedPath = path.resolve(job.filePath); + if (!resolvedPath.startsWith(path.resolve(this.exportDir))) { + throw new ForbiddenException('Invalid export file path'); + } + + if (!fs.existsSync(resolvedPath)) { + throw new BadRequestException('Export file has expired or been removed'); + } + + return { + filePath: resolvedPath, + contentType: 'application/json', + fileName: `data-export-${jobId}.json`, + }; + } + + private async processExport(jobId: string, userId: string): Promise { + await this.prisma.dataExportJob.update({ + where: { id: jobId }, + data: { status: 'PROCESSING' }, + }); + + try { + const userData = await this.compileUserData(userId); + const jsonContent = JSON.stringify(userData, null, 2); + const filePath = path.join(this.exportDir, `${jobId}.json`); + + fs.writeFileSync(filePath, jsonContent, 'utf-8'); + const stats = fs.statSync(filePath); + + await this.prisma.dataExportJob.update({ + where: { id: jobId }, + data: { + status: 'COMPLETED', + filePath, + fileSize: stats.size, + completedAt: new Date(), + }, + }); + + this.logger.log(`Export completed for job ${jobId}: ${stats.size} bytes`); + } catch (error) { + this.logger.error(`Export processing failed for job ${jobId}`, error.message); + await this.prisma.dataExportJob.update({ + where: { id: jobId }, + data: { + status: 'FAILED', + errorMessage: 'Failed to generate export file', + }, + }); + } + } + + private async compileUserData(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + bio: true, + avatarUrl: true, + role: true, + isVerified: true, + emailVerifiedAt: true, + stellarAddress: true, + createdAt: true, + updatedAt: true, + }, + }); + + const [ + enrollments, + certificates, + assignments, + notifications, + examAttempts, + reviews, + discussionComments, + refundRequests, + disputes, + kycSubmissions, + invoices, + couponRedemptions, + referrals, + referralRewards, + wishlistItems, + points, + pointsAwards, + questions, + answers, + smsMessages, + ] = await Promise.all([ + this.prisma.enrollment.findMany({ + where: { studentId: userId }, + select: { + id: true, + courseId: true, + amountPaid: true, + status: true, + progressPercent: true, + enrolledAt: true, + completedAt: true, + }, + }), + this.prisma.certificate.findMany({ + where: { studentId: userId }, + select: { + id: true, + courseId: true, + courseTitle: true, + isRevoked: true, + issuedAt: true, + }, + }), + this.prisma.assignmentSubmission.findMany({ + where: { studentId: userId }, + select: { + id: true, + assignmentId: true, + submissionUrl: true, + notes: true, + status: true, + feedback: true, + submittedAt: true, + }, + }), + this.prisma.notification.findMany({ + where: { userId }, + select: { + id: true, + type: true, + title: true, + message: true, + read: true, + createdAt: true, + }, + }), + this.prisma.examAttempt.findMany({ + where: { studentId: userId }, + select: { + id: true, + examId: true, + score: true, + passed: true, + attemptedAt: true, + }, + }), + this.prisma.courseReview.findMany({ + where: { studentId: userId }, + select: { + id: true, + courseId: true, + rating: true, + comment: true, + createdAt: true, + }, + }), + this.prisma.discussionComment.findMany({ + where: { authorId: userId }, + select: { + id: true, + discussionId: true, + content: true, + createdAt: true, + }, + }), + this.prisma.refund.findMany({ + where: { studentId: userId }, + select: { + id: true, + enrollmentId: true, + reason: true, + requestedAmount: true, + approvedAmount: true, + status: true, + createdAt: true, + }, + }), + this.prisma.dispute.findMany({ + where: { filedById: userId }, + select: { + id: true, + referenceType: true, + referenceId: true, + subject: true, + description: true, + status: true, + createdAt: true, + }, + }), + this.prisma.kycSubmission.findMany({ + where: { instructorId: userId }, + select: { + id: true, + documentType: true, + status: true, + createdAt: true, + }, + }), + this.prisma.invoice.findMany({ + where: { studentId: userId }, + select: { + id: true, + invoiceNumber: true, + amount: true, + issuedAt: true, + }, + }), + this.prisma.couponRedemption.findMany({ + where: { userId }, + select: { + id: true, + courseId: true, + discount: true, + redeemedAt: true, + }, + }), + this.prisma.referral.findMany({ + where: { referrerId: userId }, + select: { + id: true, + status: true, + signedUpAt: true, + convertedAt: true, + }, + }), + this.prisma.referralReward.findMany({ + where: { beneficiaryId: userId }, + select: { + id: true, + kind: true, + discountType: true, + discountValue: true, + status: true, + issuedAt: true, + redeemedAt: true, + expiresAt: true, + }, + }), + this.prisma.wishlistItem.findMany({ + where: { studentId: userId }, + select: { + id: true, + courseId: true, + createdAt: true, + }, + }), + this.prisma.userPoints.findUnique({ + where: { userId }, + select: { + totalPoints: true, + currentStreak: true, + longestStreak: true, + lastActivityDate: true, + }, + }), + this.prisma.pointsAward.findMany({ + where: { userId }, + select: { + id: true, + activityType: true, + points: true, + awardedDate: true, + }, + }), + this.prisma.question.findMany({ + where: { authorId: userId }, + select: { + id: true, + lessonId: true, + title: true, + content: true, + createdAt: true, + }, + }), + this.prisma.answer.findMany({ + where: { authorId: userId }, + select: { + id: true, + questionId: true, + content: true, + isBestAnswer: true, + createdAt: true, + }, + }), + this.prisma.smsMessage.findMany({ + where: { userId }, + select: { + id: true, + phoneNumber: true, + templateKey: true, + message: true, + status: true, + sentAt: true, + createdAt: true, + }, + }), + ]); + + return { + exportedAt: new Date().toISOString(), + user, + enrollments, + certificates, + assignments, + notifications, + examAttempts, + reviews, + discussionComments, + refundRequests, + disputes, + kycSubmissions, + invoices, + couponRedemptions, + referrals, + referralRewards, + wishlistItems, + gamification: { + points, + pointsAwards, + }, + questions, + answers, + smsMessages, + }; + } +} diff --git a/src/modules/privacy/privacy.module.ts b/src/modules/privacy/privacy.module.ts new file mode 100644 index 0000000..9a171e7 --- /dev/null +++ b/src/modules/privacy/privacy.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { DataExportController } from './data-export.controller'; +import { DataExportService } from './data-export.service'; + +@Module({ + controllers: [DataExportController], + providers: [DataExportService], + exports: [DataExportService], +}) +export class PrivacyModule {}