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 packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .pii import bp as pii_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +19,4 @@ def register_routes(app: Flask):
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(pii_bp, url_prefix="/pii")
85 changes: 85 additions & 0 deletions packages/backend/app/routes/pii.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""
PII Export & Delete routes — GDPR-ready endpoints.

Endpoints:
GET /pii/export — Download a JSON export of all personal data
POST /pii/delete — Irreversibly delete all personal data (requires confirmation)
"""

import json
import logging
from flask import Blueprint, jsonify, request, Response
from flask_jwt_extended import jwt_required, get_jwt_identity

from ..services.pii import export_user_data, delete_user_data

bp = Blueprint("pii", __name__)
logger = logging.getLogger("finmind.pii")


@bp.get("/export")
@jwt_required()
def export_data():
"""
Export all personal data as a downloadable JSON file.

Returns a JSON package containing:
- User profile (excluding password hash)
- Categories, expenses, recurring expenses
- Bills, reminders
- Ad impressions, subscriptions
- Audit logs

Response: 200 with JSON attachment, or 404 if user not found.
"""
user_id = get_jwt_identity()
data = export_user_data(user_id)

if data is None:
return jsonify(error="User not found"), 404

# Return as downloadable JSON file
json_bytes = json.dumps(data, indent=2).encode("utf-8")
return Response(
json_bytes,
mimetype="application/json",
headers={
"Content-Disposition": f"attachment; filename=finmind-export-user-{user_id}.json",
"Content-Length": str(len(json_bytes)),
},
)


@bp.post("/delete")
@jwt_required()
def delete_data():
"""
Irreversibly delete all personal data.

Requires a JSON body with:
{"confirm": true}

This action cannot be undone. All user data including the account itself
will be permanently removed. An anonymized audit trail entry is preserved
for compliance purposes.

Response: 200 with deletion summary, 400 if not confirmed, 404 if user not found.
"""
user_id = get_jwt_identity()

body = request.get_json(silent=True) or {}
if not body.get("confirm"):
return jsonify(
error="Deletion requires explicit confirmation. Send {\"confirm\": true} to proceed."
), 400

summary = delete_user_data(user_id)

if summary is None:
return jsonify(error="User not found"), 404

logger.info("User %d data deletion completed", user_id)
return jsonify(
message="All personal data has been permanently deleted.",
deleted=summary,
), 200
169 changes: 169 additions & 0 deletions packages/backend/app/services/pii.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""
PII Export & Delete Service — GDPR-ready data portability and erasure.

Provides:
- Export: generates a JSON package of all user PII and associated data
- Delete: irreversibly removes all user data with audit trail
"""

import json
import logging
from datetime import datetime, date
from decimal import Decimal

from ..extensions import db
from ..models import (
User,
Category,
Expense,
RecurringExpense,
Bill,
Reminder,
AdImpression,
UserSubscription,
AuditLog,
)

logger = logging.getLogger("finmind.pii")


class _JSONEncoder(json.JSONEncoder):
"""Handle datetime, date, and Decimal serialization."""

def default(self, obj):
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, date):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj)
return super().default(obj)


def _serialize_row(row, exclude_cols=None):
"""Convert a SQLAlchemy model instance to a dict."""
exclude = set(exclude_cols or [])
result = {}
for col in row.__table__.columns:
if col.name in exclude:
continue
result[col.name] = getattr(row, col.name)
return result


def export_user_data(user_id: int) -> dict:
"""
Generate a complete PII export package for the given user.

Returns a dict containing all user data, suitable for JSON serialization.
The export includes: profile, categories, expenses, recurring expenses,
bills, reminders, ad impressions, subscriptions, and audit logs.
"""
user = db.session.get(User, user_id)
if not user:
return None

# Collect all user data
categories = Category.query.filter_by(user_id=user_id).all()
expenses = Expense.query.filter_by(user_id=user_id).all()
recurring = RecurringExpense.query.filter_by(user_id=user_id).all()
bills = Bill.query.filter_by(user_id=user_id).all()
reminders = Reminder.query.filter_by(user_id=user_id).all()
ad_impressions = AdImpression.query.filter_by(user_id=user_id).all()
subscriptions = UserSubscription.query.filter_by(user_id=user_id).all()
audit_logs = AuditLog.query.filter_by(user_id=user_id).all()

export = {
"export_version": "1.0",
"exported_at": datetime.utcnow().isoformat(),
"user": _serialize_row(user, exclude_cols=["password_hash"]),
"categories": [_serialize_row(c) for c in categories],
"expenses": [_serialize_row(e) for e in expenses],
"recurring_expenses": [_serialize_row(r) for r in recurring],
"bills": [_serialize_row(b) for b in bills],
"reminders": [_serialize_row(r) for r in reminders],
"ad_impressions": [_serialize_row(a) for a in ad_impressions],
"subscriptions": [_serialize_row(s) for s in subscriptions],
"audit_logs": [_serialize_row(a) for a in audit_logs],
}

# Log the export action
db.session.add(
AuditLog(user_id=user_id, action="pii_export")
)
db.session.commit()

logger.info("PII export generated for user %d", user_id)
return json.loads(json.dumps(export, cls=_JSONEncoder))


def delete_user_data(user_id: int) -> dict:
"""
Irreversibly delete all personal data for the given user.

Deletion order respects foreign key constraints.
An audit trail entry is created *before* user deletion (with user_id=None
post-deletion to preserve the log).

Returns a summary dict with counts of deleted records per table.
"""
user = db.session.get(User, user_id)
if not user:
return None

summary = {}

# Delete in dependency order (children first)
# 1. Reminders (depends on bills)
count = Reminder.query.filter_by(user_id=user_id).delete()
summary["reminders"] = count

# 2. Expenses (depends on categories, recurring_expenses)
count = Expense.query.filter_by(user_id=user_id).delete()
summary["expenses"] = count

# 3. Recurring expenses (depends on categories)
count = RecurringExpense.query.filter_by(user_id=user_id).delete()
summary["recurring_expenses"] = count

# 4. Bills
count = Bill.query.filter_by(user_id=user_id).delete()
summary["bills"] = count

# 5. Categories
count = Category.query.filter_by(user_id=user_id).delete()
summary["categories"] = count

# 6. Ad impressions
count = AdImpression.query.filter_by(user_id=user_id).delete()
summary["ad_impressions"] = count

# 7. User subscriptions
count = UserSubscription.query.filter_by(user_id=user_id).delete()
summary["subscriptions"] = count

# 8. Audit logs for this user (except the deletion record we're about to create)
count = AuditLog.query.filter_by(user_id=user_id).delete()
summary["audit_logs"] = count

# 9. Create deletion audit trail (user_id=None since user will be gone)
email_hash = str(hash(user.email)) # pseudonymized reference
db.session.add(
AuditLog(
user_id=None,
action=f"pii_delete:user_hash={email_hash}",
)
)

# 10. Delete the user record itself
db.session.delete(user)
summary["user"] = 1

db.session.commit()

logger.info(
"PII deletion completed for user %d: %s",
user_id,
summary,
)
return summary
Loading