diff --git a/.env.example b/.env.example
index 2a31c10..ea40d3a 100644
--- a/.env.example
+++ b/.env.example
@@ -9,4 +9,10 @@ DATABASE_URL="postgresql://username:password@localhost:5432/learnault_db?schema=
STELLAR_FUNDING_AMOUNT=10
STELLAR_FUNDING_MIN_BALANCE=1
STELLAR_FUNDING_MAX_RETRIES=5
-# STELLAR_FUNDING_SOURCE_SECRET=S... (funding source account secret — set via secure env, never committed)
\ No newline at end of file
+# STELLAR_FUNDING_SOURCE_SECRET=S... (funding source account secret — set via secure env, never committed)
+
+# Account Lifecycle Configuration
+DELETION_COOLING_OFF_DAYS=30
+EXPORT_TTL_DAYS=7
+# Interval for the background lifecycle sweep (export generation, deletion finalization). 0 = disabled (lazy sweep on requests only)
+LIFECYCLE_SWEEP_INTERVAL_MS=0
diff --git a/nodemon.json b/nodemon.json
index 40370dd..9eea9a4 100644
--- a/nodemon.json
+++ b/nodemon.json
@@ -1,5 +1,5 @@
{
"watch": ["src"],
"ext": "ts",
- "exec": "ts-node src/server.ts"
+ "exec": "tsx src/server.ts"
}
\ No newline at end of file
diff --git a/prisma/migrations/20260719100042_first_migration/migration.sql b/prisma/migrations/20260719100042_first_migration/migration.sql
new file mode 100644
index 0000000..76bc2ed
--- /dev/null
+++ b/prisma/migrations/20260719100042_first_migration/migration.sql
@@ -0,0 +1,303 @@
+/*
+ Warnings:
+
+ - You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost.
+
+*/
+-- CreateEnum
+CREATE TYPE "Role" AS ENUM ('ADMIN', 'LEARNER', 'INSTRUCTOR');
+
+-- DropForeignKey
+ALTER TABLE "Completion" DROP CONSTRAINT "Completion_userId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "Credential" DROP CONSTRAINT "Credential_userId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "Transaction" DROP CONSTRAINT "Transaction_userId_fkey";
+
+-- DropTable
+DROP TABLE "User";
+
+-- CreateTable
+CREATE TABLE "users" (
+ "id" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "username" TEXT NOT NULL,
+ "password" TEXT NOT NULL,
+ "role" "Role" NOT NULL DEFAULT 'LEARNER',
+ "walletAddress" TEXT,
+ "isVerified" BOOLEAN NOT NULL DEFAULT false,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "lastLoginAt" TIMESTAMP(3),
+
+ CONSTRAINT "users_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "sessions" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "token" TEXT NOT NULL,
+ "refreshToken" TEXT,
+ "userAgent" TEXT,
+ "ipAddress" TEXT,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "isRevoked" BOOLEAN NOT NULL DEFAULT false,
+ "revokedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "audit_logs" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "action" TEXT NOT NULL,
+ "metadata" TEXT,
+ "ipAddress" TEXT,
+ "userAgent" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "verification_tokens" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "tokenHash" TEXT NOT NULL,
+ "type" TEXT NOT NULL DEFAULT 'EMAIL_VERIFICATION',
+ "status" TEXT NOT NULL DEFAULT 'PENDING',
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "verification_tokens_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "email_deliveries" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "to" TEXT NOT NULL,
+ "subject" TEXT NOT NULL,
+ "body" TEXT NOT NULL,
+ "type" TEXT NOT NULL DEFAULT 'EMAIL_VERIFICATION',
+ "status" TEXT NOT NULL DEFAULT 'pending',
+ "error" TEXT,
+ "attemptCount" INTEGER NOT NULL DEFAULT 0,
+ "maxAttempts" INTEGER NOT NULL DEFAULT 5,
+ "nextAttemptAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
+ "lastAttemptAt" TIMESTAMP(3),
+ "sentAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "email_deliveries_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "referral_codes" (
+ "id" TEXT NOT NULL,
+ "code" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "referral_codes_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "referrals" (
+ "id" TEXT NOT NULL,
+ "referrerId" TEXT NOT NULL,
+ "referreeId" TEXT NOT NULL,
+ "codeId" TEXT NOT NULL,
+ "bonusPaid" BOOLEAN NOT NULL DEFAULT false,
+ "bonusAmount" DOUBLE PRECISION,
+ "bonusPaidAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "referrals_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "sync_events" (
+ "id" TEXT NOT NULL,
+ "idempotencyKey" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "deviceId" TEXT NOT NULL,
+ "eventType" TEXT NOT NULL,
+ "payload" TEXT NOT NULL,
+ "clientTimestamp" TIMESTAMP(3) NOT NULL,
+ "serverTimestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "syncVersion" INTEGER NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'applied',
+ "rejectionReason" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "sync_events_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DeviceToken" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "token" TEXT NOT NULL,
+ "platform" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "DeviceToken_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "NotificationPreference" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "rewardReceipt" BOOLEAN NOT NULL DEFAULT true,
+ "quizPassFail" BOOLEAN NOT NULL DEFAULT true,
+ "streakReminders" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "NotificationLog" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "type" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "body" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'pending',
+ "error" TEXT,
+ "attemptCount" INTEGER NOT NULL DEFAULT 0,
+ "maxAttempts" INTEGER NOT NULL DEFAULT 5,
+ "nextAttemptAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "NotificationLog_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "stellar_fundings" (
+ "id" TEXT NOT NULL,
+ "publicKey" TEXT NOT NULL,
+ "amount" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'pending',
+ "transactionHash" TEXT,
+ "ledger" INTEGER,
+ "retryCount" INTEGER NOT NULL DEFAULT 0,
+ "maxRetries" INTEGER NOT NULL DEFAULT 5,
+ "nextAttemptAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
+ "lastAttemptAt" TIMESTAMP(3),
+ "error" TEXT,
+ "confirmedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "stellar_fundings_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "users_walletAddress_key" ON "users"("walletAddress");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "sessions_refreshToken_key" ON "sessions"("refreshToken");
+
+-- CreateIndex
+CREATE INDEX "sessions_userId_isRevoked_idx" ON "sessions"("userId", "isRevoked");
+
+-- CreateIndex
+CREATE INDEX "audit_logs_userId_action_createdAt_idx" ON "audit_logs"("userId", "action", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "verification_tokens_userId_status_idx" ON "verification_tokens"("userId", "status");
+
+-- CreateIndex
+CREATE INDEX "email_deliveries_status_nextAttemptAt_idx" ON "email_deliveries"("status", "nextAttemptAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "referral_codes_code_key" ON "referral_codes"("code");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "referral_codes_userId_key" ON "referral_codes"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "referrals_referreeId_key" ON "referrals"("referreeId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "sync_events_idempotencyKey_key" ON "sync_events"("idempotencyKey");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DeviceToken_token_key" ON "DeviceToken"("token");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "stellar_fundings_publicKey_key" ON "stellar_fundings"("publicKey");
+
+-- CreateIndex
+CREATE INDEX "stellar_fundings_status_nextAttemptAt_idx" ON "stellar_fundings"("status", "nextAttemptAt");
+
+-- AddForeignKey
+ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "verification_tokens" ADD CONSTRAINT "verification_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "email_deliveries" ADD CONSTRAINT "email_deliveries_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Completion" ADD CONSTRAINT "Completion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "referral_codes" ADD CONSTRAINT "referral_codes_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "referrals" ADD CONSTRAINT "referrals_referrerId_fkey" FOREIGN KEY ("referrerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "referrals" ADD CONSTRAINT "referrals_referreeId_fkey" FOREIGN KEY ("referreeId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "referrals" ADD CONSTRAINT "referrals_codeId_fkey" FOREIGN KEY ("codeId") REFERENCES "referral_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "sync_events" ADD CONSTRAINT "sync_events_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260719110000_account_lifecycle/migration.sql b/prisma/migrations/20260719110000_account_lifecycle/migration.sql
new file mode 100644
index 0000000..b492790
--- /dev/null
+++ b/prisma/migrations/20260719110000_account_lifecycle/migration.sql
@@ -0,0 +1,80 @@
+-- DropForeignKey
+ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_userId_fkey";
+
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'ACTIVE',
+ADD COLUMN "statusChangedAt" TIMESTAMP(3);
+
+-- AlterTable
+ALTER TABLE "audit_logs" ALTER COLUMN "userId" DROP NOT NULL;
+
+-- CreateTable
+CREATE TABLE "data_export_requests" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'pending',
+ "artifact" TEXT,
+ "artifactBytes" INTEGER,
+ "error" TEXT,
+ "attemptCount" INTEGER NOT NULL DEFAULT 0,
+ "maxAttempts" INTEGER NOT NULL DEFAULT 5,
+ "nextAttemptAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
+ "lastAttemptAt" TIMESTAMP(3),
+ "completedAt" TIMESTAMP(3),
+ "expiresAt" TIMESTAMP(3),
+ "downloadedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "data_export_requests_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "account_deletion_requests" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'pending',
+ "reason" TEXT,
+ "scheduledFor" TIMESTAMP(3) NOT NULL,
+ "cancelledAt" TIMESTAMP(3),
+ "completedAt" TIMESTAMP(3),
+ "error" TEXT,
+ "attemptCount" INTEGER NOT NULL DEFAULT 0,
+ "maxAttempts" INTEGER NOT NULL DEFAULT 5,
+ "nextAttemptAt" TIMESTAMP(3),
+ "lastAttemptAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "account_deletion_requests_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "data_export_requests_userId_status_idx" ON "data_export_requests"("userId", "status");
+
+-- CreateIndex
+CREATE INDEX "data_export_requests_status_nextAttemptAt_idx" ON "data_export_requests"("status", "nextAttemptAt");
+
+-- CreateIndex
+CREATE INDEX "data_export_requests_status_expiresAt_idx" ON "data_export_requests"("status", "expiresAt");
+
+-- CreateIndex
+CREATE INDEX "account_deletion_requests_userId_status_idx" ON "account_deletion_requests"("userId", "status");
+
+-- CreateIndex
+CREATE INDEX "account_deletion_requests_status_scheduledFor_idx" ON "account_deletion_requests"("status", "scheduledFor");
+
+-- AddForeignKey
+ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "data_export_requests" ADD CONSTRAINT "data_export_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "account_deletion_requests" ADD CONSTRAINT "account_deletion_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Race guards: at most one active (pending/processing) request per user.
+-- Partial unique indexes are not expressible in the Prisma schema language.
+CREATE UNIQUE INDEX "uq_active_deletion_per_user" ON "account_deletion_requests" ("userId") WHERE status IN ('pending','processing');
+
+CREATE UNIQUE INDEX "uq_active_export_per_user" ON "data_export_requests" ("userId") WHERE status IN ('pending','processing');
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 90ab33a..b117651 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -14,6 +14,8 @@ model User {
role Role @default(LEARNER)
walletAddress String? @unique
isVerified Boolean @default(false)
+ status String @default("ACTIVE") // ACTIVE, DEACTIVATED, PENDING_DELETION, DELETED
+ statusChangedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
@@ -31,6 +33,8 @@ model User {
emailDeliveries EmailDelivery[]
sessions Session[]
audits AuditLog[]
+ dataExports DataExportRequest[]
+ deletionRequests AccountDeletionRequest[]
@@map("users")
}
@@ -55,8 +59,8 @@ model Session {
model AuditLog {
id String @id @default(uuid())
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId String?
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
action String // "PASSWORD_RESET", "EMAIL_VERIFIED", "LOGIN", etc.
metadata String? // JSON string for additional details (without secrets)
ipAddress String?
@@ -295,3 +299,49 @@ model StellarFunding {
@@index([status, nextAttemptAt])
@@map("stellar_fundings")
}
+
+model DataExportRequest {
+ id String @id @default(uuid())
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ status String @default("pending") // pending, processing, ready, failed, expired
+ artifact String? // JSON export payload; nulled on expiry/purge
+ artifactBytes Int?
+ error String?
+ attemptCount Int @default(0)
+ maxAttempts Int @default(5)
+ nextAttemptAt DateTime? @default(now())
+ lastAttemptAt DateTime?
+ completedAt DateTime?
+ expiresAt DateTime?
+ downloadedAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([userId, status])
+ @@index([status, nextAttemptAt])
+ @@index([status, expiresAt])
+ @@map("data_export_requests")
+}
+
+model AccountDeletionRequest {
+ id String @id @default(uuid())
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ status String @default("pending") // pending (cooling-off), processing, completed, cancelled, failed
+ reason String?
+ scheduledFor DateTime
+ cancelledAt DateTime?
+ completedAt DateTime?
+ error String?
+ attemptCount Int @default(0)
+ maxAttempts Int @default(5)
+ nextAttemptAt DateTime?
+ lastAttemptAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([userId, status])
+ @@index([status, scheduledFor])
+ @@map("account_deletion_requests")
+}
diff --git a/src/config/env.ts b/src/config/env.ts
index 17fa122..75a7e6a 100644
--- a/src/config/env.ts
+++ b/src/config/env.ts
@@ -18,4 +18,9 @@ export const env = {
RATE_LIMIT_AUTHENTICATED_WINDOW_MS: parseInt(process.env.RATE_LIMIT_AUTHENTICATED_WINDOW_MS || '900000', 10), // 15 minutes
RATE_LIMIT_AUTHENTICATED_MAX: parseInt(process.env.RATE_LIMIT_AUTHENTICATED_MAX || '1000', 10),
+
+ // Account lifecycle configurations
+ DELETION_COOLING_OFF_DAYS: parseInt(process.env.DELETION_COOLING_OFF_DAYS || '30', 10),
+ EXPORT_TTL_DAYS: parseInt(process.env.EXPORT_TTL_DAYS || '7', 10),
+ LIFECYCLE_SWEEP_INTERVAL_MS: parseInt(process.env.LIFECYCLE_SWEEP_INTERVAL_MS || '0', 10), // 0 = disabled
}
\ No newline at end of file
diff --git a/src/controllers/account.controller.ts b/src/controllers/account.controller.ts
new file mode 100644
index 0000000..a38352b
--- /dev/null
+++ b/src/controllers/account.controller.ts
@@ -0,0 +1,638 @@
+import { Request, Response } from 'express'
+import bcrypt from 'bcryptjs'
+import jwt from 'jsonwebtoken'
+import prisma from '../config/database'
+import logger from '../utils/logger'
+import {
+ deactivateSchema,
+ reactivateSchema,
+ requestDeletionSchema,
+ cancelDeletionSchema,
+ exportIdParamSchema,
+} from '../schemas/account.schema'
+import { dataExportService } from '../services/data-export.service'
+import { accountLifecycleService } from '../services/account-lifecycle.service'
+import { auditService } from '../services/audit.service'
+import { emailService } from '../services/email.service'
+import { AccountStatus, AuditAction, ExportStatus, RequestContext } from '../types/account.types'
+
+const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret'
+const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
+
+function buildDeletionRequestedEmail(username: string, scheduledFor: Date): { subject: string; body: string } {
+ const subject = 'Your account deletion request'
+ const body = `\
+
+
+
+
+ Account deletion requested
+ Hi ${username},
+ We received a request to permanently delete your Learnault account.
+ Your account is now deactivated and will be permanently deleted on
+ ${scheduledFor.toUTCString()}.
+ If you change your mind before then, you can cancel the deletion by signing in
+ through the "cancel deletion" option with your email and password.
+ If you did not request this, cancel the deletion and change your password immediately.
+
+`
+
+ return { subject, body }
+}
+
+function buildDeletionCancelledEmail(username: string): { subject: string; body: string } {
+ const subject = 'Your account deletion was cancelled'
+ const body = `\
+
+
+
+
+ Deletion cancelled
+ Hi ${username},
+ Your account deletion request has been cancelled and your account is active again.
+ If you did not do this, please change your password immediately.
+
+`
+
+ return { subject, body }
+}
+
+export class AccountController {
+ /**
+ * @openapi
+ * /v1/account/export:
+ * post:
+ * summary: Request a data export
+ * description: Starts asynchronous generation of a user-scoped data export. Only one active export request is allowed at a time.
+ * tags: [Account]
+ * security:
+ * - bearerAuth: []
+ * responses:
+ * 202:
+ * description: Export request accepted
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/DataExportRequest'
+ * 401:
+ * description: Authentication required
+ * 403:
+ * description: Account is not active
+ * 409:
+ * description: An active export request already exists
+ */
+ async requestExport(req: Request, res: Response): Promise {
+ try {
+ const userId = req.user!.id
+
+ this.sweepInBackground()
+
+ const result = await dataExportService.requestExport(userId)
+
+ if (result.kind === 'duplicate') {
+ res.status(409).json({
+ error: 'An export request is already in progress',
+ existingRequestId: result.request.id,
+ })
+
+ return
+ }
+
+ res.status(202).json({
+ id: result.request.id,
+ status: result.request.status,
+ createdAt: result.request.createdAt,
+ })
+ } catch (error) {
+ logger.error('Export request error:', error)
+ res.status(500).json({ error: 'Internal server error during export request' })
+ }
+ }
+
+ /**
+ * @openapi
+ * /v1/account/export/{id}:
+ * get:
+ * summary: Get export request status
+ * description: Returns the status of an export request owned by the authenticated user. Requests owned by other users behave as not found.
+ * tags: [Account]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * format: uuid
+ * responses:
+ * 200:
+ * description: Export request status
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/DataExportRequest'
+ * 400:
+ * description: Invalid export id
+ * 401:
+ * description: Authentication required
+ * 404:
+ * description: Export request not found
+ */
+ async getExportStatus(req: Request, res: Response): Promise {
+ try {
+ const params = exportIdParamSchema.safeParse(req.params)
+ if (!params.success) {
+ res.status(400).json({ error: 'Validation failed', details: params.error.format() })
+
+ return
+ }
+
+ const request = await dataExportService.getExportStatus(req.user!.id, params.data.id)
+
+ if (!request) {
+ res.status(404).json({ error: 'Export request not found' })
+
+ return
+ }
+
+ res.status(200).json({
+ id: request.id,
+ status: request.status,
+ createdAt: request.createdAt,
+ completedAt: request.completedAt,
+ expiresAt: request.expiresAt,
+ downloadedAt: request.downloadedAt,
+ })
+ } catch (error) {
+ logger.error('Export status error:', error)
+ res.status(500).json({ error: 'Internal server error during export status lookup' })
+ }
+ }
+
+ /**
+ * @openapi
+ * /v1/account/export/{id}/download:
+ * get:
+ * summary: Download a ready data export
+ * description: Streams the export artifact as a JSON attachment. Exports expire and are purged after their retention window.
+ * tags: [Account]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * format: uuid
+ * responses:
+ * 200:
+ * description: Export artifact (JSON attachment)
+ * 400:
+ * description: Invalid export id
+ * 401:
+ * description: Authentication required
+ * 404:
+ * description: Export request not found
+ * 409:
+ * description: Export is not ready yet
+ * 410:
+ * description: Export expired, was purged, or failed
+ */
+ async downloadExport(req: Request, res: Response): Promise {
+ try {
+ const params = exportIdParamSchema.safeParse(req.params)
+ if (!params.success) {
+ res.status(400).json({ error: 'Validation failed', details: params.error.format() })
+
+ return
+ }
+
+ const userId = req.user!.id
+ const request = await dataExportService.getExportStatus(userId, params.data.id)
+
+ if (!request) {
+ res.status(404).json({ error: 'Export request not found' })
+
+ return
+ }
+
+ if (request.status === ExportStatus.PENDING || request.status === ExportStatus.PROCESSING) {
+ res.status(409).json({ error: 'Export is not ready yet', status: request.status })
+
+ return
+ }
+
+ const isExpired =
+ request.status === ExportStatus.EXPIRED ||
+ (request.expiresAt !== null && request.expiresAt <= new Date())
+
+ if (request.status === ExportStatus.FAILED || isExpired || !request.artifact) {
+ res.status(410).json({ error: 'Export is no longer available' })
+
+ return
+ }
+
+ await dataExportService.markDownloaded(userId, request.id)
+
+ res.setHeader('Content-Type', 'application/json')
+ res.setHeader(
+ 'Content-Disposition',
+ `attachment; filename="learnault-export-${request.id}.json"`
+ )
+ res.status(200).send(request.artifact)
+ } catch (error) {
+ logger.error('Export download error:', error)
+ res.status(500).json({ error: 'Internal server error during export download' })
+ }
+ }
+
+ /**
+ * @openapi
+ * /v1/account/deactivate:
+ * post:
+ * summary: Deactivate account
+ * description: Reversibly deactivates the account. Requires password re-entry (step-up). Revokes sessions and blocks login until reactivation.
+ * tags: [Account]
+ * security:
+ * - bearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/DeactivateInput'
+ * responses:
+ * 200:
+ * description: Account deactivated
+ * 400:
+ * description: Validation failed
+ * 401:
+ * description: Authentication required or wrong password
+ * 409:
+ * description: Account is already deactivated or pending deletion
+ */
+ async deactivate(req: Request, res: Response): Promise {
+ try {
+ const validation = deactivateSchema.safeParse(req.body)
+ if (!validation.success) {
+ res.status(400).json({ error: 'Validation failed', details: validation.error.format() })
+
+ return
+ }
+
+ const user = await this.stepUp(req, res, validation.data.password, 'deactivate')
+ if (!user) {
+ return
+ }
+
+ const result = await accountLifecycleService.deactivate(user.id, user.status, this.context(req))
+
+ if (result.kind === 'conflict') {
+ res.status(409).json({
+ error: result.status === AccountStatus.PENDING_DELETION
+ ? 'Account is pending deletion'
+ : 'Account is already deactivated',
+ code: result.status,
+ })
+
+ return
+ }
+
+ res.status(200).json({ message: 'Account deactivated successfully', status: AccountStatus.DEACTIVATED })
+ } catch (error) {
+ logger.error('Deactivation error:', error)
+ res.status(500).json({ error: 'Internal server error during deactivation' })
+ }
+ }
+
+ /**
+ * @openapi
+ * /v1/account/reactivate:
+ * post:
+ * summary: Reactivate a deactivated account
+ * description: Public endpoint (deactivated accounts cannot log in). Verifies credentials and reactivates the account, returning a fresh token.
+ * tags: [Account]
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/ReactivateInput'
+ * responses:
+ * 200:
+ * description: Account reactivated
+ * 400:
+ * description: Validation failed
+ * 401:
+ * description: Invalid credentials
+ * 409:
+ * description: Account is pending deletion — cancel the deletion request instead
+ */
+ async reactivate(req: Request, res: Response): Promise {
+ try {
+ const validation = reactivateSchema.safeParse(req.body)
+ if (!validation.success) {
+ res.status(400).json({ error: 'Validation failed', details: validation.error.format() })
+
+ return
+ }
+
+ const user = await this.verifyCredentials(validation.data.email, validation.data.password)
+ if (!user) {
+ res.status(401).json({ error: 'Invalid credentials' })
+
+ return
+ }
+
+ if (user.status === AccountStatus.PENDING_DELETION) {
+ res.status(409).json({
+ error: 'Account is scheduled for deletion. Cancel the deletion request to restore access.',
+ code: 'ACCOUNT_PENDING_DELETION',
+ })
+
+ return
+ }
+
+ if (user.status === AccountStatus.DEACTIVATED) {
+ await accountLifecycleService.reactivate(user.id, this.context(req))
+ }
+
+ const token = this.generateToken(user.id, user.role)
+
+ res.status(200).json({
+ message: 'Account reactivated successfully',
+ token,
+ user: {
+ id: user.id,
+ email: user.email,
+ username: user.username,
+ role: user.role,
+ },
+ })
+ } catch (error) {
+ logger.error('Reactivation error:', error)
+ res.status(500).json({ error: 'Internal server error during reactivation' })
+ }
+ }
+
+ /**
+ * @openapi
+ * /v1/account/deletion:
+ * post:
+ * summary: Request account deletion
+ * description: Starts the deletion process with a cooling-off window. Requires password re-entry (step-up). The account behaves as deactivated until finalization or cancellation.
+ * tags: [Account]
+ * security:
+ * - bearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/RequestDeletionInput'
+ * responses:
+ * 202:
+ * description: Deletion request accepted
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/AccountDeletionRequest'
+ * 400:
+ * description: Validation failed
+ * 401:
+ * description: Authentication required or wrong password
+ * 403:
+ * description: Account is not active
+ * 409:
+ * description: An active deletion request already exists
+ */
+ async requestDeletion(req: Request, res: Response): Promise {
+ try {
+ const validation = requestDeletionSchema.safeParse(req.body)
+ if (!validation.success) {
+ res.status(400).json({ error: 'Validation failed', details: validation.error.format() })
+
+ return
+ }
+
+ const user = await this.stepUp(req, res, validation.data.password, 'deletion')
+ if (!user) {
+ return
+ }
+
+ const result = await accountLifecycleService.requestDeletion(
+ user.id,
+ validation.data.reason,
+ this.context(req)
+ )
+
+ if (result.kind === 'duplicate') {
+ res.status(409).json({
+ error: 'A deletion request is already pending',
+ existingRequestId: result.request.id,
+ scheduledFor: result.request.scheduledFor,
+ })
+
+ return
+ }
+
+ const email = buildDeletionRequestedEmail(user.username, result.request.scheduledFor)
+ await emailService.queueEmail(user.id, user.email, email.subject, email.body, 'ACCOUNT_DELETION')
+
+ res.status(202).json({
+ id: result.request.id,
+ status: result.request.status,
+ scheduledFor: result.request.scheduledFor,
+ })
+ } catch (error) {
+ logger.error('Deletion request error:', error)
+ res.status(500).json({ error: 'Internal server error during deletion request' })
+ }
+ }
+
+ /**
+ * @openapi
+ * /v1/account/deletion:
+ * get:
+ * summary: Get deletion request status
+ * description: Returns the latest deletion request for the authenticated user, or a null status when none exists.
+ * tags: [Account]
+ * security:
+ * - bearerAuth: []
+ * responses:
+ * 200:
+ * description: Latest deletion request, or null status
+ * 401:
+ * description: Authentication required
+ */
+ async getDeletionStatus(req: Request, res: Response): Promise {
+ try {
+ this.sweepInBackground()
+
+ const request = await accountLifecycleService.getLatestDeletionRequest(req.user!.id)
+
+ if (!request) {
+ res.status(200).json({ status: null })
+
+ return
+ }
+
+ res.status(200).json({
+ id: request.id,
+ status: request.status,
+ scheduledFor: request.scheduledFor,
+ cancelledAt: request.cancelledAt,
+ completedAt: request.completedAt,
+ createdAt: request.createdAt,
+ })
+ } catch (error) {
+ logger.error('Deletion status error:', error)
+ res.status(500).json({ error: 'Internal server error during deletion status lookup' })
+ }
+ }
+
+ /**
+ * @openapi
+ * /v1/account/deletion/cancel:
+ * post:
+ * summary: Cancel a pending deletion request
+ * description: Public endpoint (accounts pending deletion cannot log in). Verifies credentials and cancels the pending deletion, restoring the account.
+ * tags: [Account]
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/CancelDeletionInput'
+ * responses:
+ * 200:
+ * description: Deletion cancelled, account restored
+ * 400:
+ * description: Validation failed
+ * 401:
+ * description: Invalid credentials
+ * 404:
+ * description: No pending deletion request
+ * 410:
+ * description: Deletion has already been finalized
+ */
+ async cancelDeletion(req: Request, res: Response): Promise {
+ try {
+ const validation = cancelDeletionSchema.safeParse(req.body)
+ if (!validation.success) {
+ res.status(400).json({ error: 'Validation failed', details: validation.error.format() })
+
+ return
+ }
+
+ const user = await this.verifyCredentials(validation.data.email, validation.data.password)
+ if (!user) {
+ res.status(401).json({ error: 'Invalid credentials' })
+
+ return
+ }
+
+ const result = await accountLifecycleService.cancelDeletion(user.id, this.context(req))
+
+ if (result.kind === 'none') {
+ res.status(404).json({ error: 'No pending deletion request found' })
+
+ return
+ }
+
+ if (result.kind === 'finalized') {
+ res.status(410).json({ error: 'Deletion has already been finalized and cannot be cancelled' })
+
+ return
+ }
+
+ const email = buildDeletionCancelledEmail(user.username)
+ await emailService.queueEmail(user.id, user.email, email.subject, email.body, 'ACCOUNT_DELETION')
+
+ res.status(200).json({ message: 'Deletion request cancelled. Your account is active again.' })
+ } catch (error) {
+ logger.error('Deletion cancellation error:', error)
+ res.status(500).json({ error: 'Internal server error during deletion cancellation' })
+ }
+ }
+
+ /**
+ * Step-up authentication: even with a valid JWT, sensitive actions require
+ * fresh password re-entry. Responds 401 and returns null on failure.
+ */
+ private async stepUp(
+ req: Request,
+ res: Response,
+ password: string,
+ action: string
+ ): Promise<{ id: string; email: string; username: string; role: string; status: string } | null> {
+ const userId = req.user!.id
+ const user = await prisma.user.findUnique({ where: { id: userId } })
+
+ if (!user || user.status === AccountStatus.DELETED) {
+ res.status(401).json({ error: 'Account not found' })
+
+ return null
+ }
+
+ const isMatch = await bcrypt.compare(password, user.password)
+ if (!isMatch) {
+ await auditService.record({
+ userId,
+ action: AuditAction.STEP_UP_FAILED,
+ metadata: { attemptedAction: action },
+ ...this.context(req),
+ })
+ res.status(401).json({ error: 'Invalid password', code: 'STEP_UP_FAILED' })
+
+ return null
+ }
+
+ return user
+ }
+
+ /**
+ * Credential verification for public lifecycle endpoints. Neutral null on
+ * unknown email, wrong password, or tombstoned account (no state leaks).
+ */
+ private async verifyCredentials(
+ email: string,
+ password: string
+ ): Promise<{ id: string; email: string; username: string; role: string; status: string } | null> {
+ const user = await prisma.user.findUnique({ where: { email } })
+
+ if (!user || user.status === AccountStatus.DELETED) {
+ return null
+ }
+
+ const isMatch = await bcrypt.compare(password, user.password)
+ if (!isMatch) {
+ return null
+ }
+
+ return user
+ }
+
+ private context(req: Request): RequestContext {
+ return {
+ ipAddress: req.ip,
+ userAgent: req.headers['user-agent'],
+ }
+ }
+
+ private sweepInBackground(): void {
+ accountLifecycleService.sweep().catch(err =>
+ logger.error('Lifecycle sweep error:', err)
+ )
+ }
+
+ private generateToken(userId: string, role: string): string {
+ return jwt.sign(
+ { id: userId, role },
+ JWT_SECRET as string,
+ { expiresIn: JWT_EXPIRES_IN as any }
+ )
+ }
+}
diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts
index 1e8dadb..d8a92c1 100644
--- a/src/controllers/auth.controller.ts
+++ b/src/controllers/auth.controller.ts
@@ -439,6 +439,37 @@ export class AuthController {
return
}
+ if (user.status === 'DELETED') {
+ // Tombstoned account — indistinguishable from unknown credentials
+ res.status(401).json({ error: 'Invalid credentials' })
+
+ return
+ }
+
+ if (user.status === 'DEACTIVATED') {
+ res.status(403).json({
+ error: 'Account is deactivated',
+ code: 'ACCOUNT_DEACTIVATED',
+ })
+
+ return
+ }
+
+ if (user.status === 'PENDING_DELETION') {
+ const deletionRequest = await prisma.accountDeletionRequest.findFirst({
+ where: { userId: user.id, status: 'pending' },
+ select: { scheduledFor: true },
+ })
+
+ res.status(403).json({
+ error: 'Account is scheduled for deletion',
+ code: 'ACCOUNT_PENDING_DELETION',
+ scheduledFor: deletionRequest?.scheduledFor ?? null,
+ })
+
+ return
+ }
+
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() }
diff --git a/src/docs/schemas.ts b/src/docs/schemas.ts
index eb1884f..9fcc1d2 100644
--- a/src/docs/schemas.ts
+++ b/src/docs/schemas.ts
@@ -405,3 +405,109 @@
* verification:
* type: object
*/
+
+/**
+ * @openapi
+ * components:
+ * schemas:
+ * DataExportRequest:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * format: uuid
+ * status:
+ * type: string
+ * enum: [pending, processing, ready, failed, expired]
+ * createdAt:
+ * type: string
+ * format: date-time
+ * completedAt:
+ * type: string
+ * format: date-time
+ * nullable: true
+ * expiresAt:
+ * type: string
+ * format: date-time
+ * nullable: true
+ * downloadedAt:
+ * type: string
+ * format: date-time
+ * nullable: true
+ *
+ * AccountDeletionRequest:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * format: uuid
+ * status:
+ * type: string
+ * enum: [pending, processing, completed, cancelled, failed]
+ * scheduledFor:
+ * type: string
+ * format: date-time
+ * cancelledAt:
+ * type: string
+ * format: date-time
+ * nullable: true
+ * completedAt:
+ * type: string
+ * format: date-time
+ * nullable: true
+ * createdAt:
+ * type: string
+ * format: date-time
+ *
+ * DeactivateInput:
+ * type: object
+ * required: [password]
+ * properties:
+ * password:
+ * type: string
+ * description: Current password (step-up re-authentication)
+ *
+ * ReactivateInput:
+ * type: object
+ * required: [email, password]
+ * properties:
+ * email:
+ * type: string
+ * format: email
+ * password:
+ * type: string
+ *
+ * RequestDeletionInput:
+ * type: object
+ * required: [password]
+ * properties:
+ * password:
+ * type: string
+ * description: Current password (step-up re-authentication)
+ * reason:
+ * type: string
+ * maxLength: 500
+ *
+ * CancelDeletionInput:
+ * type: object
+ * required: [email, password]
+ * properties:
+ * email:
+ * type: string
+ * format: email
+ * password:
+ * type: string
+ *
+ * AccountStatusError:
+ * type: object
+ * properties:
+ * error:
+ * type: string
+ * code:
+ * type: string
+ * enum: [ACCOUNT_DEACTIVATED, ACCOUNT_PENDING_DELETION, STEP_UP_FAILED]
+ * scheduledFor:
+ * type: string
+ * format: date-time
+ * nullable: true
+ */
diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts
index 5920318..4d976f6 100644
--- a/src/middleware/auth.middleware.ts
+++ b/src/middleware/auth.middleware.ts
@@ -1,6 +1,7 @@
import { NextFunction, Request, Response } from 'express'
import jwt from 'jsonwebtoken'
+import prisma from '../config/database'
export type UserRole = 'learner' | 'employer';
@@ -89,6 +90,59 @@ export const optionalAuthenticate = (
next()
}
+/**
+ * Account-status gate — must be used after `authenticate`.
+ * JWTs are stateless, so tokens issued before deactivation or a deletion
+ * request stay verifiable until expiry; this middleware checks the current
+ * account status in the database and only lets ACTIVE accounts through.
+ */
+export const requireActiveAccount = async (
+ req: Request,
+ res: Response,
+ next: NextFunction
+): Promise => {
+ if (!req.user) {
+ res.status(401).json({ message: 'Authentication required' })
+
+ return
+ }
+
+ try {
+ const user = await prisma.user.findUnique({
+ where: { id: req.user.id },
+ select: { status: true },
+ })
+
+ if (!user || user.status === 'DELETED') {
+ res.status(401).json({ message: 'Account not found' })
+
+ return
+ }
+
+ if (user.status === 'DEACTIVATED') {
+ res.status(403).json({
+ message: 'Account is deactivated',
+ code: 'ACCOUNT_DEACTIVATED',
+ })
+
+ return
+ }
+
+ if (user.status === 'PENDING_DELETION') {
+ res.status(403).json({
+ message: 'Account is scheduled for deletion',
+ code: 'ACCOUNT_PENDING_DELETION',
+ })
+
+ return
+ }
+
+ next()
+ } catch {
+ res.status(500).json({ message: 'Internal server error during account status check' })
+ }
+}
+
/**
* Role-based authorization — must be used after `authenticate`.
* Restricts access to users with one of the specified roles.
diff --git a/src/routes/index.ts b/src/routes/index.ts
index 97def71..617d0e3 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -8,6 +8,7 @@ import userRoutes from './v1/users.routes'
import notificationRoutes from './v1/notifications.routes'
import syncRoutes from './v1/sync.routes'
import referralRoutes from './v1/referrals.routes'
+import accountRoutes from './v1/account.routes'
const router: Router = Router()
@@ -24,5 +25,6 @@ router.use('/v1/employer', employerRoutes)
router.use('/v1/notifications', notificationRoutes)
router.use('/v1/sync', syncRoutes)
router.use('/v1/referrals', referralRoutes)
+router.use('/v1/account', accountRoutes)
export default router
diff --git a/src/routes/v1/account.routes.ts b/src/routes/v1/account.routes.ts
new file mode 100644
index 0000000..14c9285
--- /dev/null
+++ b/src/routes/v1/account.routes.ts
@@ -0,0 +1,65 @@
+import { Router } from 'express'
+import { AccountController } from '../../controllers/account.controller'
+import { authenticate, requireActiveAccount } from '../../middleware/auth.middleware'
+import { authLimiter } from '../../middleware/rate-limit.middleware'
+
+const router: Router = Router()
+const accountController = new AccountController()
+
+/**
+ * @route POST /api/v1/account/export
+ * @desc Request an asynchronous data export
+ * @access Private (active accounts only)
+ */
+router.post('/export', authenticate, requireActiveAccount, accountController.requestExport.bind(accountController))
+
+/**
+ * @route GET /api/v1/account/export/:id
+ * @desc Get export request status (allowed while deactivated/pending deletion)
+ * @access Private
+ */
+router.get('/export/:id', authenticate, accountController.getExportStatus.bind(accountController))
+
+/**
+ * @route GET /api/v1/account/export/:id/download
+ * @desc Download a ready export artifact (allowed while deactivated/pending deletion)
+ * @access Private
+ */
+router.get('/export/:id/download', authenticate, accountController.downloadExport.bind(accountController))
+
+/**
+ * @route POST /api/v1/account/deactivate
+ * @desc Deactivate account (step-up: password re-entry)
+ * @access Private
+ */
+router.post('/deactivate', authenticate, accountController.deactivate.bind(accountController))
+
+/**
+ * @route POST /api/v1/account/reactivate
+ * @desc Reactivate a deactivated account (deactivated accounts cannot log in)
+ * @access Public (credentialed)
+ */
+router.post('/reactivate', authLimiter, accountController.reactivate.bind(accountController))
+
+/**
+ * @route POST /api/v1/account/deletion
+ * @desc Request account deletion with cooling-off window (step-up: password re-entry)
+ * @access Private (active accounts only)
+ */
+router.post('/deletion', authenticate, requireActiveAccount, accountController.requestDeletion.bind(accountController))
+
+/**
+ * @route GET /api/v1/account/deletion
+ * @desc Get latest deletion request status (allowed while pending deletion)
+ * @access Private
+ */
+router.get('/deletion', authenticate, accountController.getDeletionStatus.bind(accountController))
+
+/**
+ * @route POST /api/v1/account/deletion/cancel
+ * @desc Cancel a pending deletion request (accounts pending deletion cannot log in)
+ * @access Public (credentialed)
+ */
+router.post('/deletion/cancel', authLimiter, accountController.cancelDeletion.bind(accountController))
+
+export default router
diff --git a/src/schemas/account.schema.ts b/src/schemas/account.schema.ts
new file mode 100644
index 0000000..de347f8
--- /dev/null
+++ b/src/schemas/account.schema.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod'
+
+export const deactivateSchema = z.object({
+ password: z.string().min(1, 'Password is required'),
+})
+
+export const reactivateSchema = z.object({
+ email: z.string().email('Invalid email address'),
+ password: z.string().min(1, 'Password is required'),
+})
+
+export const requestDeletionSchema = z.object({
+ password: z.string().min(1, 'Password is required'),
+ reason: z.string().max(500, 'Reason must be at most 500 characters').optional(),
+})
+
+export const cancelDeletionSchema = z.object({
+ email: z.string().email('Invalid email address'),
+ password: z.string().min(1, 'Password is required'),
+})
+
+export const exportIdParamSchema = z.object({
+ id: z.string().uuid('Invalid export id'),
+})
+
+export type DeactivateInput = z.infer;
+export type ReactivateInput = z.infer;
+export type RequestDeletionInput = z.infer;
+export type CancelDeletionInput = z.infer;
diff --git a/src/server.ts b/src/server.ts
index 8515d34..53d4f94 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,7 +1,21 @@
-import app from './app'
-
-const PORT = process.env.PORT || 5000
-
-app.listen(PORT, () => {
- console.log(`Server running on port ${PORT}`)
-})
\ No newline at end of file
+import app from './app'
+import { env } from './config/env'
+import { accountLifecycleService } from './services/account-lifecycle.service'
+import logger from './utils/logger'
+
+const PORT = process.env.PORT || 5000
+
+app.listen(PORT, () => {
+ console.log(`Server running on port ${PORT}`)
+})
+
+// Periodic lifecycle sweep (export generation, deletion finalization, artifact
+// purge). Disabled when LIFECYCLE_SWEEP_INTERVAL_MS is 0 — the sweep still runs
+// lazily from account endpoints, and a dedicated worker can call sweep() directly.
+if (env.LIFECYCLE_SWEEP_INTERVAL_MS > 0) {
+ setInterval(() => {
+ accountLifecycleService.sweep().catch(err =>
+ logger.error('Scheduled lifecycle sweep error:', err)
+ )
+ }, env.LIFECYCLE_SWEEP_INTERVAL_MS).unref()
+}
diff --git a/src/services/account-lifecycle.service.ts b/src/services/account-lifecycle.service.ts
new file mode 100644
index 0000000..720a616
--- /dev/null
+++ b/src/services/account-lifecycle.service.ts
@@ -0,0 +1,307 @@
+import crypto from 'crypto'
+import prisma from '../config/database'
+import logger from '../utils/logger'
+import { env } from '../config/env'
+import { auditService } from './audit.service'
+import { dataExportService } from './data-export.service'
+import {
+ AccountStatus,
+ AuditAction,
+ DeletionStatus,
+ RequestContext,
+} from '../types/account.types'
+
+const ACTIVE_DELETION_STATUSES = [DeletionStatus.PENDING, DeletionStatus.PROCESSING]
+
+export interface DeletionRequestRecord {
+ id: string
+ userId: string
+ status: string
+ reason: string | null
+ scheduledFor: Date
+ cancelledAt: Date | null
+ completedAt: Date | null
+ attemptCount: number
+ maxAttempts: number
+ createdAt: Date
+}
+
+export type DeactivateResult =
+ | { kind: 'deactivated' }
+ | { kind: 'conflict'; status: string }
+
+export type RequestDeletionResult =
+ | { kind: 'created'; request: DeletionRequestRecord }
+ | { kind: 'duplicate'; request: DeletionRequestRecord }
+
+export type CancelDeletionResult =
+ | { kind: 'cancelled'; request: DeletionRequestRecord }
+ | { kind: 'finalized' }
+ | { kind: 'none' }
+
+export class AccountLifecycleService {
+ async deactivate(userId: string, currentStatus: string, ctx: RequestContext): Promise {
+ if (currentStatus !== AccountStatus.ACTIVE) {
+ return { kind: 'conflict', status: currentStatus }
+ }
+
+ await prisma.$transaction([
+ prisma.user.update({
+ where: { id: userId },
+ data: { status: AccountStatus.DEACTIVATED, statusChangedAt: new Date() },
+ }),
+ prisma.session.updateMany({
+ where: { userId, isRevoked: false },
+ data: { isRevoked: true, revokedAt: new Date() },
+ }),
+ auditService.op({ userId, action: AuditAction.ACCOUNT_DEACTIVATED, ...ctx }),
+ ])
+
+ return { kind: 'deactivated' }
+ }
+
+ async reactivate(userId: string, ctx: RequestContext): Promise {
+ await prisma.$transaction([
+ prisma.user.update({
+ where: { id: userId },
+ data: { status: AccountStatus.ACTIVE, statusChangedAt: new Date() },
+ }),
+ auditService.op({ userId, action: AuditAction.ACCOUNT_REACTIVATED, ...ctx }),
+ ])
+ }
+
+ async requestDeletion(
+ userId: string,
+ reason: string | undefined,
+ ctx: RequestContext
+ ): Promise {
+ const existing = await prisma.accountDeletionRequest.findFirst({
+ where: { userId, status: { in: ACTIVE_DELETION_STATUSES } },
+ })
+
+ if (existing) {
+ return { kind: 'duplicate', request: existing as DeletionRequestRecord }
+ }
+
+ const scheduledFor = new Date(Date.now() + env.DELETION_COOLING_OFF_DAYS * 24 * 60 * 60_000)
+
+ try {
+ const [request] = await prisma.$transaction([
+ prisma.accountDeletionRequest.create({
+ data: { userId, status: DeletionStatus.PENDING, reason: reason ?? null, scheduledFor },
+ }),
+ prisma.user.update({
+ where: { id: userId },
+ data: { status: AccountStatus.PENDING_DELETION, statusChangedAt: new Date() },
+ }),
+ prisma.session.updateMany({
+ where: { userId, isRevoked: false },
+ data: { isRevoked: true, revokedAt: new Date() },
+ }),
+ auditService.op({
+ userId,
+ action: AuditAction.DELETION_REQUESTED,
+ metadata: { scheduledFor: scheduledFor.toISOString() },
+ ...ctx,
+ }),
+ ])
+
+ return { kind: 'created', request: request as DeletionRequestRecord }
+ } catch (error: any) {
+ // Partial unique index uq_active_deletion_per_user: concurrent request won the race
+ if (error?.code === 'P2002' || /uq_active_deletion_per_user/.test(error?.message ?? '')) {
+ const winner = await prisma.accountDeletionRequest.findFirst({
+ where: { userId, status: { in: ACTIVE_DELETION_STATUSES } },
+ })
+ if (winner) {
+ return { kind: 'duplicate', request: winner as DeletionRequestRecord }
+ }
+ }
+ throw error
+ }
+ }
+
+ async getLatestDeletionRequest(userId: string): Promise {
+ return (await prisma.accountDeletionRequest.findFirst({
+ where: { userId },
+ orderBy: { createdAt: 'desc' },
+ })) as DeletionRequestRecord | null
+ }
+
+ async cancelDeletion(userId: string, ctx: RequestContext): Promise {
+ // Status-guarded update is the race protection: if finalization already
+ // claimed the request (processing/completed), count is 0 and we lose.
+ const cancelled = await prisma.accountDeletionRequest.updateMany({
+ where: { userId, status: DeletionStatus.PENDING },
+ data: { status: DeletionStatus.CANCELLED, cancelledAt: new Date() },
+ })
+
+ if (cancelled.count === 0) {
+ const latest = await this.getLatestDeletionRequest(userId)
+ if (
+ latest &&
+ (latest.status === DeletionStatus.PROCESSING || latest.status === DeletionStatus.COMPLETED)
+ ) {
+ return { kind: 'finalized' }
+ }
+
+ return { kind: 'none' }
+ }
+
+ await prisma.$transaction([
+ prisma.user.update({
+ where: { id: userId },
+ data: { status: AccountStatus.ACTIVE, statusChangedAt: new Date() },
+ }),
+ auditService.op({ userId, action: AuditAction.DELETION_CANCELLED, ...ctx }),
+ ])
+
+ const request = await this.getLatestDeletionRequest(userId)
+
+ return { kind: 'cancelled', request: request as DeletionRequestRecord }
+ }
+
+ /**
+ * Finalize deletion requests whose cooling-off window has elapsed.
+ * Claim-based so concurrent sweeps and crash re-runs are safe; every
+ * operation in the finalization matrix is idempotent.
+ */
+ async processDue(): Promise {
+ const now = new Date()
+ const due = await prisma.accountDeletionRequest.findMany({
+ where: {
+ status: DeletionStatus.PENDING,
+ scheduledFor: { lte: now },
+ attemptCount: { lt: 5 },
+ OR: [{ nextAttemptAt: null }, { nextAttemptAt: { lte: now } }],
+ },
+ })
+
+ for (const request of due) {
+ const claimed = await prisma.accountDeletionRequest.updateMany({
+ where: { id: request.id, status: DeletionStatus.PENDING },
+ data: {
+ status: DeletionStatus.PROCESSING,
+ attemptCount: { increment: 1 },
+ lastAttemptAt: new Date(),
+ },
+ })
+
+ if (claimed.count === 0) {
+ continue
+ }
+
+ try {
+ await this.finalizeDeletion(request.id, request.userId)
+ } catch (error: any) {
+ await this.handleFinalizationFailure(
+ request as DeletionRequestRecord,
+ error?.message ?? 'Deletion finalization error'
+ )
+ }
+ }
+ }
+
+ /**
+ * Applies the deletion/anonymization matrix:
+ * - Hard delete: sessions, verification tokens, device tokens, sync events,
+ * notification logs/preferences, email deliveries, completions, referral
+ * code, data export requests (full-PII artifacts).
+ * - Retain: transactions, referrals, credentials (financial/on-chain
+ * records, no PII in-row), stellar fundings (keyed by public key; link is
+ * severed by nulling walletAddress).
+ * - Retain + scrub: audit logs (keep action/createdAt, drop ip/UA/metadata).
+ * - User row: anonymized in place (tombstone) so retained FKs stay valid.
+ */
+ private async finalizeDeletion(requestId: string, userId: string): Promise {
+ const tombstoneSuffix = userId.replace(/-/g, '').slice(0, 12)
+
+ await prisma.$transaction([
+ prisma.session.deleteMany({ where: { userId } }),
+ prisma.verificationToken.deleteMany({ where: { userId } }),
+ prisma.deviceToken.deleteMany({ where: { userId } }),
+ prisma.syncEvent.deleteMany({ where: { userId } }),
+ prisma.notificationLog.deleteMany({ where: { userId } }),
+ prisma.notificationPreference.deleteMany({ where: { userId } }),
+ prisma.emailDelivery.deleteMany({ where: { userId } }),
+ prisma.completion.deleteMany({ where: { userId } }),
+ prisma.referralCode.deleteMany({ where: { userId } }),
+ prisma.dataExportRequest.deleteMany({ where: { userId } }),
+ prisma.auditLog.updateMany({
+ where: { userId },
+ data: {
+ ipAddress: null,
+ userAgent: null,
+ metadata: JSON.stringify({ redacted: true }),
+ },
+ }),
+ prisma.user.update({
+ where: { id: userId },
+ data: {
+ email: `deleted+${tombstoneSuffix}@anon.invalid`,
+ username: `deleted_${tombstoneSuffix}`,
+ password: crypto.randomBytes(32).toString('hex'),
+ walletAddress: null,
+ isVerified: false,
+ lastLoginAt: null,
+ status: AccountStatus.DELETED,
+ statusChangedAt: new Date(),
+ },
+ }),
+ prisma.accountDeletionRequest.update({
+ where: { id: requestId },
+ data: { status: DeletionStatus.COMPLETED, completedAt: new Date(), error: null },
+ }),
+ auditService.op({
+ userId,
+ action: AuditAction.DELETION_COMPLETED,
+ metadata: { requestId },
+ }),
+ ])
+
+ logger.info(`[AccountLifecycleService] Deletion request ${requestId} finalized`)
+ }
+
+ private async handleFinalizationFailure(request: DeletionRequestRecord, error: string): Promise {
+ const nextAttemptCount = request.attemptCount + 1
+
+ if (nextAttemptCount >= request.maxAttempts) {
+ await prisma.accountDeletionRequest.updateMany({
+ where: { id: request.id, status: DeletionStatus.PROCESSING },
+ data: { status: DeletionStatus.FAILED, error },
+ })
+ logger.error(
+ `[AccountLifecycleService] Deletion ${request.id} dead-lettered after ${nextAttemptCount} attempts: ${error}`
+ )
+ } else {
+ const backoffMinutes = Math.pow(5, nextAttemptCount - 1)
+ const nextAttemptAt = new Date(Date.now() + backoffMinutes * 60_000)
+
+ await prisma.accountDeletionRequest.updateMany({
+ where: { id: request.id, status: DeletionStatus.PROCESSING },
+ data: { status: DeletionStatus.PENDING, error, nextAttemptAt },
+ })
+ }
+ }
+
+ /**
+ * Runs all due lifecycle work. Invoked lazily from account endpoints,
+ * optionally on an interval from server.ts, and callable from a future
+ * dedicated worker (docker/entrypoint-worker.sh).
+ */
+ async sweep(): Promise {
+ const results = await Promise.allSettled([
+ this.processDue(),
+ dataExportService.processQueue(),
+ dataExportService.purgeExpired(),
+ ])
+
+ for (const result of results) {
+ if (result.status === 'rejected') {
+ logger.error('[AccountLifecycleService] Sweep task failed:', result.reason)
+ }
+ }
+ }
+}
+
+export const accountLifecycleService = new AccountLifecycleService()
diff --git a/src/services/audit.service.ts b/src/services/audit.service.ts
new file mode 100644
index 0000000..5538b50
--- /dev/null
+++ b/src/services/audit.service.ts
@@ -0,0 +1,37 @@
+import prisma from '../config/database'
+import logger from '../utils/logger'
+import { AuditEntry } from '../types/account.types'
+
+export class AuditService {
+ /**
+ * Write an audit log entry. Never throws — auditing must not break the
+ * flow being audited. Failures are logged instead.
+ */
+ async record(entry: AuditEntry): Promise {
+ try {
+ await prisma.auditLog.create({ data: this.toData(entry) })
+ } catch (error) {
+ logger.error('[AuditService] Failed to write audit log:', error)
+ }
+ }
+
+ /**
+ * Build an auditLog.create operation for inclusion in a prisma.$transaction
+ * array, so the audit entry commits atomically with the audited change.
+ */
+ op(entry: AuditEntry) {
+ return prisma.auditLog.create({ data: this.toData(entry) })
+ }
+
+ private toData(entry: AuditEntry) {
+ return {
+ userId: entry.userId,
+ action: entry.action,
+ metadata: entry.metadata ? JSON.stringify(entry.metadata) : null,
+ ipAddress: entry.ipAddress ?? null,
+ userAgent: entry.userAgent ?? null,
+ }
+ }
+}
+
+export const auditService = new AuditService()
diff --git a/src/services/data-export.service.ts b/src/services/data-export.service.ts
new file mode 100644
index 0000000..e0b5ad5
--- /dev/null
+++ b/src/services/data-export.service.ts
@@ -0,0 +1,304 @@
+import prisma from '../config/database'
+import logger from '../utils/logger'
+import { env } from '../config/env'
+import { auditService } from './audit.service'
+import { emailService } from './email.service'
+import { ExportStatus, AuditAction } from '../types/account.types'
+
+// Caps the number of sync events included in an export so the DB-stored
+// artifact stays bounded in size.
+const SYNC_EVENT_EXPORT_CAP = 1000
+
+const ACTIVE_EXPORT_STATUSES = [ExportStatus.PENDING, ExportStatus.PROCESSING]
+
+export interface ExportRequestRecord {
+ id: string
+ userId: string
+ status: string
+ artifact: string | null
+ error: string | null
+ attemptCount: number
+ maxAttempts: number
+ completedAt: Date | null
+ expiresAt: Date | null
+ downloadedAt: Date | null
+ createdAt: Date
+}
+
+export type RequestExportResult =
+ | { kind: 'created'; request: ExportRequestRecord }
+ | { kind: 'duplicate'; request: ExportRequestRecord }
+
+export class DataExportService {
+ async requestExport(userId: string): Promise {
+ const existing = await prisma.dataExportRequest.findFirst({
+ where: { userId, status: { in: ACTIVE_EXPORT_STATUSES } },
+ })
+
+ if (existing) {
+ return { kind: 'duplicate', request: existing as ExportRequestRecord }
+ }
+
+ let request: ExportRequestRecord
+ try {
+ request = (await prisma.dataExportRequest.create({
+ data: { userId, status: ExportStatus.PENDING, nextAttemptAt: new Date() },
+ })) as ExportRequestRecord
+ } catch (error: any) {
+ // Partial unique index uq_active_export_per_user: a concurrent request won the race
+ if (error?.code === 'P2002' || /uq_active_export_per_user/.test(error?.message ?? '')) {
+ const winner = await prisma.dataExportRequest.findFirst({
+ where: { userId, status: { in: ACTIVE_EXPORT_STATUSES } },
+ })
+ if (winner) {
+ return { kind: 'duplicate', request: winner as ExportRequestRecord }
+ }
+ }
+ throw error
+ }
+
+ await auditService.record({ userId, action: AuditAction.EXPORT_REQUESTED, metadata: { requestId: request.id } })
+
+ this.processQueue().catch(err =>
+ logger.error('[DataExportService] Queue processing error:', err)
+ )
+
+ return { kind: 'created', request }
+ }
+
+ async getExportStatus(userId: string, id: string): Promise {
+ // Scoped to the requesting user: other users' export ids behave as not found
+ return (await prisma.dataExportRequest.findFirst({
+ where: { id, userId },
+ })) as ExportRequestRecord | null
+ }
+
+ async processQueue(): Promise {
+ const due = await prisma.dataExportRequest.findMany({
+ where: {
+ status: ExportStatus.PENDING,
+ nextAttemptAt: { lte: new Date() },
+ attemptCount: { lt: 5 },
+ },
+ })
+
+ for (const request of due) {
+ // Claim the row before working on it: if another runner already flipped
+ // it out of `pending`, count is 0 and we skip. This makes concurrent
+ // sweeps and re-runs after a crash safe.
+ const claimed = await prisma.dataExportRequest.updateMany({
+ where: { id: request.id, status: ExportStatus.PENDING },
+ data: {
+ status: ExportStatus.PROCESSING,
+ attemptCount: { increment: 1 },
+ lastAttemptAt: new Date(),
+ },
+ })
+
+ if (claimed.count === 0) {
+ continue
+ }
+
+ try {
+ await this.generateExport(request.id, request.userId)
+ } catch (error: any) {
+ await this.handleFailure(request as ExportRequestRecord, error?.message ?? 'Export generation error')
+ }
+ }
+ }
+
+ private async generateExport(requestId: string, userId: string): Promise {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ id: true,
+ email: true,
+ username: true,
+ role: true,
+ walletAddress: true,
+ isVerified: true,
+ status: true,
+ createdAt: true,
+ lastLoginAt: true,
+ },
+ })
+
+ if (!user) {
+ throw new Error(`User ${userId} not found for export`)
+ }
+
+ const [
+ completions,
+ credentials,
+ transactions,
+ referralCode,
+ referralsGiven,
+ referralReceived,
+ syncEvents,
+ notificationPreference,
+ notificationLogs,
+ sessions,
+ auditLogs,
+ ] = await Promise.all([
+ prisma.completion.findMany({
+ where: { userId },
+ include: { module: { select: { title: true } } },
+ }),
+ prisma.credential.findMany({
+ where: { userId },
+ include: { module: { select: { title: true } } },
+ }),
+ prisma.transaction.findMany({ where: { userId } }),
+ prisma.referralCode.findFirst({ where: { userId }, select: { code: true, createdAt: true } }),
+ prisma.referral.findMany({
+ where: { referrerId: userId },
+ select: { referreeId: true, bonusPaid: true, bonusAmount: true, createdAt: true },
+ }),
+ prisma.referral.findFirst({
+ where: { referreeId: userId },
+ select: { referrerId: true, createdAt: true },
+ }),
+ prisma.syncEvent.findMany({
+ where: { userId },
+ orderBy: { createdAt: 'desc' },
+ take: SYNC_EVENT_EXPORT_CAP,
+ select: {
+ deviceId: true,
+ eventType: true,
+ payload: true,
+ clientTimestamp: true,
+ status: true,
+ createdAt: true,
+ },
+ }),
+ prisma.notificationPreference.findFirst({
+ where: { userId },
+ select: { rewardReceipt: true, quizPassFail: true, streakReminders: true },
+ }),
+ prisma.notificationLog.findMany({
+ where: { userId },
+ select: { type: true, title: true, status: true, createdAt: true },
+ }),
+ // Session metadata only — never token/refreshToken
+ prisma.session.findMany({
+ where: { userId },
+ select: { userAgent: true, ipAddress: true, createdAt: true, expiresAt: true, isRevoked: true },
+ }),
+ prisma.auditLog.findMany({
+ where: { userId },
+ select: { action: true, createdAt: true },
+ }),
+ ])
+
+ const artifact = JSON.stringify({
+ exportVersion: 1,
+ generatedAt: new Date().toISOString(),
+ data: {
+ profile: user,
+ completions: completions.map((c: any) => ({
+ moduleId: c.moduleId,
+ moduleTitle: c.module?.title,
+ score: c.score,
+ completedAt: c.completedAt,
+ })),
+ credentials: credentials.map((c: any) => ({
+ moduleId: c.moduleId,
+ moduleTitle: c.module?.title,
+ onChainId: c.onChainId,
+ issuedAt: c.issuedAt,
+ })),
+ transactions: transactions.map((t: any) => ({
+ id: t.id,
+ amount: t.amount,
+ type: t.type,
+ status: t.status,
+ createdAt: t.createdAt,
+ })),
+ referralCode,
+ referralsGiven,
+ referralReceived,
+ syncEvents,
+ notificationPreference,
+ notificationLogs,
+ sessions,
+ auditLog: auditLogs,
+ },
+ })
+
+ const expiresAt = new Date(Date.now() + env.EXPORT_TTL_DAYS * 24 * 60 * 60_000)
+
+ await prisma.dataExportRequest.update({
+ where: { id: requestId },
+ data: {
+ status: ExportStatus.READY,
+ artifact,
+ artifactBytes: Buffer.byteLength(artifact, 'utf8'),
+ error: null,
+ completedAt: new Date(),
+ expiresAt,
+ },
+ })
+
+ await auditService.record({ userId, action: AuditAction.EXPORT_READY, metadata: { requestId } })
+
+ await emailService.queueEmail(
+ userId,
+ user.email,
+ 'Your Learnault data export is ready',
+ this.buildExportReadyEmail(user.username, expiresAt),
+ 'DATA_EXPORT'
+ )
+ }
+
+ private async handleFailure(request: ExportRequestRecord, error: string): Promise {
+ const nextAttemptCount = request.attemptCount + 1
+
+ if (nextAttemptCount >= request.maxAttempts) {
+ await prisma.dataExportRequest.update({
+ where: { id: request.id },
+ data: { status: ExportStatus.FAILED, error },
+ })
+ logger.error(`[DataExportService] Export ${request.id} dead-lettered after ${nextAttemptCount} attempts: ${error}`)
+ } else {
+ const backoffMinutes = Math.pow(5, nextAttemptCount - 1)
+ const nextAttemptAt = new Date(Date.now() + backoffMinutes * 60_000)
+
+ await prisma.dataExportRequest.update({
+ where: { id: request.id },
+ data: { status: ExportStatus.PENDING, error, nextAttemptAt },
+ })
+ }
+ }
+
+ async purgeExpired(): Promise {
+ const result = await prisma.dataExportRequest.updateMany({
+ where: { status: ExportStatus.READY, expiresAt: { lte: new Date() } },
+ data: { status: ExportStatus.EXPIRED, artifact: null },
+ })
+
+ if (result.count > 0) {
+ logger.info(`[DataExportService] Purged ${result.count} expired export artifact(s)`)
+ }
+
+ return result.count
+ }
+
+ async markDownloaded(userId: string, id: string): Promise {
+ await prisma.dataExportRequest.updateMany({
+ where: { id, userId, downloadedAt: null },
+ data: { downloadedAt: new Date() },
+ })
+ await auditService.record({ userId, action: AuditAction.EXPORT_DOWNLOADED, metadata: { requestId: id } })
+ }
+
+ private buildExportReadyEmail(username: string, expiresAt: Date): string {
+ return `
+ Hi ${username},
+ Your Learnault data export is ready to download from your account settings.
+ The download is available until ${expiresAt.toUTCString()}, after which it is permanently removed.
+ If you did not request this export, please change your password immediately.
+ `
+ }
+}
+
+export const dataExportService = new DataExportService()
diff --git a/src/types/account.types.ts b/src/types/account.types.ts
new file mode 100644
index 0000000..7acfcf7
--- /dev/null
+++ b/src/types/account.types.ts
@@ -0,0 +1,58 @@
+// ── Account lifecycle (export / deactivation / deletion) ───
+
+export const AccountStatus = {
+ ACTIVE: 'ACTIVE',
+ DEACTIVATED: 'DEACTIVATED',
+ PENDING_DELETION: 'PENDING_DELETION',
+ DELETED: 'DELETED',
+} as const
+
+export type AccountStatusValue = (typeof AccountStatus)[keyof typeof AccountStatus]
+
+export const ExportStatus = {
+ PENDING: 'pending',
+ PROCESSING: 'processing',
+ READY: 'ready',
+ FAILED: 'failed',
+ EXPIRED: 'expired',
+} as const
+
+export type ExportStatusValue = (typeof ExportStatus)[keyof typeof ExportStatus]
+
+export const DeletionStatus = {
+ PENDING: 'pending',
+ PROCESSING: 'processing',
+ COMPLETED: 'completed',
+ CANCELLED: 'cancelled',
+ FAILED: 'failed',
+} as const
+
+export type DeletionStatusValue = (typeof DeletionStatus)[keyof typeof DeletionStatus]
+
+export const AuditAction = {
+ EXPORT_REQUESTED: 'EXPORT_REQUESTED',
+ EXPORT_READY: 'EXPORT_READY',
+ EXPORT_DOWNLOADED: 'EXPORT_DOWNLOADED',
+ EXPORT_PURGED: 'EXPORT_PURGED',
+ ACCOUNT_DEACTIVATED: 'ACCOUNT_DEACTIVATED',
+ ACCOUNT_REACTIVATED: 'ACCOUNT_REACTIVATED',
+ DELETION_REQUESTED: 'DELETION_REQUESTED',
+ DELETION_CANCELLED: 'DELETION_CANCELLED',
+ DELETION_COMPLETED: 'DELETION_COMPLETED',
+ STEP_UP_FAILED: 'STEP_UP_FAILED',
+} as const
+
+export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction]
+
+export interface AuditEntry {
+ userId: string
+ action: AuditActionValue | string
+ metadata?: Record
+ ipAddress?: string
+ userAgent?: string
+}
+
+export interface RequestContext {
+ ipAddress?: string
+ userAgent?: string
+}
diff --git a/tests/account-lifecycle.service.test.ts b/tests/account-lifecycle.service.test.ts
new file mode 100644
index 0000000..16ba43a
--- /dev/null
+++ b/tests/account-lifecycle.service.test.ts
@@ -0,0 +1,218 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('../src/config/database', () => ({
+ default: {
+ user: {
+ findUnique: vi.fn(),
+ update: vi.fn(),
+ },
+ session: { updateMany: vi.fn(), deleteMany: vi.fn() },
+ verificationToken: { deleteMany: vi.fn() },
+ deviceToken: { deleteMany: vi.fn() },
+ syncEvent: { deleteMany: vi.fn() },
+ notificationLog: { deleteMany: vi.fn() },
+ notificationPreference: { deleteMany: vi.fn() },
+ emailDelivery: { deleteMany: vi.fn() },
+ completion: { deleteMany: vi.fn() },
+ referralCode: { deleteMany: vi.fn() },
+ transaction: { deleteMany: vi.fn(), findMany: vi.fn() },
+ credential: { deleteMany: vi.fn(), findMany: vi.fn() },
+ referral: { deleteMany: vi.fn(), findMany: vi.fn(), findFirst: vi.fn() },
+ stellarFunding: { deleteMany: vi.fn() },
+ dataExportRequest: { deleteMany: vi.fn(), findMany: vi.fn(), updateMany: vi.fn() },
+ accountDeletionRequest: {
+ findFirst: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ updateMany: vi.fn(),
+ },
+ auditLog: { create: vi.fn(), updateMany: vi.fn() },
+ $transaction: vi.fn(),
+ },
+}))
+
+vi.mock('../src/utils/logger', () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}))
+
+import prisma from '../src/config/database'
+import { AccountLifecycleService } from '../src/services/account-lifecycle.service'
+
+const dueRequest = {
+ id: 'del-1',
+ userId: 'user-1',
+ status: 'pending',
+ scheduledFor: new Date(Date.now() - 1000),
+ attemptCount: 0,
+ maxAttempts: 5,
+ nextAttemptAt: null,
+}
+
+describe('AccountLifecycleService', () => {
+ let service: AccountLifecycleService
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ service = new AccountLifecycleService()
+ vi.mocked(prisma.$transaction).mockImplementation((args: any[]) => Promise.all(args))
+ vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any)
+ })
+
+ describe('deactivate', () => {
+ it('returns a conflict when the account is not active', async () => {
+ const result = await service.deactivate('user-1', 'PENDING_DELETION', {})
+
+ expect(result).toEqual({ kind: 'conflict', status: 'PENDING_DELETION' })
+ expect(prisma.$transaction).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('requestDeletion', () => {
+ it('treats a unique-index violation from a concurrent request as a duplicate', async () => {
+ vi.mocked(prisma.accountDeletionRequest.findFirst)
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce({ id: 'del-winner', userId: 'user-1', status: 'pending' } as any)
+ vi.mocked(prisma.$transaction).mockRejectedValue({ code: 'P2002' })
+
+ const result = await service.requestDeletion('user-1', undefined, {})
+
+ expect(result.kind).toBe('duplicate')
+ expect((result as any).request.id).toBe('del-winner')
+ })
+ })
+
+ describe('processDue — finalization matrix', () => {
+ it('applies the deletion/anonymization matrix and completes the request', async () => {
+ vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([dueRequest] as any)
+ vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 1 } as any)
+
+ await service.processDue()
+
+ // Hard-deleted models (PII / auth artifacts / behavioral data)
+ for (const model of [
+ prisma.session,
+ prisma.verificationToken,
+ prisma.deviceToken,
+ prisma.syncEvent,
+ prisma.notificationLog,
+ prisma.notificationPreference,
+ prisma.emailDelivery,
+ prisma.completion,
+ prisma.referralCode,
+ prisma.dataExportRequest,
+ ]) {
+ expect(model.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } })
+ }
+
+ // Retained models: financial/on-chain records must NOT be deleted
+ expect(prisma.transaction.deleteMany).not.toHaveBeenCalled()
+ expect(prisma.credential.deleteMany).not.toHaveBeenCalled()
+ expect(prisma.referral.deleteMany).not.toHaveBeenCalled()
+ expect(prisma.stellarFunding.deleteMany).not.toHaveBeenCalled()
+
+ // Audit logs retained but PII-scrubbed
+ expect(prisma.auditLog.updateMany).toHaveBeenCalledWith({
+ where: { userId: 'user-1' },
+ data: {
+ ipAddress: null,
+ userAgent: null,
+ metadata: JSON.stringify({ redacted: true }),
+ },
+ })
+
+ // User row anonymized in place (tombstone), never hard-deleted
+ const userUpdate = vi.mocked(prisma.user.update).mock.calls[0][0] as any
+ expect(userUpdate.where).toEqual({ id: 'user-1' })
+ expect(userUpdate.data.email).toMatch(/^deleted\+[a-z0-9]+@anon\.invalid$/)
+ expect(userUpdate.data.username).toMatch(/^deleted_[a-z0-9]+$/)
+ expect(userUpdate.data.password).toMatch(/^[0-9a-f]{64}$/)
+ expect(userUpdate.data.walletAddress).toBeNull()
+ expect(userUpdate.data.isVerified).toBe(false)
+ expect(userUpdate.data.lastLoginAt).toBeNull()
+ expect(userUpdate.data.status).toBe('DELETED')
+
+ // Request marked completed + completion audited
+ expect(prisma.accountDeletionRequest.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 'del-1' },
+ data: expect.objectContaining({ status: 'completed' }),
+ })
+ )
+ expect(prisma.auditLog.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ action: 'DELETION_COMPLETED' }),
+ })
+ )
+ })
+
+ it('skips rows another runner already claimed', async () => {
+ vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([dueRequest] as any)
+ vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 0 } as any)
+
+ await service.processDue()
+
+ expect(prisma.$transaction).not.toHaveBeenCalled()
+ expect(prisma.user.update).not.toHaveBeenCalled()
+ })
+
+ it('re-running after completion is a no-op (idempotent)', async () => {
+ // Completed requests no longer match the pending filter
+ vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([] as any)
+
+ await service.processDue()
+
+ expect(prisma.$transaction).not.toHaveBeenCalled()
+ })
+
+ it('returns the request to pending with backoff on finalization failure', async () => {
+ vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([dueRequest] as any)
+ vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 1 } as any)
+ vi.mocked(prisma.$transaction).mockRejectedValue(new Error('db down'))
+
+ await service.processDue()
+
+ const retryCall = vi.mocked(prisma.accountDeletionRequest.updateMany).mock.calls[1][0] as any
+ expect(retryCall.where).toEqual({ id: 'del-1', status: 'processing' })
+ expect(retryCall.data.status).toBe('pending')
+ expect(retryCall.data.error).toBe('db down')
+ expect(retryCall.data.nextAttemptAt.getTime()).toBeGreaterThan(Date.now())
+ })
+
+ it('dead-letters the request as failed after max attempts', async () => {
+ vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([
+ { ...dueRequest, attemptCount: 4 },
+ ] as any)
+ vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 1 } as any)
+ vi.mocked(prisma.$transaction).mockRejectedValue(new Error('db down'))
+
+ await service.processDue()
+
+ const failCall = vi.mocked(prisma.accountDeletionRequest.updateMany).mock.calls[1][0] as any
+ expect(failCall.data.status).toBe('failed')
+ })
+ })
+
+ describe('cancelDeletion', () => {
+ it('reports finalized when the finalizer won the race', async () => {
+ vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 0 } as any)
+ vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue({
+ id: 'del-1', userId: 'user-1', status: 'processing',
+ } as any)
+
+ const result = await service.cancelDeletion('user-1', {})
+
+ expect(result.kind).toBe('finalized')
+ expect(prisma.user.update).not.toHaveBeenCalled()
+ })
+
+ it('reports none when no request exists', async () => {
+ vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 0 } as any)
+ vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue(null)
+
+ const result = await service.cancelDeletion('user-1', {})
+
+ expect(result.kind).toBe('none')
+ })
+ })
+})
diff --git a/tests/account.controller.test.ts b/tests/account.controller.test.ts
new file mode 100644
index 0000000..8cb25d7
--- /dev/null
+++ b/tests/account.controller.test.ts
@@ -0,0 +1,532 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { Request, Response } from 'express'
+import { AccountController } from '../src/controllers/account.controller'
+
+vi.mock('../src/config/database', () => ({
+ default: {
+ user: {
+ findUnique: vi.fn(),
+ update: vi.fn(),
+ },
+ dataExportRequest: {
+ findFirst: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ updateMany: vi.fn(),
+ deleteMany: vi.fn(),
+ },
+ accountDeletionRequest: {
+ findFirst: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ updateMany: vi.fn(),
+ },
+ session: {
+ updateMany: vi.fn(),
+ deleteMany: vi.fn(),
+ },
+ auditLog: {
+ create: vi.fn(),
+ updateMany: vi.fn(),
+ findMany: vi.fn(),
+ },
+ $transaction: vi.fn(),
+ },
+}))
+
+vi.mock('bcryptjs', () => ({
+ default: {
+ compare: vi.fn(),
+ hash: vi.fn().mockResolvedValue('hashed'),
+ },
+}))
+
+vi.mock('jsonwebtoken', () => ({
+ default: {
+ sign: vi.fn().mockReturnValue('mock_token'),
+ },
+}))
+
+vi.mock('../src/services/email.service', () => ({
+ emailService: {
+ queueEmail: vi.fn().mockResolvedValue({ id: 'email-1' }),
+ },
+}))
+
+vi.mock('../src/utils/logger', () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}))
+
+import prisma from '../src/config/database'
+import bcrypt from 'bcryptjs'
+import jwt from 'jsonwebtoken'
+import { emailService } from '../src/services/email.service'
+
+const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
+
+const DAY_MS = 24 * 60 * 60 * 1000
+
+interface AuthRequest extends Request {
+ user?: { id: string; email: string; role: string }
+}
+
+const activeUser = {
+ id: 'user-1',
+ email: 'test@example.com',
+ username: 'testuser',
+ password: 'hashed_password',
+ role: 'LEARNER',
+ status: 'ACTIVE',
+}
+
+describe('AccountController', () => {
+ let controller: AccountController
+ let req: Partial
+ let res: Partial
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+
+ controller = new AccountController()
+ req = {
+ user: { id: 'user-1', email: 'test@example.com', role: 'LEARNER' },
+ body: {},
+ params: {},
+ headers: { 'user-agent': 'vitest' },
+ ip: '127.0.0.1',
+ } as Partial
+ res = {
+ json: vi.fn(),
+ status: vi.fn().mockReturnThis(),
+ setHeader: vi.fn(),
+ send: vi.fn(),
+ }
+
+ // Quiet defaults for the background lifecycle sweep
+ vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([] as any)
+ vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([] as any)
+ vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 0 } as any)
+ vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any)
+ vi.mocked(prisma.$transaction).mockImplementation((args: any[]) => Promise.all(args))
+ vi.mocked(emailService.queueEmail).mockResolvedValue({ id: 'email-1' } as any)
+ vi.mocked(jwt.sign as any).mockReturnValue('mock_token')
+ })
+
+ describe('requestExport', () => {
+ it('accepts a new export request with 202', async () => {
+ vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue(null)
+ vi.mocked(prisma.dataExportRequest.create).mockResolvedValue({
+ id: 'exp-1', userId: 'user-1', status: 'pending', createdAt: new Date(),
+ } as any)
+
+ await controller.requestExport(req as Request, res as Response)
+ await flushPromises()
+
+ expect(res.status).toHaveBeenCalledWith(202)
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'exp-1', status: 'pending' })
+ )
+ })
+
+ it('returns 409 with the existing request id on duplicate', async () => {
+ vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue({
+ id: 'exp-existing', userId: 'user-1', status: 'processing',
+ } as any)
+
+ await controller.requestExport(req as Request, res as Response)
+ await flushPromises()
+
+ expect(res.status).toHaveBeenCalledWith(409)
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ existingRequestId: 'exp-existing' })
+ )
+ expect(prisma.dataExportRequest.create).not.toHaveBeenCalled()
+ })
+
+ it('returns 409 when a concurrent request wins the unique-index race', async () => {
+ vi.mocked(prisma.dataExportRequest.findFirst)
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce({ id: 'exp-winner', userId: 'user-1', status: 'pending' } as any)
+ vi.mocked(prisma.dataExportRequest.create).mockRejectedValue({ code: 'P2002' })
+
+ await controller.requestExport(req as Request, res as Response)
+ await flushPromises()
+
+ expect(res.status).toHaveBeenCalledWith(409)
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ existingRequestId: 'exp-winner' })
+ )
+ })
+ })
+
+ describe('getExportStatus', () => {
+ it('scopes the lookup to the requesting user and 404s on other users\' requests', async () => {
+ req.params = { id: '123e4567-e89b-42d3-a456-426614174000' }
+ vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue(null)
+
+ await controller.getExportStatus(req as Request, res as Response)
+
+ expect(prisma.dataExportRequest.findFirst).toHaveBeenCalledWith({
+ where: { id: '123e4567-e89b-42d3-a456-426614174000', userId: 'user-1' },
+ })
+ expect(res.status).toHaveBeenCalledWith(404)
+ })
+
+ it('rejects malformed export ids with 400', async () => {
+ req.params = { id: 'not-a-uuid' }
+
+ await controller.getExportStatus(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(400)
+ expect(prisma.dataExportRequest.findFirst).not.toHaveBeenCalled()
+ })
+
+ it('returns status fields without the artifact', async () => {
+ req.params = { id: '123e4567-e89b-42d3-a456-426614174000' }
+ vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue({
+ id: '123e4567-e89b-42d3-a456-426614174000',
+ userId: 'user-1',
+ status: 'ready',
+ artifact: '{"secret":"data"}',
+ createdAt: new Date(),
+ completedAt: new Date(),
+ expiresAt: new Date(Date.now() + DAY_MS),
+ downloadedAt: null,
+ } as any)
+
+ await controller.getExportStatus(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(200)
+ const payload = vi.mocked(res.json as any).mock.calls[0][0]
+ expect(payload.status).toBe('ready')
+ expect(payload.artifact).toBeUndefined()
+ })
+ })
+
+ describe('downloadExport', () => {
+ const exportId = '123e4567-e89b-42d3-a456-426614174000'
+
+ it('sends a ready artifact as a JSON attachment and marks it downloaded', async () => {
+ req.params = { id: exportId }
+ const artifact = JSON.stringify({ exportVersion: 1, data: { profile: { id: 'user-1' } } })
+ vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue({
+ id: exportId,
+ userId: 'user-1',
+ status: 'ready',
+ artifact,
+ expiresAt: new Date(Date.now() + DAY_MS),
+ downloadedAt: null,
+ } as any)
+ vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 1 } as any)
+
+ await controller.downloadExport(req as Request, res as Response)
+
+ expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json')
+ expect(res.setHeader).toHaveBeenCalledWith(
+ 'Content-Disposition',
+ `attachment; filename="learnault-export-${exportId}.json"`
+ )
+ expect(res.send).toHaveBeenCalledWith(artifact)
+ expect(prisma.dataExportRequest.updateMany).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { id: exportId, userId: 'user-1', downloadedAt: null } })
+ )
+ })
+
+ it('returns 409 while the export is still processing', async () => {
+ req.params = { id: exportId }
+ vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue({
+ id: exportId, userId: 'user-1', status: 'processing', artifact: null, expiresAt: null,
+ } as any)
+
+ await controller.downloadExport(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(409)
+ })
+
+ it('returns 410 for an expired export', async () => {
+ req.params = { id: exportId }
+ vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue({
+ id: exportId, userId: 'user-1', status: 'expired', artifact: null, expiresAt: new Date(0),
+ } as any)
+
+ await controller.downloadExport(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(410)
+ expect(res.send).not.toHaveBeenCalled()
+ })
+
+ it('returns 410 for a ready export whose expiry has passed but is not yet purged', async () => {
+ req.params = { id: exportId }
+ vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue({
+ id: exportId,
+ userId: 'user-1',
+ status: 'ready',
+ artifact: '{"data":1}',
+ expiresAt: new Date(Date.now() - 1000),
+ } as any)
+
+ await controller.downloadExport(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(410)
+ expect(res.send).not.toHaveBeenCalled()
+ })
+
+ it('404s for exports owned by another user', async () => {
+ req.params = { id: exportId }
+ vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue(null)
+
+ await controller.downloadExport(req as Request, res as Response)
+
+ expect(prisma.dataExportRequest.findFirst).toHaveBeenCalledWith({
+ where: { id: exportId, userId: 'user-1' },
+ })
+ expect(res.status).toHaveBeenCalledWith(404)
+ })
+ })
+
+ describe('deactivate', () => {
+ it('rejects a wrong password with 401 and audits the failed step-up', async () => {
+ req.body = { password: 'wrong' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(activeUser as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(false)
+
+ await controller.deactivate(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(401)
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'STEP_UP_FAILED' })
+ )
+ expect(prisma.auditLog.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ action: 'STEP_UP_FAILED' }),
+ })
+ )
+ expect(prisma.$transaction).not.toHaveBeenCalled()
+ })
+
+ it('returns 409 when the account is already deactivated', async () => {
+ req.body = { password: 'correct' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ ...activeUser, status: 'DEACTIVATED',
+ } as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(true)
+
+ await controller.deactivate(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(409)
+ })
+
+ it('deactivates the account, revoking sessions in the same transaction', async () => {
+ req.body = { password: 'correct' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(activeUser as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(true)
+
+ await controller.deactivate(req as Request, res as Response)
+
+ expect(prisma.user.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 'user-1' },
+ data: expect.objectContaining({ status: 'DEACTIVATED' }),
+ })
+ )
+ expect(prisma.session.updateMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { userId: 'user-1', isRevoked: false },
+ })
+ )
+ expect(res.status).toHaveBeenCalledWith(200)
+ })
+ })
+
+ describe('reactivate', () => {
+ it('reactivates a deactivated account and returns a fresh token', async () => {
+ req.body = { email: 'test@example.com', password: 'correct' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ ...activeUser, status: 'DEACTIVATED',
+ } as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(true)
+
+ await controller.reactivate(req as Request, res as Response)
+
+ expect(prisma.user.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ status: 'ACTIVE' }),
+ })
+ )
+ expect(res.status).toHaveBeenCalledWith(200)
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ token: 'mock_token' })
+ )
+ })
+
+ it('returns a neutral 401 on bad credentials', async () => {
+ req.body = { email: 'test@example.com', password: 'wrong' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ ...activeUser, status: 'DEACTIVATED',
+ } as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(false)
+
+ await controller.reactivate(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(401)
+ expect(res.json).toHaveBeenCalledWith({ error: 'Invalid credentials' })
+ })
+
+ it('returns a neutral 401 for tombstoned (deleted) accounts', async () => {
+ req.body = { email: 'test@example.com', password: 'correct' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ ...activeUser, status: 'DELETED',
+ } as any)
+
+ await controller.reactivate(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(401)
+ expect(res.json).toHaveBeenCalledWith({ error: 'Invalid credentials' })
+ })
+
+ it('returns 409 when the account is pending deletion', async () => {
+ req.body = { email: 'test@example.com', password: 'correct' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ ...activeUser, status: 'PENDING_DELETION',
+ } as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(true)
+
+ await controller.reactivate(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(409)
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'ACCOUNT_PENDING_DELETION' })
+ )
+ })
+ })
+
+ describe('requestDeletion', () => {
+ it('accepts a deletion request with the configured cooling-off window', async () => {
+ req.body = { password: 'correct', reason: 'no longer needed' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(activeUser as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(true)
+ vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue(null)
+ const scheduledFor = new Date(Date.now() + 30 * DAY_MS)
+ vi.mocked(prisma.accountDeletionRequest.create).mockResolvedValue({
+ id: 'del-1', userId: 'user-1', status: 'pending', scheduledFor,
+ } as any)
+
+ await controller.requestDeletion(req as Request, res as Response)
+ await flushPromises()
+
+ // Cooling-off window: scheduledFor persisted ≈ now + 30 days (default)
+ const createArg = vi.mocked(prisma.accountDeletionRequest.create).mock.calls[0][0] as any
+ const deltaDays = (createArg.data.scheduledFor.getTime() - Date.now()) / DAY_MS
+ expect(deltaDays).toBeGreaterThan(29.9)
+ expect(deltaDays).toBeLessThan(30.1)
+
+ expect(res.status).toHaveBeenCalledWith(202)
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'del-1', status: 'pending', scheduledFor })
+ )
+ expect(emailService.queueEmail).toHaveBeenCalled()
+ })
+
+ it('returns 409 when a deletion request is already pending', async () => {
+ req.body = { password: 'correct' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(activeUser as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(true)
+ const scheduledFor = new Date(Date.now() + 10 * DAY_MS)
+ vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue({
+ id: 'del-existing', userId: 'user-1', status: 'pending', scheduledFor,
+ } as any)
+
+ await controller.requestDeletion(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(409)
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ existingRequestId: 'del-existing', scheduledFor })
+ )
+ expect(prisma.accountDeletionRequest.create).not.toHaveBeenCalled()
+ })
+
+ it('rejects a wrong password with 401 before touching deletion state', async () => {
+ req.body = { password: 'wrong' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(activeUser as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(false)
+
+ await controller.requestDeletion(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(401)
+ expect(prisma.accountDeletionRequest.findFirst).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('getDeletionStatus', () => {
+ it('returns a null status when no request exists', async () => {
+ vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue(null)
+
+ await controller.getDeletionStatus(req as Request, res as Response)
+ await flushPromises()
+
+ expect(res.status).toHaveBeenCalledWith(200)
+ expect(res.json).toHaveBeenCalledWith({ status: null })
+ })
+ })
+
+ describe('cancelDeletion', () => {
+ it('cancels a pending deletion and restores the account', async () => {
+ req.body = { email: 'test@example.com', password: 'correct' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ ...activeUser, status: 'PENDING_DELETION',
+ } as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(true)
+ vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 1 } as any)
+ vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue({
+ id: 'del-1', userId: 'user-1', status: 'cancelled', cancelledAt: new Date(),
+ } as any)
+
+ await controller.cancelDeletion(req as Request, res as Response)
+ await flushPromises()
+
+ expect(prisma.accountDeletionRequest.updateMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { userId: 'user-1', status: 'pending' },
+ })
+ )
+ expect(prisma.user.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ status: 'ACTIVE' }),
+ })
+ )
+ expect(res.status).toHaveBeenCalledWith(200)
+ expect(emailService.queueEmail).toHaveBeenCalled()
+ })
+
+ it('returns 410 when finalization already won the race', async () => {
+ req.body = { email: 'test@example.com', password: 'correct' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ ...activeUser, status: 'PENDING_DELETION',
+ } as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(true)
+ vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 0 } as any)
+ vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue({
+ id: 'del-1', userId: 'user-1', status: 'completed',
+ } as any)
+
+ await controller.cancelDeletion(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(410)
+ expect(prisma.user.update).not.toHaveBeenCalled()
+ })
+
+ it('returns 404 when no deletion request exists', async () => {
+ req.body = { email: 'test@example.com', password: 'correct' }
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(activeUser as any)
+ vi.mocked(bcrypt.compare as any).mockResolvedValue(true)
+ vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 0 } as any)
+ vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue(null)
+
+ await controller.cancelDeletion(req as Request, res as Response)
+
+ expect(res.status).toHaveBeenCalledWith(404)
+ })
+ })
+})
diff --git a/tests/audit.service.test.ts b/tests/audit.service.test.ts
new file mode 100644
index 0000000..54a8271
--- /dev/null
+++ b/tests/audit.service.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('../src/config/database', () => ({
+ default: {
+ auditLog: {
+ create: vi.fn(),
+ },
+ },
+}))
+
+vi.mock('../src/utils/logger', () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}))
+
+import prisma from '../src/config/database'
+import logger from '../src/utils/logger'
+import { AuditService } from '../src/services/audit.service'
+
+describe('AuditService', () => {
+ let service: AuditService
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ service = new AuditService()
+ })
+
+ it('writes an audit entry with serialized metadata', async () => {
+ vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any)
+
+ await service.record({
+ userId: 'user-1',
+ action: 'ACCOUNT_DEACTIVATED',
+ metadata: { source: 'test' },
+ ipAddress: '127.0.0.1',
+ userAgent: 'vitest',
+ })
+
+ expect(prisma.auditLog.create).toHaveBeenCalledWith({
+ data: {
+ userId: 'user-1',
+ action: 'ACCOUNT_DEACTIVATED',
+ metadata: JSON.stringify({ source: 'test' }),
+ ipAddress: '127.0.0.1',
+ userAgent: 'vitest',
+ },
+ })
+ })
+
+ it('never throws when the write fails — auditing must not break the flow', async () => {
+ vi.mocked(prisma.auditLog.create).mockRejectedValue(new Error('db down'))
+
+ await expect(
+ service.record({ userId: 'user-1', action: 'EXPORT_REQUESTED' })
+ ).resolves.toBeUndefined()
+
+ expect(logger.error).toHaveBeenCalled()
+ })
+})
diff --git a/tests/auth.controller.test.ts b/tests/auth.controller.test.ts
index 3c74b9c..efa323b 100644
--- a/tests/auth.controller.test.ts
+++ b/tests/auth.controller.test.ts
@@ -25,6 +25,9 @@ vi.mock('../src/config/database', () => ({
emailDelivery: {
create: vi.fn(),
},
+ accountDeletionRequest: {
+ findFirst: vi.fn(),
+ },
$transaction: vi.fn((args: any[]) => Promise.all(args)),
},
}))
@@ -595,6 +598,91 @@ describe('AuthController', () => {
)
})
+ it('should return 403 with ACCOUNT_DEACTIVATED for deactivated accounts', async () => {
+ mockRequest.body = {
+ email: 'test@example.com',
+ password: 'Password123!',
+ }
+
+ ;(prisma.user.findUnique as any).mockResolvedValue({
+ id: '1',
+ email: 'test@example.com',
+ password: 'hashed_password',
+ username: 'testuser',
+ role: 'LEARNER',
+ status: 'DEACTIVATED',
+ })
+ ;(bcrypt.compare as any).mockResolvedValue(true)
+
+ await authController.login(
+ mockRequest as Request,
+ mockResponse as Response
+ )
+
+ expect(mockResponse.status).toHaveBeenCalledWith(403)
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' })
+ )
+ })
+
+ it('should return 403 with scheduledFor for accounts pending deletion', async () => {
+ mockRequest.body = {
+ email: 'test@example.com',
+ password: 'Password123!',
+ }
+
+ const scheduledFor = new Date('2026-08-18T00:00:00Z')
+
+ ;(prisma.user.findUnique as any).mockResolvedValue({
+ id: '1',
+ email: 'test@example.com',
+ password: 'hashed_password',
+ username: 'testuser',
+ role: 'LEARNER',
+ status: 'PENDING_DELETION',
+ })
+ ;(bcrypt.compare as any).mockResolvedValue(true)
+ ;(prisma.accountDeletionRequest.findFirst as any).mockResolvedValue({ scheduledFor })
+
+ await authController.login(
+ mockRequest as Request,
+ mockResponse as Response
+ )
+
+ expect(mockResponse.status).toHaveBeenCalledWith(403)
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: 'ACCOUNT_PENDING_DELETION',
+ scheduledFor,
+ })
+ )
+ })
+
+ it('should return a neutral 401 for deleted (tombstoned) accounts', async () => {
+ mockRequest.body = {
+ email: 'test@example.com',
+ password: 'Password123!',
+ }
+
+ ;(prisma.user.findUnique as any).mockResolvedValue({
+ id: '1',
+ email: 'deleted+abc@anon.invalid',
+ password: 'tombstone',
+ username: 'deleted_abc',
+ role: 'LEARNER',
+ status: 'DELETED',
+ })
+ ;(bcrypt.compare as any).mockResolvedValue(true)
+
+ await authController.login(
+ mockRequest as Request,
+ mockResponse as Response
+ )
+
+ expect(mockResponse.status).toHaveBeenCalledWith(401)
+ expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid credentials' })
+ })
+
it('should return 401 for invalid credentials', async () => {
mockRequest.body = {
email: 'test@example.com',
diff --git a/tests/data-export.service.test.ts b/tests/data-export.service.test.ts
new file mode 100644
index 0000000..4d70011
--- /dev/null
+++ b/tests/data-export.service.test.ts
@@ -0,0 +1,215 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('../src/config/database', () => ({
+ default: {
+ user: {
+ findUnique: vi.fn(),
+ },
+ dataExportRequest: {
+ findFirst: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ updateMany: vi.fn(),
+ },
+ completion: { findMany: vi.fn() },
+ credential: { findMany: vi.fn() },
+ transaction: { findMany: vi.fn() },
+ referralCode: { findFirst: vi.fn() },
+ referral: { findMany: vi.fn(), findFirst: vi.fn() },
+ syncEvent: { findMany: vi.fn() },
+ notificationPreference: { findFirst: vi.fn() },
+ notificationLog: { findMany: vi.fn() },
+ session: { findMany: vi.fn() },
+ auditLog: { findMany: vi.fn(), create: vi.fn() },
+ },
+}))
+
+vi.mock('../src/services/email.service', () => ({
+ emailService: {
+ queueEmail: vi.fn().mockResolvedValue({ id: 'email-1' }),
+ },
+}))
+
+vi.mock('../src/utils/logger', () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}))
+
+import prisma from '../src/config/database'
+import { emailService } from '../src/services/email.service'
+import { DataExportService } from '../src/services/data-export.service'
+
+const DAY_MS = 24 * 60 * 60 * 1000
+
+const pendingRow = {
+ id: 'exp-1',
+ userId: 'user-1',
+ status: 'pending',
+ attemptCount: 0,
+ maxAttempts: 5,
+ nextAttemptAt: new Date(0),
+}
+
+function mockUserData() {
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ id: 'user-1',
+ email: 'test@example.com',
+ username: 'testuser',
+ role: 'LEARNER',
+ walletAddress: 'GABC',
+ isVerified: true,
+ status: 'ACTIVE',
+ createdAt: new Date(),
+ lastLoginAt: null,
+ } as any)
+ vi.mocked(prisma.completion.findMany).mockResolvedValue([
+ { moduleId: 'm1', module: { title: 'Module One' }, score: 90, completedAt: new Date() },
+ ] as any)
+ vi.mocked(prisma.credential.findMany).mockResolvedValue([
+ { moduleId: 'm1', module: { title: 'Module One' }, onChainId: 'chain-1', issuedAt: new Date() },
+ ] as any)
+ vi.mocked(prisma.transaction.findMany).mockResolvedValue([
+ { id: 't1', amount: 10, type: 'reward', status: 'completed', createdAt: new Date() },
+ ] as any)
+ vi.mocked(prisma.referralCode.findFirst).mockResolvedValue({ code: 'REF1', createdAt: new Date() } as any)
+ vi.mocked(prisma.referral.findMany).mockResolvedValue([] as any)
+ vi.mocked(prisma.referral.findFirst).mockResolvedValue(null)
+ vi.mocked(prisma.syncEvent.findMany).mockResolvedValue([] as any)
+ vi.mocked(prisma.notificationPreference.findFirst).mockResolvedValue(null)
+ vi.mocked(prisma.notificationLog.findMany).mockResolvedValue([] as any)
+ vi.mocked(prisma.session.findMany).mockResolvedValue([
+ { userAgent: 'ua', ipAddress: '1.2.3.4', createdAt: new Date(), expiresAt: new Date(), isRevoked: false },
+ ] as any)
+ vi.mocked(prisma.auditLog.findMany).mockResolvedValue([
+ { action: 'LOGIN', createdAt: new Date() },
+ ] as any)
+}
+
+describe('DataExportService', () => {
+ let service: DataExportService
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ service = new DataExportService()
+ vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any)
+ vi.mocked(emailService.queueEmail).mockResolvedValue({ id: 'email-1' } as any)
+ })
+
+ describe('processQueue', () => {
+ it('skips generation when another runner already claimed the row', async () => {
+ vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([pendingRow] as any)
+ vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 0 } as any)
+
+ await service.processQueue()
+
+ expect(prisma.dataExportRequest.updateMany).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { id: 'exp-1', status: 'pending' } })
+ )
+ expect(prisma.user.findUnique).not.toHaveBeenCalled()
+ expect(prisma.dataExportRequest.update).not.toHaveBeenCalled()
+ })
+
+ it('generates a redacted artifact and marks the request ready with an expiry', async () => {
+ vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([pendingRow] as any)
+ vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 1 } as any)
+ mockUserData()
+
+ await service.processQueue()
+
+ const updateArg = vi.mocked(prisma.dataExportRequest.update).mock.calls[0][0] as any
+ expect(updateArg.data.status).toBe('ready')
+
+ // Redaction: no credentials/secrets anywhere in the artifact
+ const artifact = updateArg.data.artifact as string
+ expect(artifact).not.toContain('"password"')
+ expect(artifact).not.toContain('"token"')
+ expect(artifact).not.toContain('"tokenHash"')
+ expect(artifact).not.toContain('"refreshToken"')
+ expect(artifact).not.toContain('hashed_password')
+
+ const parsed = JSON.parse(artifact)
+ expect(parsed.exportVersion).toBe(1)
+ expect(parsed.data.profile.id).toBe('user-1')
+ expect(parsed.data.completions[0].moduleTitle).toBe('Module One')
+
+ // Time-bounded: expiresAt ≈ now + EXPORT_TTL_DAYS (default 7)
+ const deltaDays = (updateArg.data.expiresAt.getTime() - Date.now()) / DAY_MS
+ expect(deltaDays).toBeGreaterThan(6.9)
+ expect(deltaDays).toBeLessThan(7.1)
+
+ expect(emailService.queueEmail).toHaveBeenCalledWith(
+ 'user-1',
+ 'test@example.com',
+ expect.any(String),
+ expect.any(String),
+ 'DATA_EXPORT'
+ )
+ })
+
+ it('backs off and returns the request to pending on failure', async () => {
+ vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([pendingRow] as any)
+ vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 1 } as any)
+ vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error('db down'))
+
+ await service.processQueue()
+
+ const updateArg = vi.mocked(prisma.dataExportRequest.update).mock.calls[0][0] as any
+ expect(updateArg.data.status).toBe('pending')
+ expect(updateArg.data.error).toBe('db down')
+ expect(updateArg.data.nextAttemptAt).toBeInstanceOf(Date)
+ expect(updateArg.data.nextAttemptAt.getTime()).toBeGreaterThan(Date.now())
+ })
+
+ it('dead-letters the request as failed after max attempts', async () => {
+ vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([
+ { ...pendingRow, attemptCount: 4 },
+ ] as any)
+ vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 1 } as any)
+ vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error('db down'))
+
+ await service.processQueue()
+
+ const updateArg = vi.mocked(prisma.dataExportRequest.update).mock.calls[0][0] as any
+ expect(updateArg.data.status).toBe('failed')
+ })
+
+ it('re-running after completion is a no-op (idempotent)', async () => {
+ // The completed row no longer matches the pending filter
+ vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([] as any)
+
+ await service.processQueue()
+
+ expect(prisma.dataExportRequest.updateMany).not.toHaveBeenCalled()
+ expect(prisma.dataExportRequest.update).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('purgeExpired', () => {
+ it('expires ready requests past their expiry and nulls the artifact', async () => {
+ vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 2 } as any)
+
+ const purged = await service.purgeExpired()
+
+ expect(purged).toBe(2)
+ expect(prisma.dataExportRequest.updateMany).toHaveBeenCalledWith({
+ where: { status: 'ready', expiresAt: { lte: expect.any(Date) } },
+ data: { status: 'expired', artifact: null },
+ })
+ })
+ })
+
+ describe('requestExport', () => {
+ it('treats a unique-index violation from a concurrent create as a duplicate', async () => {
+ vi.mocked(prisma.dataExportRequest.findFirst)
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce({ id: 'exp-winner', userId: 'user-1', status: 'pending' } as any)
+ vi.mocked(prisma.dataExportRequest.create).mockRejectedValue({ code: 'P2002' })
+ vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([] as any)
+
+ const result = await service.requestExport('user-1')
+
+ expect(result.kind).toBe('duplicate')
+ expect(result.request.id).toBe('exp-winner')
+ })
+ })
+})