Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/src/audit/audit-action.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ export enum AuditAction {
STREAM_DELETE = "stream_delete",
ROLE_CHANGE = "role_change",
PROFILE_UPDATE = "profile_update",
/** Issue #344: recorded when a user soft-deletes their account via DELETE /users/me */
USER_DELETE = "user_delete",
}
27 changes: 22 additions & 5 deletions api/src/auth/users.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface User {
password_hash: string
created_at: Date
password_changed_at?: Date
deleted_at?: Date | null
}

/**
Expand All @@ -23,23 +24,23 @@ export class UsersRepository {

async findByEmail(email: string): Promise<User | null> {
const { rows } = await this.pool.query(
"SELECT id, username, email, password_hash, created_at, password_changed_at FROM users WHERE email = $1",
"SELECT id, username, email, password_hash, created_at, password_changed_at FROM users WHERE email = $1 AND deleted_at IS NULL",
[email],
)
return rows[0] ?? null
}

async findByUsername(username: string): Promise<User | null> {
const { rows } = await this.pool.query(
"SELECT id, username, email, password_hash, created_at, password_changed_at FROM users WHERE username = $1",
"SELECT id, username, email, password_hash, created_at, password_changed_at FROM users WHERE username = $1 AND deleted_at IS NULL",
[username],
)
return rows[0] ?? null
}

async findById(id: number): Promise<User | null> {
const { rows } = await this.pool.query(
"SELECT id, username, email, password_hash, created_at, password_changed_at FROM users WHERE id = $1",
"SELECT id, username, email, password_hash, created_at, password_changed_at FROM users WHERE id = $1 AND deleted_at IS NULL",
[id],
)
return rows[0] ?? null
Expand Down Expand Up @@ -84,7 +85,7 @@ export class UsersRepository {
const { rows } = await this.pool.query(
`UPDATE users
SET ${sets.join(", ")}
WHERE id = $${idx}
WHERE id = $${idx} AND deleted_at IS NULL
RETURNING id, username, email, password_hash, created_at, password_changed_at`,
values,
)
Expand All @@ -100,10 +101,26 @@ export class UsersRepository {
`UPDATE users
SET password_hash = $1,
password_changed_at = $2
WHERE id = $3
WHERE id = $3 AND deleted_at IS NULL
RETURNING id, username, email, password_hash, created_at, password_changed_at`,
[passwordHash, passwordChangedAt, id],
)
return rows[0]
}

/**
* Soft-delete a user by setting `deleted_at` to the current timestamp.
* Returns the updated user row, or null if the user was not found or
* was already deleted.
*/
async softDelete(id: number): Promise<User | null> {
const { rows } = await this.pool.query(
`UPDATE users
SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, username, email, password_hash, created_at, password_changed_at, deleted_at`,
[id],
)
return rows[0] ?? null
}
}
1 change: 1 addition & 0 deletions api/src/database.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe("Database Integration Tests", () => {
expect(columns).toContain("email")
expect(columns).toContain("password_hash")
expect(columns).toContain("created_at")
expect(columns).toContain("deleted_at")
})

it("has the streams table with foreign key to users", async () => {
Expand Down
23 changes: 23 additions & 0 deletions api/src/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Expand All @@ -11,6 +12,7 @@ import {
} from "@nestjs/common"
import { Throttle } from "@nestjs/throttler"
import {
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
Expand Down Expand Up @@ -88,4 +90,25 @@ export class UsersController {
req.header("authorization") ?? "",
)
}

@Delete("me")
@HttpCode(HttpStatus.NO_CONTENT)
@Throttle({ default: { limit: 3, ttl: 60000 } })
@ApiOperation({
summary: "Delete user account",
description:
"Soft-deletes the authenticated user's account. " +
"Sets a deleted_at timestamp, revokes the current token, and " +
"prevents future authentication. Compliant with GDPR right-to-erasure.",
})
@ApiNoContentResponse({
description: "Account deleted successfully.",
})
async deleteUser(@Req() req: Request): Promise<void> {
const { userId } = (req as Request & { auth: { userId: number } }).auth
return this.usersService.deleteUser(
userId,
req.header("authorization") ?? "",
)
}
}
32 changes: 32 additions & 0 deletions api/src/users/users.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ConflictException,
Injectable,
NotFoundException,
UnauthorizedException,
} from "@nestjs/common"
import { JwtService } from "@nestjs/jwt"
Expand All @@ -9,6 +10,7 @@ import { SafeUser, toSafeUser } from "../auth/auth.service"
import { TokenDenylistService } from "../auth/token-denylist.service"
import { User, UsersRepository } from "../auth/users.repository"
import { AuditService } from "../audit/audit.service"
import { AuditAction } from "../audit/audit-action.enum"
import { ChangePasswordDto } from "./dto/change-password.dto"
import { UpdateProfileDto } from "./dto/update-profile.dto"

Expand Down Expand Up @@ -109,6 +111,36 @@ export class UsersService {
}
}

