GDPR
Petal Pro ships with a built-in GDPR compliance system covering two user rights: the right to data portability (export) and the right to erasure (account deletion). Both are exposed to users through Settings > Your Data and processed by PetalPro.Accounts.GDPR.
Enabling GDPR mode
The system is gated behind the :gdpr_mode config key. When disabled, the Settings > Your Data page is hidden from users.
# config/config.exs
config :petal_pro, :gdpr_mode, true
Check programmatically with:
PetalPro.gdpr_mode()
# => true
Data export
What users see
Users visit Settings > Your Data (/app/users/your-data) and click “Download my data”. The browser receives a JSON file generated on the fly.
What’s included
PetalPro.Accounts.GDPR.export_user_data/1 assembles a single map with these top-level keys:
| Key | Contents |
|---|---|
exported_at |
ISO 8601 timestamp |
user |
Name, email, avatar, role, onboarding status, timestamps |
organizations |
Org name, slug, membership role, join date |
notifications |
Up to 1 000 most recent (truncated flag if capped) |
notification_preferences |
Per-type in-app/email toggles |
billing |
Masked provider customer ID, subscription history |
posts |
Post titles, slugs, categories, publish dates |
files |
File names and URLs |
feedback |
User’s own submitted feedback items |
ai_usage |
AI call log entries — provider, model, token counts, cost |
activity_log |
Audit log entries attributed to the user |
changelog_interactions |
Changelog reads and likes |
Security-sensitive data is deliberately excluded: password hashes, auth tokens, TOTP secrets, and PINs are never exported. Billing provider IDs (Stripe customer IDs) are masked to the last four characters.
Generating the JSON
user = Repo.get!(User, user_id)
# Returns a map
data = PetalPro.Accounts.GDPR.export_user_data(user)
# Returns a pretty-printed JSON string
json = PetalPro.Accounts.GDPR.generate_export_json(user)
Adding custom data to exports
When you add a new schema that associates data with a user, update export_user_data/1 to include it. The function builds a map — just add a new key:
def export_user_data(%User{} = user) do
user = Repo.preload(user, [:memberships, :customer])
%{
exported_at: DateTime.to_iso8601(DateTime.utc_now()),
user: export_user_profile(user),
# ... existing keys ...
survey_responses: export_survey_responses(user) # your addition
}
end
defp export_survey_responses(user) do
MyApp.Surveys.SurveyResponse
|> where(user_id: ^user.id)
|> Repo.all()
|> Enum.map(fn r ->
%{
question: r.question,
answer: r.answer,
submitted_at: r.inserted_at
}
end)
end
Account deletion
How it works
Deletion is a two-step process:
- The user clicks “Delete my account” in Settings > Your Data and confirms.
-
PetalPro.Accounts.GDPR.can_delete_user?/1runs a guard check — a user cannot be deleted if they are the sole admin of any organization. -
If the check passes, an Oban job (
GDPRDeletionWorker) is enqueued. The user is informed their deletion is scheduled and the session ends shortly after. -
The worker calls
execute_data_deletion/1which runs inside a single database transaction.
What execute_data_deletion/1 does
The deletion cascade runs in this order:
- Logs the deletion — audit record created before anything is anonymized
- Deletes AI call logs — contain sensitive prompt/response data
- Deletes file records — storage object cleanup is a separate concern
-
Anonymizes feedback —
user_idset to nil, content kept -
Anonymizes posts —
author_idset to nil, posts shown as “Deleted User” - Deletes notifications — recipient notifications deleted; sender references nullified
- Deletes notification preferences
- Deletes changelog interactions — reads and likes
- Deletes auth data — tokens, PINs, TOTP configs
- Removes org memberships and invitations
-
Anonymizes audit logs —
user_idnullified, metadata cleared (record kept for compliance) -
Anonymizes billing customer — email replaced,
user_idcleared (record kept for tax/legal) -
Soft-deletes the user — name set to “Deleted User”, email replaced with
deleted-{uuid}@deleted.local,deleted_attimestamp set - Removes marketing email contact — Resend contact deleted outside the transaction; failure is logged but does not roll back the DB deletion
Adding custom deletion logic
Add cleanup for your new schema inside the transaction in execute_data_deletion/1. Choose the right strategy:
- Delete the records when they contain personal data that serves no compliance purpose.
- Nullify the foreign key when you want to keep the record but disassociate it from the user.
def execute_data_deletion(%User{} = user) do
result =
Repo.transaction(fn ->
# ... existing steps ...
# Delete records that belong to this user
MyApp.Surveys.SurveyResponse
|> where(user_id: ^user.id)
|> Repo.delete_all()
# Or anonymize by nullifying the user reference
MyApp.Comments.Comment
|> where(author_id: ^user.id)
|> Repo.update_all(set: [author_id: nil])
# ... rest of cascade ...
end)
result
end
The CLAUDE.md for
petal_proexplicitly calls this out as a required step whenever you add a new user-associated schema: update bothexport_user_data/1andexecute_data_deletion/1.