# Admin Hard-Delete User Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add an admin-only, two-step (password + emailed-code), atomically cascading hard-delete for user accounts, plus a self-only authorization fix to the existing soft-delete endpoint.

**Architecture:** All work lives in `src/users/` plus a Prisma migration and one new email template. Two new endpoints (`POST /users/:id/delete-confirmation`, `POST /users/:id/hard-delete`) on the existing `UserController`. Service methods on `UserService`. New Prisma models `AdminDeleteConfirmation` (issued tokens) and `AdminDeleteAttempt` (rate-limit counter). The 17-step cascade and post-commit physical-file/session cleanup are encapsulated in private helpers. Confirmation codes are 6-digit numerics, hashed via the existing bcrypt helper, delivered by email only (never returned in HTTP responses). A daily cron prunes expired confirmations and old attempt rows.

**Tech Stack:** NestJS 11, Prisma 6, MySQL, bcrypt, `@nestjs-modules/mailer` (Handlebars), `@nestjs/schedule`, jest + `@nestjs/testing` (test scaffolding being introduced for the first time in `src/`), `@quixo3/prisma-session-store`.

**Reference spec:** `docs/superpowers/specs/2026-04-28-admin-delete-user-design.md` (commit `4c12786`). Re-read it before starting if you skipped it.

---

## Files to create / modify

**Create:**
- `prisma/migrations/<timestamp>_add_admin_delete_confirmation_and_attempts/migration.sql` (auto-generated by `prisma migrate dev`)
- `src/users/dtos/initiate-admin-delete.dto.ts`
- `src/users/dtos/confirm-admin-delete.dto.ts`
- `src/templates/admin-delete-confirmation.hbs`
- `src/users/users.service.spec.ts` (first service-level test file in the project)

**Modify:**
- `prisma/schema.prisma` — add two models, three User back-relations
- `src/utils/date.utils.ts` — add `addMinutesUTC`
- `src/users/users.service.ts` — inject `MailerService` + `ConfigService`; add public methods (`initiateAdminDelete`, `confirmAdminDelete`, `cleanupAdminDeleteRows`); add private helpers (`assertNotSelf`, `assertNotLastAdmin`, `enforceAdminDeleteRateLimit`, `computeUserDependencies`, `cascadeDeleteUserInTx`, `unlinkPathsBestEffort`, `destroyUserSessions`, `generateConfirmationCode`)
- `src/users/users.controller.ts` — add two routes; add self-only check inside `deleteProfile`
- `src/users/users.module.ts` — verify Mailer is global; otherwise no change

---

## Task 1: Schema changes + migration

**Files:**
- Modify: `prisma/schema.prisma`
- Create: `prisma/migrations/<timestamp>_add_admin_delete_confirmation_and_attempts/migration.sql` (auto-generated)

- [ ] **Step 1: Add the two new models to `prisma/schema.prisma`**