/**
* Soft-delete the authenticated user (GDPR-compliant account deletion).
* Sets `deleted_at` on the user row and revokes the current token so
* the session is immediately invalidated.
*/
async deleteUser(
userId: number,
authorizationHeader: string,
): Promise<void> {
const user = await this.usersRepository.findById(userId)
if (!user) {
throw new NotFoundException("user not found")
}

const deleted = await this.usersRepository.softDelete(userId)
if (!deleted) {
throw new NotFoundException("user not found or already deleted")
}

const token = this.extractBearerToken(authorizationHeader)
await this.tokenDenylistService.revoke(token, 3600)

await this.auditService.log(
userId,
AuditAction.USER_DELETE,
{ email: user.email },
"system",
)
}

private signToken(user: User): string {
return this.jwtService.sign({
sub: user.id,
Expand Down
3 changes: 2 additions & 1 deletion database/audit_logs.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
metadata JSONB DEFAULT '{}',
ip VARCHAR(45),
created_at TIMESTAMPTZ DEFAULT NOW()
);
Expand Down
28 changes: 28 additions & 0 deletions database/migrations/2026082001_add_user_soft_delete.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Migration: 2026082001_add_user_soft_delete (DOWN)
--
-- Reverses 2026082001_add_user_soft_delete.up.sql.
--
-- WARNING: rolling back will permanently DROP the deleted_at column. Any
-- previously soft-deleted rows will become indistinguishable from active
-- users. Only roll back when you are certain no rows have been
-- soft-deleted, or when you are rebuilding the database from scratch.

BEGIN;

-- 1. Drop the index before the column it depends on.
DROP INDEX IF EXISTS idx_users_deleted_at;

-- 2. Remove the soft-delete column.
ALTER TABLE users
DROP COLUMN IF EXISTS deleted_at;

-- 3. Revert the audit_logs FK to its original state (no ON DELETE clause,
-- which is equivalent to NO ACTION in Postgres).
ALTER TABLE audit_logs
DROP CONSTRAINT IF EXISTS audit_logs_user_id_fkey;

ALTER TABLE audit_logs
ADD CONSTRAINT audit_logs_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);

COMMIT;
39 changes: 39 additions & 0 deletions database/migrations/2026082001_add_user_soft_delete.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- Migration: 2026082001_add_user_soft_delete (UP)
--
-- Addresses issue #344: users table has no soft-delete — deleted users
-- leave orphaned foreign keys.
--
-- Changes:
-- 1. Drop the existing audit_logs FK and re-create it with
-- ON DELETE SET NULL so deleting a user (or soft-deleting) does
-- not leave orphaned audit_log rows referencing a stale user_id.
-- 2. Add `users.deleted_at TIMESTAMPTZ` for GDPR-compliant soft-delete.
-- 3. Add a covering index on `deleted_at` so `WHERE deleted_at IS NULL`
-- filters remain index-friendly as the table grows.

BEGIN;

-- 1. Fix audit_logs FK so it does not block user deletion.
-- The auto-generated constraint name follows the Postgres convention
-- `<table>_<column>_fkey`.
ALTER TABLE audit_logs
DROP CONSTRAINT IF EXISTS audit_logs_user_id_fkey;

ALTER TABLE audit_logs
ADD CONSTRAINT audit_logs_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;

-- 2. Add soft-delete column.
-- NULL means "not deleted" — the vast majority of rows will stay NULL,
-- keeping the index lean and writes fast.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;

-- 3. Index to keep soft-delete-aware queries efficient.
-- Partial index on non-deleted rows keeps the B-tree small since most
-- rows never receive a deleted_at value.
CREATE INDEX IF NOT EXISTS idx_users_deleted_at
ON users(deleted_at)
WHERE deleted_at IS NOT NULL;

COMMIT;
1 change: 1 addition & 0 deletions database/migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ psql -d "$DATABASE_URL" -f database/migrations/2026051501_add_stream_tags.down.s
| `2026072501_add_composite_stream_events_index.up.sql` | `idx_stream_events_stream_id_created_at_desc` — composite index for the `WHERE stream_id = ? ORDER BY created_at DESC` query pattern |
| `2026072801_alter_timestamp_to_timestamptz.up.sql` | Converts all `TIMESTAMP` columns to `TIMESTAMPTZ` across every table; rewrites `DEFAULT CURRENT_TIMESTAMP` → `DEFAULT NOW()` |
| `2026080501_add_stream_visibility.up.sql` | `streams.visibility` (`public` \| `private`, default `private`), CHECK constraint, supporting index |
| `2026082001_add_user_soft_delete.up.sql` | `users.deleted_at`, `idx_users_deleted_at`, `audit_logs.user_id` FK changed to `ON DELETE SET NULL` |

> **Note on `2026061001` / `2026061002`:** both migrations add the same
> `users.password_hash` column. `2026061001_add_password_hash` is the
Expand Down
12 changes: 9 additions & 3 deletions database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ CREATE TABLE IF NOT EXISTS users (
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
created_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS idx_users_deleted_at
ON users(deleted_at)
WHERE deleted_at IS NOT NULL;

-- Streams table
CREATE TABLE IF NOT EXISTS streams (
id SERIAL PRIMARY KEY,
Expand Down Expand Up @@ -184,10 +189,11 @@ CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_pending_next_attempt

CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
metadata JSONB DEFAULT '{}',
ip VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
Expand Down
Loading