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:
+1
-2
@@ -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."}
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user