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') + }) + }) +})