feat: allow user deletion — FK constraints ON DELETE SET NULL

- schema.sql: all user_id FKs now ON DELETE SET NULL (was RESTRICT)
- migrate_user_fk_set_null.py: drop+readd constraints on existing DBs
- entrypoint.sh: runs migration automatically on startup (non-fatal)
- db.py: rollback moved before FK check (cleanup)

Deleting a user now nullifies their references in audit_log,
project_context, context_files, change_requests, reviews instead of
blocking with a 409.
This commit is contained in:
2026-06-25 14:43:58 +00:00
parent bc43e9a8d1
commit 9bb89ee62f
4 changed files with 70 additions and 6 deletions
+1 -2
View File
@@ -190,11 +190,10 @@ def user_delete(conn, user_id: str) -> dict:
conn.execute(f"DELETE FROM users WHERE user_id = {ph}", (user_id,))
return {"ok": True}
except (sqlite3.IntegrityError, Exception) as e:
# Check if it's a foreign key violation
if _is_pg(conn):
import psycopg
conn.rollback()
if isinstance(e, psycopg.errors.ForeignKeyViolation):
conn.rollback() # clear aborted transaction state
return {"ok": False, "error": "user_has_references", "hint": "Inactivate the user instead of deleting."}
raise
return {"ok": False, "error": "user_has_references", "hint": "Inactivate the user instead of deleting."}
+62
View File
@@ -0,0 +1,62 @@
"""
Migration: alter user FK constraints to ON DELETE SET NULL.
PostgreSQL doesn't support ALTER CONSTRAINT inline — you must drop and
re-add the constraint. This script does that for all user_id FKs that
were originally created as RESTRICT (no ON DELETE action).
Run once against the production database:
docker exec ctxd python3 -m ctxd.migrate_user_fk_set_null
Safe to run multiple times (skips if constraint already has ON DELETE SET NULL).
"""
import os
import psycopg
# Tables + constraints to fix. constraint_name is the auto-generated PG name.
FKS_TO_FIX = [
("project_context", "project_context_updated_by_fkey"),
("context_files", "context_files_updated_by_fkey"),
("change_requests", "change_requests_submitted_by_fkey"),
("reviews", "reviews_reviewer_id_fkey"),
("audit_log", "audit_log_user_id_fkey"),
]
def main():
url = os.environ.get("DATABASE_URL")
if not url:
print("DATABASE_URL not set — nothing to do (SQLite mode)")
return
conn = psycopg.connect(url)
conn.autocommit = True
for table, constraint in FKS_TO_FIX:
# Check if the constraint exists and what its current ON DELETE action is
row = conn.execute(
"""SELECT confdeltype FROM pg_constraint
WHERE conname = %s AND contype = 'f'""",
(constraint,),
).fetchone()
if not row:
print(f" SKIP {constraint}: not found (already migrated or table missing)")
continue
if row[0] == "n": # 'n' = SET NULL
print(f" SKIP {constraint}: already ON DELETE SET NULL")
continue
col = constraint.replace(f"{table}_", "").replace("_fkey", "")
print(f" ALTER {table}.{col} ({constraint}): {row[0]} -> SET NULL")
conn.execute(f'ALTER TABLE {table} DROP CONSTRAINT {constraint}')
conn.execute(
f'ALTER TABLE {table} ADD CONSTRAINT {constraint} '
f'FOREIGN KEY ({col}) REFERENCES users(user_id) ON DELETE SET NULL'
)
print(f" DONE {constraint}")
conn.close()
print("Migration complete.")
if __name__ == "__main__":
main()
+4 -4
View File
@@ -65,7 +65,7 @@ CREATE TABLE project_context (
project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE,
content TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 0,
updated_by TEXT REFERENCES users(user_id),
updated_by TEXT REFERENCES users(user_id) ON DELETE SET NULL,
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
);
@@ -124,7 +124,7 @@ CREATE TABLE change_requests (
request_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(project_id),
submitted_by TEXT NOT NULL REFERENCES users(user_id),
submitted_by TEXT NOT NULL REFERENCES users(user_id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'merged')),
diff_summary TEXT,
@@ -140,7 +140,7 @@ CREATE TABLE change_requests (
CREATE TABLE reviews (
review_id SERIAL PRIMARY KEY,
request_id TEXT NOT NULL REFERENCES change_requests(request_id) ON DELETE CASCADE,
reviewer_id TEXT NOT NULL REFERENCES users(user_id),
reviewer_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE SET NULL,
decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')),
comments TEXT,
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
@@ -171,7 +171,7 @@ CREATE INDEX idx_snapshots_cleanup ON snapshots (project_id, user_id, created_at
-- ============================================================================
CREATE TABLE audit_log (
entry_id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id),
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE SET NULL,
agent_id TEXT NOT NULL DEFAULT 'cli',
session_id TEXT,
project_id TEXT REFERENCES projects(project_id) ON DELETE SET NULL,