diff --git a/src/controllers/DataExportController.ts b/src/controllers/DataExportController.ts index 60a8372..6584af5 100644 --- a/src/controllers/DataExportController.ts +++ b/src/controllers/DataExportController.ts @@ -5,9 +5,11 @@ import { Request, Response } from 'express'; import BaseController from './BaseController'; import DataExportRequestResource from 'src/resources/DataExportRequestResource'; +import { config } from 'src/config'; import { prisma } from 'src/db'; import { dataExportQueue } from 'src/services/DataExportQueue'; import { DataExportService } from 'src/services/DataExportService'; +import { sendMail } from 'src/mailer/mailer'; import { logAuditEvent } from 'src/utils/auditLogger'; import { RequestError } from 'src/utils/errors'; import argon2 from 'argon2'; @@ -360,9 +362,22 @@ export default class extends BaseController { }, }); - // 4. Queue confirmation email (wire in your mailer/queue here) - // e.g. emailQueue.enqueue({ type: 'DELETION_REQUESTED', userId, scheduledAt, token }) - void token; // token is passed to the email queue — suppress unused-var lint + // 4. Send confirmation email with cancel link + const cancelUrl = `${config('app.front_url')}/account/cancel-deletion?token=${token}`; + await sendMail({ + to: user.email, + subject: 'Your account deletion has been scheduled', + text: + `You requested deletion of your Artisyn account.\n\n` + + `Your account will be permanently deleted on ${scheduledAt.toDateString()}.\n\n` + + `If you change your mind, cancel before that date:\n${cancelUrl}`, + temp: 'auth', + caption: 'Account deletion scheduled', + data: { + link: cancelUrl, + linkTitle: 'Cancel Deletion', + }, + }); await logAuditEvent(userId, 'DATA_DELETE', { req, @@ -405,8 +420,8 @@ export default class extends BaseController { // Resolve record by token (email-link flow) or userId (in-app flow) const pending = token - ? await prisma.pendingDeletion.findUnique({ where: { token } }) - : await prisma.pendingDeletion.findUnique({ where: { userId } }); + ? await prisma.pendingDeletion.findUnique({ where: { token }, include: { user: true } }) + : await prisma.pendingDeletion.findUnique({ where: { userId }, include: { user: true } }); RequestError.assertFound(pending, 'No pending deletion request found', 404); @@ -418,8 +433,13 @@ export default class extends BaseController { // Remove the pending-deletion record await prisma.pendingDeletion.delete({ where: { userId: pending.userId } }); - // Queue cancellation confirmation email - // e.g. emailQueue.enqueue({ type: 'DELETION_CANCELLED', userId: pending.userId }) + await sendMail({ + to: pending.user.email, + subject: 'Your account deletion has been cancelled', + text: 'Your account deletion request has been cancelled. Your Artisyn account is safe and will not be deleted.', + temp: 'auth', + caption: 'Account deletion cancelled', + }); await logAuditEvent(pending.userId, 'DATA_DELETE', { req, diff --git a/src/controllers/__tests__/data-export-http.test.ts b/src/controllers/__tests__/data-export-http.test.ts index a1a0429..6d2deb8 100644 --- a/src/controllers/__tests__/data-export-http.test.ts +++ b/src/controllers/__tests__/data-export-http.test.ts @@ -16,7 +16,7 @@ import fs from 'node:fs/promises'; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import argon2 from 'argon2'; import { faker } from '@faker-js/faker'; @@ -580,4 +580,94 @@ describe('Data Export & Account Deletion — HTTP integration', () => { expect(stillPending).not.toBeNull(); }); }); + + // ======================================================================= + // Email notifications + // ======================================================================= + + describe('Email notifications', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Re-apply the mock after clearAllMocks resets it + vi.spyOn(mailer, 'sendMail').mockResolvedValue(null); + }); + + it('sends a confirmation email to the user when deletion is requested', async () => { + await request(app) + .post('/api/account/deletion-request') + .set(authHeaders(owner.token)) + .send({ password: owner.plainPassword }) + .expect(202); + + expect(mailer.sendMail).toHaveBeenCalledOnce(); + const [call] = (mailer.sendMail as ReturnType).mock.calls; + const mailArgs = call[0]; + + expect(mailArgs.to).toBe(owner.user.email); + expect(mailArgs.subject).toMatch(/deletion/i); + // Body must mention the cancel link and deletion window + expect(mailArgs.text).toMatch(/cancel/i); + expect(mailArgs.data?.linkTitle).toMatch(/cancel/i); + }); + + it('sends a cancellation email after a deletion is cancelled in-app', async () => { + await request(app) + .post('/api/account/deletion-request') + .set(authHeaders(owner.token)) + .send({ password: owner.plainPassword }) + .expect(202); + + vi.clearAllMocks(); + vi.spyOn(mailer, 'sendMail').mockResolvedValue(null); + + await request(app) + .post('/api/account/cancel-deletion') + .set(authHeaders(owner.token)) + .send({}) + .expect(200); + + expect(mailer.sendMail).toHaveBeenCalledOnce(); + const [call] = (mailer.sendMail as ReturnType).mock.calls; + const mailArgs = call[0]; + + expect(mailArgs.to).toBe(owner.user.email); + expect(mailArgs.subject).toMatch(/cancelled/i); + }); + + it('sends a cancellation email after a deletion is cancelled via email token', async () => { + await request(app) + .post('/api/account/deletion-request') + .set(authHeaders(owner.token)) + .send({ password: owner.plainPassword }) + .expect(202); + + const record = await prisma.pendingDeletion.findUnique({ + where: { userId: owner.user.id }, + }); + + vi.clearAllMocks(); + vi.spyOn(mailer, 'sendMail').mockResolvedValue(null); + + await request(app) + .post('/api/account/cancel-deletion') + .set(authHeaders(owner.token)) + .send({ token: record!.token }) + .expect(200); + + expect(mailer.sendMail).toHaveBeenCalledOnce(); + const mailArgs = (mailer.sendMail as ReturnType).mock.calls[0][0]; + expect(mailArgs.to).toBe(owner.user.email); + expect(mailArgs.subject).toMatch(/cancelled/i); + }); + + it('does not send an email when deletion request fails (wrong password)', async () => { + await request(app) + .post('/api/account/deletion-request') + .set(authHeaders(owner.token)) + .send({ password: 'WrongPassword!' }) + .expect(401); + + expect(mailer.sendMail).not.toHaveBeenCalled(); + }); + }); }); \ No newline at end of file diff --git a/src/services/DeletionPurgeJob.ts b/src/services/DeletionPurgeJob.ts new file mode 100644 index 0000000..5f4f2d3 --- /dev/null +++ b/src/services/DeletionPurgeJob.ts @@ -0,0 +1,87 @@ +import { prisma } from 'src/db'; +import { logAuditEvent } from 'src/utils/auditLogger'; + +/** + * DeletionPurgeJob — executes pending account deletions whose grace period has elapsed. + * + * Called by the nightly deletion-purge scheduler. The job is idempotent: + * if a user record has already been removed (e.g. manual admin action) the + * corresponding stale pendingDeletion row is cleaned up without error. + * + * Cascade behaviour (from schema.prisma): + * - pendingDeletion.onDelete = Cascade → removed with the User row + * - auditLog.onDelete = SetNull → audit trail is preserved (userId → NULL) + */ +export class DeletionPurgeJob { + /** + * Run the purge sweep for all accounts whose scheduledAt has passed. + * Call this from a nightly cron/scheduler. + */ + static async run(): Promise { + const summary: PurgeRunSummary = { purged: 0, skipped: 0, errors: [] }; + + const due = await prisma.pendingDeletion.findMany({ + where: { scheduledAt: { lte: new Date() } }, + select: { userId: true }, + }); + + for (const { userId } of due) { + try { + const deleted = await this.purgeUser(userId); + if (deleted) { + summary.purged++; + } else { + summary.skipped++; + } + } catch (error) { + summary.errors.push({ + userId, + message: error instanceof Error ? error.message : String(error), + }); + } + } + + return summary; + } + + /** + * Purge a single user account. + * + * Returns true if the user was deleted, false if the user was already gone + * (stale record cleaned up instead). Exported so tests and admin tooling + * can trigger an immediate per-user purge. + */ + static async purgeUser(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + + if (!user) { + // User already removed — clean up the orphaned pendingDeletion row. + await prisma.pendingDeletion.deleteMany({ where: { userId } }); + return false; + } + + // Write audit record before deletion — the AuditLog relation uses + // onDelete: SetNull so this row survives the cascade as a compliance trail. + await logAuditEvent(userId, 'DATA_DELETE', { + entityType: 'User', + entityId: userId, + statusCode: 200, + metadata: { action: 'purge_account' }, + }); + + // Deleting the User row cascades to pendingDeletion (and all other + // Cascade-linked relations) automatically. + await prisma.user.delete({ where: { id: userId } }); + + return true; + } +} + +export interface PurgeRunSummary { + purged: number; + skipped: number; + errors: Array<{ userId: string; message: string }>; +} diff --git a/src/utils/deletionPurgeScheduler.ts b/src/utils/deletionPurgeScheduler.ts new file mode 100644 index 0000000..27a6559 --- /dev/null +++ b/src/utils/deletionPurgeScheduler.ts @@ -0,0 +1,52 @@ +import { DeletionPurgeJob } from 'src/services/DeletionPurgeJob'; +import { env } from './helpers'; + +const LOG_PREFIX = '[Deletion Purge Scheduler]'; + +const DAY = 24 * 60 * 60 * 1000; + +let purgeInterval: NodeJS.Timeout | null = null; + +const runPurge = async () => { + try { + if (env('NODE_ENV') !== 'test') { + console.log(`${LOG_PREFIX} Running deletion purge sweep...`); + } + + const summary = await DeletionPurgeJob.run(); + + if (env('NODE_ENV') !== 'test') { + console.log( + `${LOG_PREFIX} Sweep complete — purged: ${summary.purged}, skipped: ${summary.skipped}, errors: ${summary.errors.length}`, + ); + + for (const { userId, message } of summary.errors) { + console.error(`${LOG_PREFIX} Error purging user ${userId}: ${message}`); + } + } + } catch (error) { + console.error(`${LOG_PREFIX} Sweep failed:`, error); + } +}; + +export const startDeletionPurgeScheduler = () => { + if (env('NODE_ENV') === 'test') { + return; + } + + console.log(`${LOG_PREFIX} Starting scheduled deletion purge job (every 24 h)...`); + + purgeInterval = setInterval(runPurge, DAY); +}; + +export const stopDeletionPurgeScheduler = () => { + if (purgeInterval) { + clearInterval(purgeInterval); + purgeInterval = null; + } +}; + +export default { + start: startDeletionPurgeScheduler, + stop: stopDeletionPurgeScheduler, +}; diff --git a/src/utils/initialize.ts b/src/utils/initialize.ts index 5b0cfd2..226bb0a 100644 --- a/src/utils/initialize.ts +++ b/src/utils/initialize.ts @@ -33,6 +33,7 @@ import methodOverride from "method-override"; import passport from "passport"; import path from "path"; import { startAnalyticsScheduler } from "./analyticsScheduler"; +import { startDeletionPurgeScheduler } from "./deletionPurgeScheduler"; import { startMediaScheduler } from "./mediaScheduler"; import { startMonitoringScheduler } from "src/services/monitoringService"; @@ -156,6 +157,7 @@ export const initialize = async (app: Express) => { startLogCleanupScheduler(); startAnalyticsScheduler(); startMediaScheduler(); + startDeletionPurgeScheduler(); if (process.env.NODE_ENV !== "test") { console.log("[Security] All security services initialized successfully");