Append after the existing `model User { ... }` block (anywhere after — Prisma doesn't care about position, but conceptually these belong with the auth/admin models):

```prisma
model AdminDeleteConfirmation {
    id           Int       @id @default(autoincrement())
    code         String    @db.VarChar(255)
    adminId      Int       @map("admin_id")
    admin        User      @relation("AdminDeleteConfirmationAdmin", fields: [adminId], references: [id])
    targetUserId Int       @map("target_user_id")
    targetUser   User      @relation("AdminDeleteConfirmationTarget", fields: [targetUserId], references: [id])
    expiresAt    DateTime  @map("expires_at")
    consumedAt   DateTime? @map("consumed_at")

    createdAt DateTime @default(now()) @map("created_at")

    @@index([adminId, targetUserId])
    @@index([expiresAt])
    @@map("admin_delete_confirmations")
}

model AdminDeleteAttempt {
    id      Int     @id @default(autoincrement())
    adminId Int     @map("admin_id")
    admin   User    @relation("AdminDeleteAttemptAdmin", fields: [adminId], references: [id])
    success Boolean @default(false)

    createdAt DateTime @default(now()) @map("created_at")

    @@index([adminId, createdAt])
    @@map("admin_delete_attempts")
}
```

- [ ] **Step 2: Add three back-relations to the existing `User` model**

Inside `model User { ... }`, in the `// Relationships` block (after `blogComments`):

```prisma
adminDeleteConfirmationsInitiated AdminDeleteConfirmation[] @relation("AdminDeleteConfirmationAdmin")
adminDeleteConfirmationsTargeting AdminDeleteConfirmation[] @relation("AdminDeleteConfirmationTarget")
adminDeleteAttempts               AdminDeleteAttempt[]      @relation("AdminDeleteAttemptAdmin")
```

- [ ] **Step 3: Run the migration**

```bash
npx prisma migrate dev --name add-admin-delete-confirmation-and-attempts
```

Expected: migration files generated under `prisma/migrations/`, Prisma client regenerated, no errors. Verify the generated `migration.sql` creates `admin_delete_confirmations` and `admin_delete_attempts` tables with the indexes.

- [ ] **Step 4: Verify Prisma client picks up the new models**

```bash
npx prisma generate
```

Then check that `node_modules/.prisma/client/index.d.ts` contains `AdminDeleteConfirmationDelegate` and `AdminDeleteAttemptDelegate` (grep for those names). If not, the migration didn't apply; re-run.

- [ ] **Step 5: Commit**

```bash
git add prisma/schema.prisma prisma/migrations/
git commit -m "feat(db): add admin_delete_confirmations and admin_delete_attempts tables"
```

---

## Task 2: Add `addMinutesUTC` date helper (TDD)

**Files:**
- Modify: `src/utils/date.utils.ts`
- Create: `src/utils/date.utils.spec.ts`

- [ ] **Step 1: Write the failing test**

Create `src/utils/date.utils.spec.ts`:

```ts
import { addMinutesUTC } from './date.utils';

describe('addMinutesUTC', () => {
    it('adds positive minutes', () => {
        const base = new Date('2026-04-28T12:00:00.000Z');
        const result = addMinutesUTC(base, 10);
        expect(result.toISOString()).toBe('2026-04-28T12:10:00.000Z');
    });

    it('handles negative minutes', () => {
        const base = new Date('2026-04-28T12:00:00.000Z');
        const result = addMinutesUTC(base, -30);
        expect(result.toISOString()).toBe('2026-04-28T11:30:00.000Z');
    });

    it('does not mutate the input date', () => {
        const base = new Date('2026-04-28T12:00:00.000Z');
        const original = base.getTime();
        addMinutesUTC(base, 5);
        expect(base.getTime()).toBe(original);
    });
});
```

- [ ] **Step 2: Run test to verify it fails**

```bash
npx jest src/utils/date.utils.spec.ts
```

Expected: FAIL — `addMinutesUTC is not a function`.

- [ ] **Step 3: Implement**

In `src/utils/date.utils.ts`, after `addDaysUTC`, add:

```ts
/**
 * Add minutes to a UTC date
 * @param date - Base date
 * @param minutes - Minutes to add
 * @returns New date with minutes added
 */
export function addMinutesUTC(date: Date, minutes: number): Date {
    const result = new Date(date.getTime());
    result.setUTCMinutes(result.getUTCMinutes() + minutes);
    return result;
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
npx jest src/utils/date.utils.spec.ts
```

Expected: 3 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/utils/date.utils.ts src/utils/date.utils.spec.ts
git commit -m "feat(utils): add addMinutesUTC date helper"
```

---

## Task 3: Create the two DTOs

**Files:**
- Create: `src/users/dtos/initiate-admin-delete.dto.ts`
- Create: `src/users/dtos/confirm-admin-delete.dto.ts`

- [ ] **Step 1: Create `initiate-admin-delete.dto.ts`**

```ts
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';

export class InitiateAdminDeleteDto {
    @ApiProperty({
        description: "The admin's current password, for re-authentication.",
        example: 'CorrectHorseBatteryStaple',
    })
    @IsString()
    @IsNotEmpty()
    @MinLength(1)
    password!: string;
}
```

- [ ] **Step 2: Create `confirm-admin-delete.dto.ts`**

```ts
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';

export class ConfirmAdminDeleteDto {
    @ApiProperty({
        description: "The admin's current password.",
        example: 'CorrectHorseBatteryStaple',
    })
    @IsString()
    @IsNotEmpty()
    password!: string;

    @ApiProperty({
        description: 'The 6-digit confirmation code from the email sent during step 1.',
        example: '483921',
    })
    @IsString()
    @IsNotEmpty()
    @Matches(/^\d{6}$/, { message: 'confirmationCode must be exactly 6 digits' })
    confirmationCode!: string;
}
```

- [ ] **Step 3: Verify build**

```bash
npx tsc --noEmit
```

Expected: no errors.

- [ ] **Step 4: Commit**

```bash
git add src/users/dtos/initiate-admin-delete.dto.ts src/users/dtos/confirm-admin-delete.dto.ts
git commit -m "feat(users): add admin delete confirmation DTOs"
```

---

## Task 4: Create the email template

**Files:**
- Create: `src/templates/admin-delete-confirmation.hbs`

- [ ] **Step 1: Write the template**

Modeled after `src/templates/link-email.hbs` but with a code-block instead of a CTA button.

```hbs
<html lang='en'>
    <head>
        <meta charset='UTF-8' />
        <meta name='viewport' content='width=device-width, initial-scale=1' />
        <title>{{title}}</title>
        <link href='https://fonts.googleapis.com/css?family=Inter' rel='stylesheet' type='text/css' />
        <style type='text/css'>
            body, p, div { font-family: Inter, -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 20px; color: #1a1a1a; margin: 0; padding: 0; }
            .wrapper { width: 100%; background-color: #ffffff; }
            .container { max-width: 650px; margin: 0 auto; border: 1px solid #e1e1e1; border-radius: 12px; padding: 48px; }
            .heading { font-size: 24px; font-weight: 600; letter-spacing: -0.4px; line-height: 1.15; margin-bottom: 16px; }
            .subheading { color: #808080; font-size: 18px; line-height: 26px; margin: 16px 0; white-space: pre-line; }
            .target-block { background-color: #f5f5f5; border-radius: 8px; padding: 16px; margin: 24px 0; font-size: 16px; line-height: 24px; }
            .deps-list { color: #555; font-size: 16px; line-height: 26px; margin: 16px 0; padding-left: 20px; }
            .code-block { font-family: 'Courier New', monospace; font-size: 36px; font-weight: 700; letter-spacing: 8px; text-align: center; background: #090909; color: #fff; padding: 24px; border-radius: 12px; margin: 32px 0; }
            .footer { margin-top: 32px; color: #838383; font-size: 13px; line-height: 16px; white-space: pre-line; }
            .preheader { display: none; font-size: 0; color: transparent; height: 0; max-height: 0; opacity: 0; overflow: hidden; }
            @media screen and (max-width: 480px) { .container { padding: 24px; } .heading { font-size: 18px !important; } .subheading { font-size: 16px !important; } .code-block { font-size: 28px; letter-spacing: 4px; } }
        </style>
    </head>
    <body>
        <div class='preheader'>{{preheaderText}}</div>
        <center class='wrapper'>
            <table class='wrapper' width='100%' cellspacing='0' cellpadding='0' bgcolor='#FFFFFF'>
                <tr>
                    <td>
                        <table class='container' width='100%' cellspacing='0' cellpadding='0' bgcolor='#FFFFFF'>
                            <tr>
                                <td>
                                    <a href='{{logoLink}}'>
                                        <img src='{{logoSrc}}' alt='{{logoAlt}}' width='145' height='58' style='display:block;margin-bottom:32px;' />
                                    </a>
                                    <div class='heading'>{{title}}</div>
                                    <div class='subheading'>{{subtitle}}</div>

                                    <div class='target-block'>
                                        <strong>{{targetName}}</strong><br />
                                        {{targetEmail}}<br />
                                        Role: {{targetRole}}
                                    </div>

                                    <div class='subheading'>This will also delete:</div>
                                    <ul class='deps-list'>
                                        {{#each depsList}}
                                            <li>{{this}}</li>
                                        {{/each}}
                                    </ul>

                                    <div class='subheading'>Your confirmation code:</div>
                                    <div class='code-block'>{{code}}</div>

                                    <div style='background-color: #e1e1e1; height: 1px; width: 100%; margin: 32px 0;'></div>
                                    <div class='footer'>{{footerText}}</div>
                                </td>
                            </tr>
                        </table>
                    </td>
                </tr>
            </table>
        </center>
    </body>
</html>
```

- [ ] **Step 2: Verify the build copies the template**

```bash
npm run build
ls dist/templates/admin-delete-confirmation.hbs
```

Expected: file exists at `dist/templates/admin-delete-confirmation.hbs` (NestJS copies templates to dist by default — verify by checking `nest-cli.json` if missing).

- [ ] **Step 3: Commit**

```bash
git add src/templates/admin-delete-confirmation.hbs
git commit -m "feat(users): add admin delete confirmation email template"
```

---

## Task 5: Set up `users.service.spec.ts` test scaffolding

**Files:**
- Create: `src/users/users.service.spec.ts`

This is the first service-level spec file in `src/`. We're establishing the pattern: each test creates a fresh testing module with deeply-mocked dependencies. We use a manual mock factory rather than installing `jest-mock-extended` to keep deps unchanged.

- [ ] **Step 1: Write the scaffolding (no real tests yet — just one smoke test)**

```ts
import { Test, TestingModule } from '@nestjs/testing';
import { MailerService } from '@nestjs-modules/mailer';
import { ConfigService } from '@nestjs/config';
import { UserService } from './users.service';
import { PrismaService } from '~/prisma/prisma.service';
import { FilesService } from '~/files/files.service';

/**
 * Build a Prisma mock that auto-creates jest mocks for any model on first access,
 * with sensible default return values so cascading code that calls findMany.map(...)
 * doesn't blow up. The same model object is returned on every access (essential —
 * otherwise assertions never fire because the mock you tweak is a different mock
 * than the one the service called).
 *
 * $transaction calls back with the proxy itself, so tx.<model>.<op> hits the same
 * cached mocks as prisma.<model>.<op>.
 */
function makePrismaMock(): any {
    const cache: Record<string, any> = {};
    const proxy: any = new Proxy(
        {},
        {
            get(_t, prop: string | symbol) {
                if (prop === '$transaction') {
                    return jest.fn(async (cb: (tx: any) => any) => cb(proxy));
                }
                if (typeof prop !== 'string' || prop === 'then') return undefined;
                if (!cache[prop]) {
                    cache[prop] = {
                        findMany: jest.fn().mockResolvedValue([]),
                        findFirst: jest.fn().mockResolvedValue(null),
                        findUnique: jest.fn().mockResolvedValue(null),
                        count: jest.fn().mockResolvedValue(0),
                        create: jest.fn().mockResolvedValue({}),
                        update: jest.fn().mockResolvedValue({}),
                        updateMany: jest.fn().mockResolvedValue({ count: 0 }),
                        delete: jest.fn().mockResolvedValue({}),
                        deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
                    };
                }
                return cache[prop];
            },
            set(_t, prop: string, value) {
                cache[prop] = value;
                return true;
            },
        },
    );
    return proxy;
}

function makeMailerMock() {
    return { sendMail: jest.fn().mockResolvedValue({ messageId: 'test', accepted: ['x@y.z'], rejected: [] }) };
}

function makeConfigMock() {
    const map: Record<string, any> = {
        'email.from': 'no-reply@procleanlakes.eu',
        url: 'https://api.example.com',
        frontend: 'https://app.example.com',
    };
    return { get: jest.fn((k: string) => map[k]) };
}

function makeFilesMock() {
    return {} as DeepMock<FilesService>;
}

describe('UserService', () => {
    let service: UserService;
    let prisma: ReturnType<typeof makePrismaMock>;
    let mailer: ReturnType<typeof makeMailerMock>;
    let config: ReturnType<typeof makeConfigMock>;

    beforeEach(async () => {
        prisma = makePrismaMock();
        mailer = makeMailerMock();
        config = makeConfigMock();

        const module: TestingModule = await Test.createTestingModule({
            providers: [
                UserService,
                { provide: PrismaService, useValue: prisma },
                { provide: FilesService, useValue: makeFilesMock() },
                { provide: MailerService, useValue: mailer },
                { provide: ConfigService, useValue: config },
            ],
        }).compile();

        service = module.get<UserService>(UserService);
    });

    it('is defined', () => {
        expect(service).toBeDefined();
    });
});

export { makePrismaMock, makeMailerMock, makeConfigMock };
```

- [ ] **Step 2: Run test — expect failure (UserService does not yet take MailerService/ConfigService)**

```bash
npx jest src/users/users.service.spec.ts
```

Expected: FAIL — DI cannot resolve `MailerService` or `ConfigService` because `UserService` constructor doesn't list them yet.

- [ ] **Step 3: Add the new constructor params to `UserService`**

In `src/users/users.service.ts`:

```ts
import { MailerService } from '@nestjs-modules/mailer';
import { ConfigService } from '@nestjs/config';

// ...

@Injectable()
export class UserService {
    constructor(
        private readonly prisma: PrismaService,
        private readonly filesService: FilesService,
        private readonly mailerService: MailerService,
        private readonly configService: ConfigService,
    ) {}
    // ... rest unchanged
}
```

- [ ] **Step 4: Run test again — expect pass**

```bash
npx jest src/users/users.service.spec.ts
```

Expected: 1 test passes (`is defined`).

- [ ] **Step 5: Commit**

```bash
git add src/users/users.service.ts src/users/users.service.spec.ts
git commit -m "test(users): add users.service spec scaffolding with mock factories"
```

---

## Task 6: `generateConfirmationCode` helper (TDD)

**Files:**
- Modify: `src/users/users.service.ts`
- Modify: `src/users/users.service.spec.ts`

- [ ] **Step 1: Add failing test**

Append inside the `describe('UserService', ...)` block:

```ts
describe('generateConfirmationCode', () => {
    it('returns a 6-digit numeric string', () => {
        // @ts-expect-error - private method, accessed for test
        const code = service.generateConfirmationCode();
        expect(code).toMatch(/^\d{6}$/);
    });

    it('returns different values across calls (probabilistic — 5 in a row identical is ~1 in 10^25)', () => {
        const codes = new Set<string>();
        for (let i = 0; i < 5; i++) {
            // @ts-expect-error - private method, accessed for test
            codes.add(service.generateConfirmationCode());
        }
        expect(codes.size).toBeGreaterThan(1);
    });
});
```

- [ ] **Step 2: Run test — expect failure**

```bash
npx jest src/users/users.service.spec.ts -t generateConfirmationCode
```

Expected: FAIL — `service.generateConfirmationCode is not a function`.

- [ ] **Step 3: Implement**

In `src/users/users.service.ts`, add at the top:

```ts
import { randomInt } from 'crypto';
```

Then inside `UserService`:

```ts
private generateConfirmationCode(): string {
    return String(randomInt(100000, 1000000));
}
```

- [ ] **Step 4: Run test — expect pass**

```bash
npx jest src/users/users.service.spec.ts -t generateConfirmationCode
```

Expected: 2 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/users/users.service.ts src/users/users.service.spec.ts
git commit -m "feat(users): add generateConfirmationCode helper"
```

---

## Task 7: `assertNotSelf` and `assertNotLastAdmin` guards (TDD)

**Files:**
- Modify: `src/users/users.service.ts`
- Modify: `src/users/users.service.spec.ts`

- [ ] **Step 1: Add failing tests**

Append:

```ts
import { BadRequestException, ConflictException } from '@nestjs/common';
import { Role } from '@prisma/client';

// ...

describe('assertNotSelf', () => {
    it('throws CANNOT_DELETE_SELF when admin id equals target id', () => {
        // @ts-expect-error private
        expect(() => service.assertNotSelf(7, 7)).toThrow(BadRequestException);
        try {
            // @ts-expect-error private
            service.assertNotSelf(7, 7);
        } catch (err: any) {
            expect(err.response?.error?.code).toBe('CANNOT_DELETE_SELF');
        }
    });

    it('does not throw when admin id differs from target id', () => {
        // @ts-expect-error private
        expect(() => service.assertNotSelf(7, 8)).not.toThrow();
    });
});

describe('assertNotLastAdmin', () => {
    it('does nothing when target is not an admin', async () => {
        // @ts-expect-error private
        await expect(service.assertNotLastAdmin(prisma as any, { role: Role.STAKEHOLDER } as any)).resolves.toBeUndefined();
        expect(prisma.user.count).not.toHaveBeenCalled();
    });

    it('throws LAST_ADMIN when target is admin and admin-count is 1', async () => {
        (prisma.user.count as jest.Mock).mockResolvedValueOnce(1);
        await expect(
            // @ts-expect-error private
            service.assertNotLastAdmin(prisma as any, { role: Role.ADMIN } as any),
        ).rejects.toThrow(ConflictException);
    });

    it('does not throw when target is admin and another admin exists', async () => {
        (prisma.user.count as jest.Mock).mockResolvedValueOnce(2);
        await expect(
            // @ts-expect-error private
            service.assertNotLastAdmin(prisma as any, { role: Role.ADMIN } as any),
        ).resolves.toBeUndefined();
    });
});
```

- [ ] **Step 2: Run tests — expect failure**

```bash
npx jest src/users/users.service.spec.ts -t "assertNotSelf|assertNotLastAdmin"
```

Expected: 5 tests fail — methods don't exist.

- [ ] **Step 3: Implement**

In `src/users/users.service.ts`, add imports:

```ts
import { BadRequestException, ConflictException, Injectable, NotFoundException, ForbiddenException, UnauthorizedException, GoneException, HttpException, HttpStatus } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client';
```

(Replace the existing import line; keep what's already there.)

Then inside `UserService`:

```ts
private assertNotSelf(adminId: number, targetUserId: number): void {
    if (adminId === targetUserId) {
        throw new BadRequestException(
            createErrorResponse('Admins cannot delete their own account.', 'CANNOT_DELETE_SELF'),
        );
    }
}

private async assertNotLastAdmin(
    tx: Pick<PrismaService, 'user'> | Prisma.TransactionClient,
    target: Pick<User, 'role'>,
): Promise<void> {
    if (target.role !== Role.ADMIN) return;
    const adminCount = await tx.user.count({ where: { role: Role.ADMIN } });
    if (adminCount <= 1) {
        throw new ConflictException(
            createErrorResponse('Cannot delete the last remaining admin.', 'LAST_ADMIN'),
        );
    }
}
```

- [ ] **Step 4: Run tests — expect pass**

```bash
npx jest src/users/users.service.spec.ts -t "assertNotSelf|assertNotLastAdmin"
```

Expected: 5 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/users/users.service.ts src/users/users.service.spec.ts
git commit -m "feat(users): add assertNotSelf and assertNotLastAdmin guards"
```

---

## Task 8: `enforceAdminDeleteRateLimit` (TDD)

**Files:**
- Modify: `src/users/users.service.ts`
- Modify: `src/users/users.service.spec.ts`

- [ ] **Step 1: Add failing tests**

Append:

```ts
describe('enforceAdminDeleteRateLimit', () => {
    it('does nothing under threshold', async () => {
        (prisma.adminDeleteAttempt.count as jest.Mock).mockResolvedValueOnce(4);
        // @ts-expect-error private
        await expect(service.enforceAdminDeleteRateLimit(7)).resolves.toBeUndefined();
    });

    it('throws TOO_MANY_ATTEMPTS at threshold (5)', async () => {
        (prisma.adminDeleteAttempt.count as jest.Mock).mockResolvedValueOnce(5);
        // @ts-expect-error private
        await expect(service.enforceAdminDeleteRateLimit(7)).rejects.toMatchObject({
            status: 429,
            response: { error: { code: 'TOO_MANY_ATTEMPTS' } },
        });
    });

    it('throws TOO_MANY_ATTEMPTS above threshold', async () => {
        (prisma.adminDeleteAttempt.count as jest.Mock).mockResolvedValueOnce(99);
        // @ts-expect-error private
        await expect(service.enforceAdminDeleteRateLimit(7)).rejects.toMatchObject({
            status: 429,
        });
    });
});
```

- [ ] **Step 2: Run — expect failure**

```bash
npx jest src/users/users.service.spec.ts -t enforceAdminDeleteRateLimit
```

Expected: 3 tests fail.

- [ ] **Step 3: Implement**

In `src/users/users.service.ts`:

```ts
import { addMinutesUTC, nowUTC } from '~/utils';

// ...

private static readonly RATE_LIMIT_WINDOW_MINUTES = 15;
private static readonly RATE_LIMIT_MAX_FAILURES = 5;

private async enforceAdminDeleteRateLimit(adminId: number): Promise<void> {
    const since = addMinutesUTC(nowUTC(), -UserService.RATE_LIMIT_WINDOW_MINUTES);
    const failures = await this.prisma.adminDeleteAttempt.count({
        where: {
            adminId,
            success: false,
            createdAt: { gt: since },
        },
    });
    if (failures >= UserService.RATE_LIMIT_MAX_FAILURES) {
        // retryAfter is approximate — full window from oldest possible failure
        const retryAfterSeconds = UserService.RATE_LIMIT_WINDOW_MINUTES * 60;
        throw new HttpException(
            {
                ...createErrorResponse(
                    'Too many failed attempts. Please wait before retrying.',
                    'TOO_MANY_ATTEMPTS',
                ),
                retryAfter: retryAfterSeconds,
            },
            HttpStatus.TOO_MANY_REQUESTS,
        );
    }
}
```

- [ ] **Step 4: Run — expect pass**

```bash
npx jest src/users/users.service.spec.ts -t enforceAdminDeleteRateLimit
```

Expected: 3 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/users/users.service.ts src/users/users.service.spec.ts
git commit -m "feat(users): add admin-delete rate limit enforcement"
```

---

## Task 9: `computeUserDependencies` (TDD)

**Files:**
- Modify: `src/users/users.service.ts`
- Modify: `src/users/users.service.spec.ts`

- [ ] **Step 1: Add failing test**

Append:

```ts
describe('computeUserDependencies', () => {
    it('returns count for every relation type', async () => {
        (prisma.trainingSession.count as jest.Mock).mockResolvedValue(3);
        (prisma.trainingModuleTrainer.count as jest.Mock).mockResolvedValue(2);
        (prisma.discussion.count as jest.Mock).mockResolvedValueOnce(5).mockResolvedValueOnce(1);
        (prisma.comment.count as jest.Mock).mockResolvedValue(12);
        (prisma.file.count as jest.Mock).mockResolvedValue(4);
        (prisma.blogPost.count as jest.Mock).mockResolvedValue(1);
        (prisma.blogPostModerator.count as jest.Mock).mockResolvedValue(0);
        (prisma.blogComment.count as jest.Mock).mockResolvedValue(7);
        (prisma.trainingSessionStakeholders.count as jest.Mock).mockResolvedValue(6);
        (prisma.userFavoriteTrainingModule.count as jest.Mock).mockResolvedValue(9);
        (prisma.notification.count as jest.Mock).mockResolvedValue(23);

        // @ts-expect-error private
        const deps = await service.computeUserDependencies(prisma as any, 42);

        expect(deps).toEqual({
            trainingSessionsOwned: 3,
            trainingModuleTrainerships: 2,
            discussionsAuthored: 5,
            discussionsCoaching: 1,
            comments: 12,
            files: 4,
            blogPostsAuthored: 1,
            blogPostsModerating: 0,
            blogComments: 7,
            trainingSessionEnrollments: 6,
            favoriteTrainingModules: 9,
            notifications: 23,
        });
    });
});
```

- [ ] **Step 2: Run — expect failure**

```bash
npx jest src/users/users.service.spec.ts -t computeUserDependencies
```

Expected: FAIL.

- [ ] **Step 3: Implement**

In `src/users/users.service.ts`:

```ts
private async computeUserDependencies(
    db: PrismaService | Prisma.TransactionClient,
    userId: number,
) {
    const [
        trainingSessionsOwned,
        trainingModuleTrainerships,
        discussionsAuthored,
        discussionsCoaching,
        comments,
        files,
        blogPostsAuthored,
        blogPostsModerating,
        blogComments,
        trainingSessionEnrollments,
        favoriteTrainingModules,
        notifications,
    ] = await Promise.all([
        db.trainingSession.count({ where: { ownerId: userId } }),
        db.trainingModuleTrainer.count({ where: { userId } }),
        db.discussion.count({ where: { userId } }),
        db.discussion.count({ where: { coachId: userId } }),
        db.comment.count({ where: { userId } }),
        db.file.count({ where: { userId } }),
        db.blogPost.count({ where: { authorId: userId } }),
        db.blogPostModerator.count({ where: { userId } }),
        db.blogComment.count({ where: { userId } }),
        db.trainingSessionStakeholders.count({ where: { stakeholderId: userId } }),
        db.userFavoriteTrainingModule.count({ where: { userId } }),
        db.notification.count({ where: { userId } }),
    ]);

    return {
        trainingSessionsOwned,
        trainingModuleTrainerships,
        discussionsAuthored,
        discussionsCoaching,
        comments,
        files,
        blogPostsAuthored,
        blogPostsModerating,
        blogComments,
        trainingSessionEnrollments,
        favoriteTrainingModules,
        notifications,
    };
}
```

- [ ] **Step 4: Run — expect pass**

```bash
npx jest src/users/users.service.spec.ts -t computeUserDependencies
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/users/users.service.ts src/users/users.service.spec.ts
git commit -m "feat(users): add computeUserDependencies summary helper"
```

---

## Task 10: `initiateAdminDelete` service method (TDD)

**Files:**
- Modify: `src/users/users.service.ts`
- Modify: `src/users/users.service.spec.ts`

- [ ] **Step 1: Add failing tests**

Append:

```ts
import { hashPassword } from '~/utils';

describe('initiateAdminDelete', () => {
    const ADMIN = { id: 1, role: Role.ADMIN, password: hashPassword('admin-pw'), email: 'admin@example.com', firstName: 'Ad', lastName: 'Min' } as any;
    const TARGET = { id: 99, role: Role.STAKEHOLDER, email: 'target@example.com', firstName: 'Tar', lastName: 'Get' } as any;

    function setupHappyPath() {
        // user lookup returns admin first, then target. Everything else (counts → 0,
        // findMany → [], create → {}) comes from the Proxy mock defaults in Task 5.
        (prisma.user.findUnique as jest.Mock)
            .mockResolvedValueOnce(ADMIN)
            .mockResolvedValueOnce(TARGET);
    }

    it('happy path: returns dependency summary, sends email, code is NOT in response', async () => {
        setupHappyPath();
        const result = await service.initiateAdminDelete(ADMIN.id, 99, 'admin-pw');
        expect(result).toMatchObject({
            success: true,
            data: { message: expect.any(String), expiresAt: expect.any(Date), dependencies: expect.any(Object) },
        });
        expect((result as any).data.code).toBeUndefined();
        expect((result as any).data.confirmationCode).toBeUndefined();
        expect(mailer.sendMail).toHaveBeenCalledWith(
            expect.objectContaining({ to: ADMIN.email, template: 'admin-delete-confirmation' }),
        );
    });

    it('throws CANNOT_DELETE_SELF when target is admin themselves', async () => {
        await expect(service.initiateAdminDelete(7, 7, 'pw')).rejects.toMatchObject({
            response: { error: { code: 'CANNOT_DELETE_SELF' } },
        });
    });

    it('throws INVALID_PASSWORD and logs failed attempt on wrong password', async () => {
        (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(ADMIN);
        await expect(service.initiateAdminDelete(ADMIN.id, 99, 'wrong-pw')).rejects.toMatchObject({
            status: 401,
            response: { error: { code: 'INVALID_PASSWORD' } },
        });
        expect(prisma.adminDeleteAttempt.create).toHaveBeenCalledWith({
            data: { adminId: ADMIN.id, success: false },
        });
    });

    it('throws USER_NOT_FOUND when target does not exist', async () => {
        (prisma.user.findUnique as jest.Mock)
            .mockResolvedValueOnce(ADMIN)
            .mockResolvedValueOnce(null);
        await expect(service.initiateAdminDelete(ADMIN.id, 99, 'admin-pw')).rejects.toMatchObject({
            status: 404,
            response: { error: { code: 'USER_NOT_FOUND' } },
        });
    });

    it('throws LAST_ADMIN when deleting the only admin', async () => {
        const onlyAdminTarget = { ...TARGET, role: Role.ADMIN };
        (prisma.user.findUnique as jest.Mock)
            .mockResolvedValueOnce(ADMIN)
            .mockResolvedValueOnce(onlyAdminTarget);
        (prisma.user.count as jest.Mock).mockResolvedValueOnce(1);
        await expect(service.initiateAdminDelete(ADMIN.id, 99, 'admin-pw')).rejects.toMatchObject({
            status: 409,
            response: { error: { code: 'LAST_ADMIN' } },
        });
    });

    it('throws TOO_MANY_ATTEMPTS when rate-limited', async () => {
        (prisma.adminDeleteAttempt.count as jest.Mock).mockResolvedValue(99);
        await expect(service.initiateAdminDelete(ADMIN.id, 99, 'admin-pw')).rejects.toMatchObject({
            status: 429,
            response: { error: { code: 'TOO_MANY_ATTEMPTS' } },
        });
    });

    it('returns MAIL_SEND_FAILED 503 when mailer throws', async () => {
        setupHappyPath();
        (mailer.sendMail as jest.Mock).mockRejectedValueOnce(new Error('SMTP down'));
        await expect(service.initiateAdminDelete(ADMIN.id, 99, 'admin-pw')).rejects.toMatchObject({
            status: 503,
            response: { error: { code: 'MAIL_SEND_FAILED' } },
        });
    });
});
```

- [ ] **Step 2: Run — expect failure**

```bash
npx jest src/users/users.service.spec.ts -t initiateAdminDelete
```

Expected: tests fail — method doesn't exist.

- [ ] **Step 3: Implement**

In `src/users/users.service.ts`:

```ts
private static readonly CODE_TTL_MINUTES = 10;

async initiateAdminDelete(
    adminId: number,
    targetUserId: number,
    password: string,
): Promise<ServiceResponse> {
    await this.enforceAdminDeleteRateLimit(adminId);
    this.assertNotSelf(adminId, targetUserId);

    const admin = await this.prisma.user.findUnique({ where: { id: adminId } });
    if (!admin) {
        // Defensive — RolesGuard should already block this, but treat as auth failure if so.
        throw new UnauthorizedException(
            createErrorResponse('Not authenticated', 'NOT_AUTHENTICATED'),
        );
    }

    if (!comparePasswords(password, admin.password)) {
        await this.prisma.adminDeleteAttempt.create({
            data: { adminId, success: false },
        });
        throw new UnauthorizedException(
            createErrorResponse('Invalid password.', 'INVALID_PASSWORD'),
        );
    }

    const target = await this.prisma.user.findUnique({ where: { id: targetUserId } });
    if (!target) {
        throw new NotFoundException(createErrorResponse('User not found', 'USER_NOT_FOUND'));
    }
    await this.assertNotLastAdmin(this.prisma, target);

    const dependencies = await this.computeUserDependencies(this.prisma, targetUserId);

    const code = this.generateConfirmationCode();
    const hashedCode = hashPassword(code);
    const expiresAt = addMinutesUTC(nowUTC(), UserService.CODE_TTL_MINUTES);

    await this.prisma.$transaction(async (tx) => {
        await tx.adminDeleteConfirmation.deleteMany({
            where: { adminId, targetUserId, consumedAt: null },
        });
        await tx.adminDeleteConfirmation.create({
            data: { code: hashedCode, adminId, targetUserId, expiresAt },
        });
    });

    try {
        await this.mailerService.sendMail({
            to: admin.email,
            subject: `Confirm deletion: ${target.firstName} ${target.lastName} (${target.email}) — ProCleanLakes`,
            template: 'admin-delete-confirmation',
            from: `ProCleanLakes <${this.configService.get('email.from')}>`,
            context: {
                preheaderText: 'Your admin deletion confirmation code.',
                logoLink: 'https://procleanlakes.eu',
                logoSrc: 'https://procleanlakes.eu/wp-content/uploads/2024/07/ProCleanLakes_project_logo.png',
                logoAlt: 'ProClean Lakes Logo',
                title: 'Confirm User Deletion',
                subtitle: `Hi ${admin.firstName},\n\nYou requested to permanently delete the following user:`,
                targetName: `${target.firstName} ${target.lastName}`,
                targetEmail: target.email,
                targetRole: target.role,
                depsList: this.formatDepsForEmail(dependencies),
                code,
                footerText: `This code expires in ${UserService.CODE_TTL_MINUTES} minutes. If you didn't request this, ignore this email and change your password immediately.\n\n© ${new Date().getFullYear()} ProCleanLakes. All rights reserved.`,
            },
        });
    } catch (err) {
        // Email failed; the confirmation row will simply expire. Surface to caller.
        throw new HttpException(
            createErrorResponse('Failed to send confirmation email.', 'MAIL_SEND_FAILED'),
            HttpStatus.SERVICE_UNAVAILABLE,
        );
    }

    return createSuccessResponse({
        message: 'Confirmation code sent to your email.',
        expiresAt,
        dependencies,
    });
}

private formatDepsForEmail(deps: Record<string, number>): string[] {
    const labels: Record<string, string> = {
        trainingSessionsOwned: 'training sessions owned',
        trainingModuleTrainerships: 'training module trainer assignments',
        discussionsAuthored: 'discussions authored',
        discussionsCoaching: 'discussions being coached',
        comments: 'comments',
        files: 'uploaded files',
        blogPostsAuthored: 'blog posts authored',
        blogPostsModerating: 'blog moderator assignments',
        blogComments: 'blog comments',
        trainingSessionEnrollments: 'training session enrollments',
        favoriteTrainingModules: 'favorite training modules',
        notifications: 'notifications',
    };
    return Object.entries(deps)
        .filter(([, count]) => count > 0)
        .map(([key, count]) => `${count} ${labels[key] ?? key}`);
}
```

- [ ] **Step 4: Run — expect pass**

```bash
npx jest src/users/users.service.spec.ts -t initiateAdminDelete
```

Expected: 7 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/users/users.service.ts src/users/users.service.spec.ts
git commit -m "feat(users): add initiateAdminDelete service method"
```

---

## Task 11: `cascadeDeleteUserInTx` (TDD — verify call order)

**Files:**
- Modify: `src/users/users.service.ts`
- Modify: `src/users/users.service.spec.ts`

This test verifies the **order of operations** by recording each `deleteMany`/`updateMany`/`delete` call against a mock `tx`. Cascade correctness against a real DB is verified manually (see final task).

- [ ] **Step 1: Add failing test**

Append:

```ts
describe('cascadeDeleteUserInTx', () => {
    it('executes the 17 cascade steps in the documented order', async () => {
        const calls: Array<{ table: string; op: string; arg?: any }> = [];
        const mkTable = (name: string) => ({
            deleteMany: jest.fn(async (a: any) => { calls.push({ table: name, op: 'deleteMany', arg: a }); return { count: 0 }; }),
            delete: jest.fn(async (a: any) => { calls.push({ table: name, op: 'delete', arg: a }); return {}; }),
            updateMany: jest.fn(async (a: any) => { calls.push({ table: name, op: 'updateMany', arg: a }); return { count: 0 }; }),
            findMany: jest.fn(async () => []),
        });
        const tx: any = {
            adminDeleteConfirmation: mkTable('adminDeleteConfirmation'),
            adminDeleteAttempt: mkTable('adminDeleteAttempt'),
            verificationToken: mkTable('verificationToken'),
            notification: mkTable('notification'),
            notificationSettings: mkTable('notificationSettings'),
            userFavoriteTrainingModule: mkTable('userFavoriteTrainingModule'),
            discussion: { ...mkTable('discussion'), findMany: jest.fn(async () => []) },
            blogPost: { ...mkTable('blogPost'), findMany: jest.fn(async () => []) },
            blogComment: mkTable('blogComment'),
            blogPostModerator: mkTable('blogPostModerator'),
            comment: { ...mkTable('comment'), findMany: jest.fn(async () => []) },
            coachingSession: mkTable('coachingSession'),
            file: mkTable('file'),
            trainingSession: { ...mkTable('trainingSession'), findMany: jest.fn(async () => []) },
            trainingSessionStakeholders: mkTable('trainingSessionStakeholders'),
            trainingModuleTrainer: mkTable('trainingModuleTrainer'),
            user: mkTable('user'),
        };

        // @ts-expect-error private
        await service.cascadeDeleteUserInTx(tx, 42);

        const tables = calls.map((c) => `${c.table}.${c.op}`);
        // Sample assertions — these reflect the documented order
        expect(tables.indexOf('adminDeleteConfirmation.deleteMany')).toBeLessThan(tables.indexOf('verificationToken.deleteMany'));
        expect(tables.indexOf('verificationToken.deleteMany')).toBeLessThan(tables.indexOf('notification.deleteMany'));
        expect(tables.indexOf('notification.deleteMany')).toBeLessThan(tables.indexOf('userFavoriteTrainingModule.deleteMany'));
        expect(tables.indexOf('blogComment.deleteMany')).toBeLessThan(tables.indexOf('blogPost.deleteMany'));
        expect(tables.indexOf('comment.deleteMany')).toBeLessThan(tables.indexOf('discussion.deleteMany'));
        expect(tables.indexOf('coachingSession.deleteMany')).toBeLessThan(tables.indexOf('discussion.deleteMany'));
        expect(tables.indexOf('trainingSessionStakeholders.deleteMany')).toBeLessThan(tables.indexOf('trainingSession.deleteMany'));
        // user.delete is the very last op
        expect(tables[tables.length - 1]).toBe('user.delete');
    });
});
```

- [ ] **Step 2: Run — expect failure**

```bash
npx jest src/users/users.service.spec.ts -t cascadeDeleteUserInTx
```

Expected: FAIL — method doesn't exist.

- [ ] **Step 3: Implement**

In `src/users/users.service.ts`:

```ts
/**
 * Cascade-delete a user and ALL dependent records.
 *
 * AUTHORITATIVE ORDER — DO NOT REORDER WITHOUT REVIEWING FK CONSTRAINTS.
 * If you add a new relation to User in schema.prisma, it MUST be deleted here
 * before User in the correct dependency order, or this transaction will throw
 * on FK_RESTRICT.
 *
 *  1. AdminDeleteConfirmation (admin & target sides)
 *  2. AdminDeleteAttempt
 *  3. VerificationToken
 *  4. Notification
 *  5. NotificationSettings
 *  6. UserFavoriteTrainingModule
 *  7. Discussion.coachId set to null (preserve discussions authored by others)
 *  8. Blog comment subtree on user-authored posts (replies → roots → moderators → posts)
 *  9. BlogComment authored by user (replies → roots)
 * 10. BlogPostModerator
 * 11. Comments-and-files subtree under user's discussions
 * 12. Comments authored by user on others' discussions (with their files)
 * 13. File rows directly owned by user (avatars, standalone)
 * 14. TrainingSession subtree (sessions the user owns)
 * 15. TrainingSessionStakeholders (user's enrollments)
 * 16. TrainingModuleTrainer
 * 17. User
 */
private async cascadeDeleteUserInTx(
    tx: Prisma.TransactionClient,
    userId: number,
): Promise<void> {
    // 1
    await tx.adminDeleteConfirmation.deleteMany({ where: { adminId: userId } });
    await tx.adminDeleteConfirmation.deleteMany({ where: { targetUserId: userId } });
    // 2
    await tx.adminDeleteAttempt.deleteMany({ where: { adminId: userId } });
    // 3
    await tx.verificationToken.deleteMany({ where: { userId } });
    // 4
    await tx.notification.deleteMany({ where: { userId } });
    // 5
    await tx.notificationSettings.deleteMany({ where: { userId } });
    // 6
    await tx.userFavoriteTrainingModule.deleteMany({ where: { userId } });
    // 7
    await tx.discussion.updateMany({ where: { coachId: userId }, data: { coachId: null } });
    // 8 — blog post subtree
    const userPosts = await tx.blogPost.findMany({
        where: { authorId: userId },
        select: { id: true },
    });
    const postIds = userPosts.map((p) => p.id);
    if (postIds.length > 0) {
        await tx.blogComment.deleteMany({
            where: { blogPostId: { in: postIds }, repliedToId: { not: null } },
        });
        await tx.blogComment.deleteMany({ where: { blogPostId: { in: postIds } } });
        await tx.blogPostModerator.deleteMany({ where: { blogPostId: { in: postIds } } });
        await tx.blogPost.deleteMany({ where: { authorId: userId } });
    }
    // 9 — user-authored blog comments on others' posts
    await tx.blogComment.deleteMany({ where: { userId, repliedToId: { not: null } } });
    await tx.blogComment.deleteMany({ where: { userId } });
    // 10
    await tx.blogPostModerator.deleteMany({ where: { userId } });
    // 11 — comment+file subtree under user's discussions
    const userDiscussions = await tx.discussion.findMany({
        where: { userId },
        select: { id: true },
    });
    const discussionIds = userDiscussions.map((d) => d.id);
    if (discussionIds.length > 0) {
        const cmts = await tx.comment.findMany({
            where: { discussionId: { in: discussionIds } },
            select: { id: true },
        });
        const cmtIds = cmts.map((c) => c.id);
        if (cmtIds.length > 0) {
            await tx.file.deleteMany({ where: { commentId: { in: cmtIds } } });
            await tx.comment.deleteMany({ where: { id: { in: cmtIds } } });
        }
        await tx.coachingSession.deleteMany({ where: { discussionId: { in: discussionIds } } });
        await tx.discussion.deleteMany({ where: { userId } });
    }
    // 12 — user's comments on others' discussions
    const userComments = await tx.comment.findMany({
        where: { userId },
        select: { id: true },
    });
    const userCommentIds = userComments.map((c) => c.id);
    if (userCommentIds.length > 0) {
        await tx.file.deleteMany({ where: { commentId: { in: userCommentIds } } });
        await tx.comment.deleteMany({ where: { id: { in: userCommentIds } } });
    }
    // 13
    await tx.file.deleteMany({ where: { userId } });
    // 14 — training session subtree
    const userSessions = await tx.trainingSession.findMany({
        where: { ownerId: userId },
        select: { id: true },
    });
    const sessionIds = userSessions.map((s) => s.id);
    if (sessionIds.length > 0) {
        await tx.trainingSessionStakeholders.deleteMany({
            where: { trainingSessionId: { in: sessionIds } },
        });
        await tx.trainingSession.deleteMany({ where: { ownerId: userId } });
    }
    // 15
    await tx.trainingSessionStakeholders.deleteMany({ where: { stakeholderId: userId } });
    // 16
    await tx.trainingModuleTrainer.deleteMany({ where: { userId } });
    // 17
    await tx.user.delete({ where: { id: userId } });
}
```

- [ ] **Step 4: Run — expect pass**

```bash
npx jest src/users/users.service.spec.ts -t cascadeDeleteUserInTx
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/users/users.service.ts src/users/users.service.spec.ts
git commit -m "feat(users): add cascadeDeleteUserInTx with documented FK order"
```

---

## Task 12: `unlinkPathsBestEffort` and `destroyUserSessions` (TDD)

**Files:**
- Modify: `src/users/users.service.ts`
- Modify: `src/users/users.service.spec.ts`

- [ ] **Step 1: Add failing tests**

Append:

```ts
import * as fs from 'fs';
import * as path from 'path';

jest.mock('fs');

describe('unlinkPathsBestEffort', () => {
    beforeEach(() => {
        (fs.existsSync as jest.Mock).mockReset();
        (fs.unlinkSync as jest.Mock).mockReset();
    });

    it('unlinks each existing file and ignores missing ones', () => {
        (fs.existsSync as jest.Mock).mockImplementation((p: string) => p.endsWith('keep.png'));
        // @ts-expect-error private
        service.unlinkPathsBestEffort(['public/uploads/keep.png', 'public/uploads/missing.png']);
        expect(fs.unlinkSync).toHaveBeenCalledTimes(1);
    });

    it('does not throw when unlinkSync throws (best-effort)', () => {
        (fs.existsSync as jest.Mock).mockReturnValue(true);
        (fs.unlinkSync as jest.Mock).mockImplementation(() => { throw new Error('EACCES'); });
        expect(() =>
            // @ts-expect-error private
            service.unlinkPathsBestEffort(['public/uploads/locked.png']),
        ).not.toThrow();
    });
});

describe('destroyUserSessions', () => {
    it('deletes only sessions belonging to the user', async () => {
        (prisma.session.findMany as jest.Mock).mockResolvedValue([
            { id: 's1', data: JSON.stringify({ passport: { user: { id: 42 } } }) },
            { id: 's2', data: JSON.stringify({ passport: { user: { id: 99 } } }) },
            { id: 's3', data: 'not-json-corrupt-row' },
        ]);
        // @ts-expect-error private
        await service.destroyUserSessions(42);
        expect(prisma.session.deleteMany).toHaveBeenCalledWith({
            where: { id: { in: ['s1'] } },
        });
    });

    it('skips deleteMany when no sessions match', async () => {
        (prisma.session.findMany as jest.Mock).mockResolvedValue([
            { id: 's2', data: JSON.stringify({ passport: { user: { id: 99 } } }) },
        ]);
        // @ts-expect-error private
        await service.destroyUserSessions(42);
        expect(prisma.session.deleteMany).not.toHaveBeenCalled();
    });
});
```

- [ ] **Step 2: Run — expect failure**

```bash
npx jest src/users/users.service.spec.ts -t "unlinkPathsBestEffort|destroyUserSessions"
```

Expected: FAIL — methods don't exist.

- [ ] **Step 3: Implement**

In `src/users/users.service.ts`:

```ts
import { existsSync, unlinkSync } from 'fs';
import { join } from 'path';
import { Logger } from '@nestjs/common';

// ...

private readonly logger = new Logger(UserService.name);
private static readonly UPLOAD_DIR = join(__dirname, '..', '..', '..', 'public', 'uploads');

private unlinkPathsBestEffort(paths: string[]): void {
    for (const p of paths) {
        try {
            const filename = p.split('/').pop();
            if (!filename) continue;
            const abs = join(UserService.UPLOAD_DIR, filename);
            if (existsSync(abs)) {
                unlinkSync(abs);
            }
        } catch (err) {
            this.logger.warn(`Failed to unlink ${p}: ${(err as Error).message}`);
        }
    }
}

private async destroyUserSessions(userId: number): Promise<void> {
    const sessions = await this.prisma.session.findMany();
    const matchingIds: string[] = [];
    for (const s of sessions) {
        try {
            const data = JSON.parse(s.data);
            if (data?.passport?.user?.id === userId) {
                matchingIds.push(s.id);
            }
        } catch {
            // Corrupt row — skip; the session-store will eventually evict it.
        }
    }
    if (matchingIds.length > 0) {
        await this.prisma.session.deleteMany({ where: { id: { in: matchingIds } } });
    }
}
```

- [ ] **Step 4: Run — expect pass**

```bash
npx jest src/users/users.service.spec.ts -t "unlinkPathsBestEffort|destroyUserSessions"
```

Expected: 4 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/users/users.service.ts src/users/users.service.spec.ts
git commit -m "feat(users): add post-commit file unlink and session destroyer helpers"
```

---

## Task 13: `confirmAdminDelete` service method (TDD)

**Files:**
- Modify: `src/users/users.service.ts`
- Modify: `src/users/users.service.spec.ts`

- [ ] **Step 1: Add failing tests**

Append:

```ts
import { hashPassword } from '~/utils';

describe('confirmAdminDelete', () => {
    const ADMIN = { id: 1, role: Role.ADMIN, password: hashPassword('admin-pw'), email: 'admin@example.com' } as any;
    const TARGET = { id: 99, role: Role.STAKEHOLDER, email: 'target@example.com' } as any;
    const CODE_PLAINTEXT = '483921';

    function setupConfirmation(opts: { expired?: boolean; consumed?: boolean } = {}) {
        const expiresAt = opts.expired
            ? new Date(Date.now() - 60_000)
            : new Date(Date.now() + 60_000);
        const consumedAt = opts.consumed ? new Date() : null;
        (prisma.adminDeleteConfirmation.findFirst as jest.Mock).mockResolvedValue({
            id: 11,
            code: hashPassword(CODE_PLAINTEXT),
            adminId: ADMIN.id,
            targetUserId: TARGET.id,
            expiresAt,
            consumedAt,
        });
    }

    function setupCommonMocks() {
        // user.findUnique returns admin first, target second. All other methods
        // (counts → 0, findMany → [], deleteMany/update/etc → mocks) come from the
        // Proxy defaults in Task 5. tx.<model> shares the same backing mocks as
        // prisma.<model> because $transaction passes the proxy itself.
        (prisma.user.findUnique as jest.Mock)
            .mockResolvedValueOnce(ADMIN)
            .mockResolvedValueOnce(TARGET);
    }

    it('happy path: marks confirmation consumed, runs cascade, logs success', async () => {
        setupCommonMocks();
        setupConfirmation();
        const result = await service.confirmAdminDelete(ADMIN.id, TARGET.id, 'admin-pw', CODE_PLAINTEXT);
        expect(result).toMatchObject({ success: true, data: { message: expect.any(String) } });
        expect(prisma.adminDeleteConfirmation.update).toHaveBeenCalledWith(
            expect.objectContaining({ where: { id: 11 }, data: expect.objectContaining({ consumedAt: expect.any(Date) }) }),
        );
        expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: TARGET.id } });
        expect(prisma.adminDeleteAttempt.create).toHaveBeenCalledWith({ data: { adminId: ADMIN.id, success: true } });
    });

    it('throws CANNOT_DELETE_SELF', async () => {
        await expect(service.confirmAdminDelete(7, 7, 'pw', '000000')).rejects.toMatchObject({
            response: { error: { code: 'CANNOT_DELETE_SELF' } },
        });
    });

    it('throws INVALID_PASSWORD and logs failed attempt', async () => {
        setupCommonMocks();
        await expect(service.confirmAdminDelete(ADMIN.id, TARGET.id, 'wrong', CODE_PLAINTEXT)).rejects.toMatchObject({
            status: 401,
            response: { error: { code: 'INVALID_PASSWORD' } },
        });
        expect(prisma.adminDeleteAttempt.create).toHaveBeenCalledWith({ data: { adminId: ADMIN.id, success: false } });
    });

    it('throws INVALID_CODE when no confirmation row exists', async () => {
        setupCommonMocks();
        (prisma.adminDeleteConfirmation.findFirst as jest.Mock).mockResolvedValue(null);
        await expect(service.confirmAdminDelete(ADMIN.id, TARGET.id, 'admin-pw', CODE_PLAINTEXT)).rejects.toMatchObject({
            status: 401,
            response: { error: { code: 'INVALID_CODE' } },
        });
    });

    it('throws INVALID_CODE when code does not match', async () => {
        setupCommonMocks();
        setupConfirmation();
        await expect(service.confirmAdminDelete(ADMIN.id, TARGET.id, 'admin-pw', '999999')).rejects.toMatchObject({
            status: 401,
            response: { error: { code: 'INVALID_CODE' } },
        });
    });

    it('throws CODE_EXPIRED for expired confirmation; does NOT log failed attempt', async () => {
        setupCommonMocks();
        setupConfirmation({ expired: true });
        await expect(service.confirmAdminDelete(ADMIN.id, TARGET.id, 'admin-pw', CODE_PLAINTEXT)).rejects.toMatchObject({
            status: 410,
            response: { error: { code: 'CODE_EXPIRED' } },
        });
        // ensure no failed-attempt logged for expired
        const failedCalls = (prisma.adminDeleteAttempt.create as jest.Mock).mock.calls.filter(
            (c) => c[0]?.data?.success === false,
        );
        expect(failedCalls).toHaveLength(0);
    });
});
```

- [ ] **Step 2: Run — expect failure**

```bash
npx jest src/users/users.service.spec.ts -t confirmAdminDelete
```

Expected: tests fail.

- [ ] **Step 3: Implement**

In `src/users/users.service.ts`:

```ts
async confirmAdminDelete(
    adminId: number,
    targetUserId: number,
    password: string,
    confirmationCode: string,
): Promise<ServiceResponse> {
    await this.enforceAdminDeleteRateLimit(adminId);
    this.assertNotSelf(adminId, targetUserId);

    const admin = await this.prisma.user.findUnique({ where: { id: adminId } });
    if (!admin) {
        throw new UnauthorizedException(createErrorResponse('Not authenticated', 'NOT_AUTHENTICATED'));
    }
    if (!comparePasswords(password, admin.password)) {
        await this.prisma.adminDeleteAttempt.create({ data: { adminId, success: false } });
        throw new UnauthorizedException(createErrorResponse('Invalid password.', 'INVALID_PASSWORD'));
    }

    const target = await this.prisma.user.findUnique({ where: { id: targetUserId } });
    if (!target) {
        throw new NotFoundException(createErrorResponse('User not found', 'USER_NOT_FOUND'));
    }
    await this.assertNotLastAdmin(this.prisma, target);

    const confirmation = await this.prisma.adminDeleteConfirmation.findFirst({
        where: { adminId, targetUserId, consumedAt: null },
        orderBy: { createdAt: 'desc' },
    });
    if (!confirmation) {
        await this.prisma.adminDeleteAttempt.create({ data: { adminId, success: false } });
        throw new UnauthorizedException(createErrorResponse('Invalid confirmation code.', 'INVALID_CODE'));
    }
    if (isPastUTC(confirmation.expiresAt)) {
        // Expired — do NOT count as a failed attempt (it's user error, not an attack signal)
        throw new HttpException(
            createErrorResponse('Confirmation code has expired.', 'CODE_EXPIRED'),
            HttpStatus.GONE,
        );
    }
    if (!comparePasswords(confirmationCode, confirmation.code)) {
        await this.prisma.adminDeleteAttempt.create({ data: { adminId, success: false } });
        throw new UnauthorizedException(createErrorResponse('Invalid confirmation code.', 'INVALID_CODE'));
    }

    // Pre-read file paths for post-commit unlink (both directly-owned and reachable through comments)
    const directFiles = await this.prisma.file.findMany({
        where: { userId: targetUserId },
        select: { path: true },
    });
    const userDiscussions = await this.prisma.discussion.findMany({
        where: { userId: targetUserId },
        select: { id: true },
    });
    const discussionIds = userDiscussions.map((d) => d.id);
    let commentFiles: { path: string }[] = [];
    if (discussionIds.length > 0) {
        const comments = await this.prisma.comment.findMany({
            where: { discussionId: { in: discussionIds } },
            select: { id: true },
        });
        const commentIds = comments.map((c) => c.id);
        if (commentIds.length > 0) {
            commentFiles = await this.prisma.file.findMany({
                where: { commentId: { in: commentIds } },
                select: { path: true },
            });
        }
    }
    const allPaths = [...directFiles, ...commentFiles].map((f) => f.path);

    await this.prisma.$transaction(async (tx) => {
        await tx.adminDeleteConfirmation.update({
            where: { id: confirmation.id },
            data: { consumedAt: nowUTC() },
        });
        await this.cascadeDeleteUserInTx(tx, targetUserId);
    });

    // Post-commit best-effort cleanup
    this.unlinkPathsBestEffort(allPaths);
    try {
        await this.destroyUserSessions(targetUserId);
    } catch (err) {
        this.logger.warn(`Failed to destroy sessions for deleted user ${targetUserId}: ${(err as Error).message}`);
    }
    await this.prisma.adminDeleteAttempt.create({ data: { adminId, success: true } });

    return createSuccessResponse({ message: 'User deleted successfully.' });
}
```

Add the `isPastUTC` import to the existing utils import line:

```ts
import { addMinutesUTC, comparePasswords, hashPassword, isPastUTC, nowUTC } from '~/utils';
```

- [ ] **Step 4: Run — expect pass**

```bash
npx jest src/users/users.service.spec.ts -t confirmAdminDelete
```

Expected: 6 tests pass.

- [ ] **Step 5: Run all UserService tests to make sure nothing regressed**

```bash
npx jest src/users/users.service.spec.ts
```

Expected: full suite green.

- [ ] **Step 6: Commit**

```bash
git add src/users/users.service.ts src/users/users.service.spec.ts
git commit -m "feat(users): add confirmAdminDelete service method"
```

---

## Task 14: Daily cleanup cron

**Files:**
- Modify: `src/users/users.service.ts`
- Modify: `src/users/users.service.spec.ts`

- [ ] **Step 1: Add failing test**

```ts
describe('cleanupAdminDeleteRows', () => {
    it('deletes confirmations older than 7 days and attempts older than 30 days', async () => {
        prisma.adminDeleteConfirmation = { ...prisma.adminDeleteConfirmation, deleteMany: jest.fn() } as any;
        prisma.adminDeleteAttempt = { ...prisma.adminDeleteAttempt, deleteMany: jest.fn() } as any;
        await service.cleanupAdminDeleteRows();
        expect(prisma.adminDeleteConfirmation.deleteMany).toHaveBeenCalledWith({
            where: { expiresAt: { lt: expect.any(Date) } },
        });
        expect(prisma.adminDeleteAttempt.deleteMany).toHaveBeenCalledWith({
            where: { createdAt: { lt: expect.any(Date) } },
        });
    });
});
```

- [ ] **Step 2: Run — expect failure**

```bash
npx jest src/users/users.service.spec.ts -t cleanupAdminDeleteRows
```

Expected: FAIL.

- [ ] **Step 3: Implement**

In `src/users/users.service.ts`:

```ts
import { Cron, CronExpression } from '@nestjs/schedule';

// ...

@Cron(CronExpression.EVERY_DAY_AT_3AM)
async cleanupAdminDeleteRows(): Promise<void> {
    const sevenDaysAgo = addDaysUTC(nowUTC(), -7);
    const thirtyDaysAgo = addDaysUTC(nowUTC(), -30);
    await this.prisma.adminDeleteConfirmation.deleteMany({
        where: { expiresAt: { lt: sevenDaysAgo } },
    });
    await this.prisma.adminDeleteAttempt.deleteMany({
        where: { createdAt: { lt: thirtyDaysAgo } },
    });
}
```

(Add `addDaysUTC` to the utils import if not already present.)

- [ ] **Step 4: Run — expect pass**

```bash
npx jest src/users/users.service.spec.ts -t cleanupAdminDeleteRows
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/users/users.service.ts src/users/users.service.spec.ts
git commit -m "feat(users): add daily cleanup cron for admin-delete tables"
```

---

## Task 15: Controller routes + bonus self-only fix

**Files:**
- Modify: `src/users/users.controller.ts`

- [ ] **Step 1: Add the new routes and the bonus authorization fix**

Replace the existing `deleteProfile` method to add the self-only check, and add the two new routes immediately below it.

Find:

```ts
    @Delete(':id')
    @ApiCookieAuth()
    @ApiOperation({
        summary: 'Deactivate user profile',
        description: 'Soft deletes the user profile by setting isActive to false',
    })
    @ApiResponse({
        status: 200,
        description: 'User profile deactivated successfully',
    })
    @ApiResponse({
        status: 400,
        description: 'User is already deactivated',
    })
    @ApiResponse({
        status: 404,
        description: 'User not found',
    })
    async deleteProfile(@Param('id', ParseIntPipe) id: number) {
        return this.userService.deleteProfile(id);
    }
```

Replace with:

```ts
    @Delete(':id')
    @ApiCookieAuth()
    @ApiOperation({
        summary: 'Deactivate user profile (self-only)',
        description: 'Soft-deletes the requesting user by setting isActive=false. Cannot be used to deactivate other users.',
    })
    @ApiResponse({ status: 200, description: 'User profile deactivated successfully' })
    @ApiResponse({ status: 400, description: 'User is already deactivated' })
    @ApiResponse({ status: 403, description: 'Cannot deactivate another user' })
    @ApiResponse({ status: 404, description: 'User not found' })
    async deleteProfile(@Param('id', ParseIntPipe) id: number, @Req() req: Request) {
        if (id !== req.user!.id) {
            throw new ForbiddenException(
                createErrorResponse('You can only deactivate your own account.', 'FORBIDDEN_NOT_SELF'),
            );
        }
        return this.userService.deleteProfile(id);
    }

    @Post(':id/delete-confirmation')
    @Roles(Role.ADMIN)
    @ApiCookieAuth()
    @ApiOperation({
        summary: 'Step 1 — initiate admin hard-delete: verify password, email confirmation code',
    })
    @ApiParam({ name: 'id', description: 'Target user ID', type: 'number' })
    @ApiBody({ type: InitiateAdminDeleteDto })
    @ApiResponse({ status: 200, description: 'Confirmation code emailed to admin' })
    @ApiResponse({ status: 400, description: 'CANNOT_DELETE_SELF' })
    @ApiResponse({ status: 401, description: 'INVALID_PASSWORD' })
    @ApiResponse({ status: 404, description: 'USER_NOT_FOUND' })
    @ApiResponse({ status: 409, description: 'LAST_ADMIN' })
    @ApiResponse({ status: 429, description: 'TOO_MANY_ATTEMPTS' })
    @ApiResponse({ status: 503, description: 'MAIL_SEND_FAILED' })
    async initiateAdminDelete(
        @Param('id', ParseIntPipe) id: number,
        @Body() dto: InitiateAdminDeleteDto,
        @Req() req: Request,
    ) {
        return this.userService.initiateAdminDelete(req.user!.id, id, dto.password);
    }

    @Post(':id/hard-delete')
    @Roles(Role.ADMIN)
    @ApiCookieAuth()
    @ApiOperation({
        summary: 'Step 2 — confirm admin hard-delete: cascade-delete user and all dependents',
    })
    @ApiParam({ name: 'id', description: 'Target user ID', type: 'number' })
    @ApiBody({ type: ConfirmAdminDeleteDto })
    @ApiResponse({ status: 200, description: 'User permanently deleted' })
    @ApiResponse({ status: 400, description: 'CANNOT_DELETE_SELF' })
    @ApiResponse({ status: 401, description: 'INVALID_PASSWORD or INVALID_CODE' })
    @ApiResponse({ status: 404, description: 'USER_NOT_FOUND' })
    @ApiResponse({ status: 409, description: 'LAST_ADMIN' })
    @ApiResponse({ status: 410, description: 'CODE_EXPIRED' })
    @ApiResponse({ status: 429, description: 'TOO_MANY_ATTEMPTS' })
    async confirmAdminDelete(
        @Param('id', ParseIntPipe) id: number,
        @Body() dto: ConfirmAdminDeleteDto,
        @Req() req: Request,
    ) {
        return this.userService.confirmAdminDelete(req.user!.id, id, dto.password, dto.confirmationCode);
    }
```

- [ ] **Step 2: Add new imports at the top of `users.controller.ts`**

Add to the existing import block:

```ts
import { ForbiddenException } from '@nestjs/common';
import { InitiateAdminDeleteDto } from './dtos/initiate-admin-delete.dto';
import { ConfirmAdminDeleteDto } from './dtos/confirm-admin-delete.dto';
import { createErrorResponse } from '~/utils/responses';
```

- [ ] **Step 3: Run typecheck**

```bash
npx tsc --noEmit
```

Expected: no errors.

- [ ] **Step 4: Run full test suite**

```bash
npx jest
```

Expected: every test passes.

- [ ] **Step 5: Commit**

```bash
git add src/users/users.controller.ts
git commit -m "feat(users): add admin hard-delete routes and self-only soft-delete check"
```

---

## Task 16: Verify Mailer is wired into UserModule

**Files:**
- Read: `src/users/users.module.ts`
- Modify: `src/users/users.module.ts` (only if needed)

`MailerModule.forRoot()` in `app.module.ts` exports `MailerService` globally; injection into `UserService` should work without further wiring. This task verifies and only changes `users.module.ts` if the build proves otherwise.

- [ ] **Step 1: Boot the app and verify the module compiles at runtime**

```bash
npm run build && npm start
```

Expected: app starts successfully, no `Nest can't resolve dependencies of the UserService (?, ..., MailerService, ConfigService)` errors. Stop the server (`Ctrl+C`) once the "Application started" line appears.

- [ ] **Step 2: If startup failed with DI error, import MailerModule into UserModule**

Only if the previous step failed. In `src/users/users.module.ts`:

```ts
import { MailerModule } from '@nestjs-modules/mailer';

@Module({
    imports: [PrismaModule, FilesModule, MailerModule],
    // ... rest unchanged
})
```

- [ ] **Step 3: Re-run startup**

```bash
npm start
```

Expected: clean start.

- [ ] **Step 4: Commit (only if Task 16 Step 2 was needed)**

```bash
git add src/users/users.module.ts
git commit -m "chore(users): explicitly import MailerModule into UserModule"
```

---

## Task 17: Manual end-to-end verification

The cascade-correctness tests are unit tests against mocks. Real-DB cascade correctness is verified manually here.

- [ ] **Step 1: Seed two test users in dev**

Use Prisma Studio (`npx prisma studio`) or the existing registration endpoint to create:
- `admin@local.test` with role `ADMIN` (manually update role in Studio after registration)
- `target@local.test` with role `STAKEHOLDER`, plus add at least one of: a training session they own, a blog post they authored with one comment, a discussion they started with one comment that has a file attachment, a notification, a favorite training module.

- [ ] **Step 2: Log in as admin via the existing login endpoint**

POST to `http://localhost:3000/api/v1/auth/login` with the admin credentials. Capture the session cookie.

- [ ] **Step 3: Hit step 1**

```bash
curl -X POST 'http://localhost:3000/api/v1/users/<targetId>/delete-confirmation' \
  -H 'Content-Type: application/json' \
  -b 'ecom.sid=<cookie>' \
  -d '{"password":"<admin-password>"}'
```

Expected: 200 with `dependencies` summary reflecting the records you seeded; an email arrives at `admin@local.test` (check inbox or, if running locally, check whatever local SMTP catcher is in use); the response body does NOT contain a `code` or `confirmationCode` field.

- [ ] **Step 4: Hit step 2 with the wrong code**

```bash
curl -X POST 'http://localhost:3000/api/v1/users/<targetId>/hard-delete' \
  -H 'Content-Type: application/json' \
  -b 'ecom.sid=<cookie>' \
  -d '{"password":"<admin-password>","confirmationCode":"000000"}'
```

Expected: 401 `INVALID_CODE`; one new row in `admin_delete_attempts` with `success=0`; the target user still exists in `users` table.

- [ ] **Step 5: Hit step 2 with the correct code from the email**

```bash
curl -X POST 'http://localhost:3000/api/v1/users/<targetId>/hard-delete' \
  -H 'Content-Type: application/json' \
  -b 'ecom.sid=<cookie>' \
  -d '{"password":"<admin-password>","confirmationCode":"<code-from-email>"}'
```

Expected: 200 success; target user gone from `users`; all of: their training session, blog post, blog post's comment, discussion, discussion's comment, comment's file (DB row), notification, favorite — all gone. The physical file under `public/uploads/` for the comment attachment is also removed.

- [ ] **Step 6: Verify session destruction**

If you logged in as the target user in another browser or curl session before deletion, the session cookie should now be invalid: `GET /api/v1/auth/me` with the target's cookie should return 401.

- [ ] **Step 7: Verify the bonus fix**

```bash
curl -X DELETE 'http://localhost:3000/api/v1/users/<otherUserId>' \
  -b 'ecom.sid=<admin-cookie>'
```

Expected: 403 `FORBIDDEN_NOT_SELF` (admin trying to soft-delete a non-self).

```bash
curl -X DELETE 'http://localhost:3000/api/v1/users/<adminId>' \
  -b 'ecom.sid=<admin-cookie>'
```

Expected: 200 success — admin soft-deleted themselves (existing behavior preserved).

- [ ] **Step 8: Verify rate limit**

Send five wrong-password POSTs to step 1 in rapid succession. The sixth should return 429 `TOO_MANY_ATTEMPTS` with a `retryAfter` value in the body.

- [ ] **Step 9: If anything failed, fix in a follow-up commit**

If any step above produced unexpected output, debug and patch — small follow-up commits are acceptable on top of the feature commits.

---

## Spec coverage check (before merging)

Walk through `docs/superpowers/specs/2026-04-28-admin-delete-user-design.md` section by section and confirm:

- [ ] Architecture — implemented in Tasks 5, 10, 13 (UserService methods + DI).
- [ ] Data model — Task 1.
- [ ] API contract step 1 — Task 10 (service) + Task 15 (route).
- [ ] API contract step 2 — Task 13 (service) + Task 15 (route).
- [ ] Cascade order — Task 11 with code comment as authoritative reference.
- [ ] Rate limiting — Task 8.
- [ ] Email template — Task 4.
- [ ] Maintenance cron — Task 14.
- [ ] Bonus self-only fix — Task 15.
- [ ] Testing — Tasks 5–14 (unit tests with mocks) + Task 17 (manual e2e for cascade correctness).
- [ ] Open question on session-data structure — confirmed via Task 12 fixture and Task 17 step 6.
