Internal Documentation internal
TalkIDE internal documentation

Authenticated user submits an account deletion request with password re-authentication. A 14-day grace period applies before permanent data purge. An undo link is sent by email. A scheduled job purges data after the grace period expires. Implements GDPR Article 17 — Right to Erasure.

  • Deletion requires password re-authentication (typing the current password in the modal) to prevent accidental or unauthorized requests.
  • The user must also retype the word “DELETE” to confirm intent.
  • After submitting, the user is immediately logged out (refresh token invalidated server-side) and cannot log in again until the grace period expires or they undo the request.
  • Login attempts during the grace period return 403 Forbidden with a message indicating the undo deadline.
  • The undo link in the email contains a random secure token (deletion_undo_token). Following the link cancels the deletion without requiring login.
  • Hard purge runs as a nightly scheduled job (cron: daily at 03:00 UTC, @Scheduled(cron = "0 0 3 * * *")). It processes all users where deletion_scheduled_at < now().
  • Hard purge cascade: users table row, projects, conversations, messages, usage_events, budget, topups, gdpr_export_request, cluster B schemas (per-project), K8s Deployments/Ingresses for published apps.
  • Audit events are logged at INFO level with structured keys: gdpr.delete.requested, gdpr.delete.cancelled, gdpr.delete.purged.
  • Email sending (undo link + purge confirmation) uses Mailgun. See ADR-025: Transactional Email via Mailgun. If Mailgun is in stub mode, see limitations.md — Transactional E-mail.
  • Invoices are retained in Stripe for 7 years per Czech tax law — purge does NOT delete Stripe invoice records; only the local users row and associated data is removed.
sequenceDiagram
    actor User

    %% --- Submit delete request ---
    User->>+FE: clicks "Delete account" in Danger Zone
    FE->>FE: open Delete Account modal <br> (retype "DELETE" + enter password)

    User->>+FE: types "DELETE" and enters current password, submits

    FE->>FE: validate: confirmation text == "DELETE" AND password not blank
    alt validation fails
        FE-->>User: show inline error, keep modal open
    end

    FE->>+BE: POST /api/v1/users/me/gdpr/delete <br> Authorization: Bearer {accessToken} <br> DeleteAccountRequest

    BE->>BE: validate JWT access token
    alt access token invalid or missing
        BE-->>FE: 401 Unauthorized <br> ErrorResponse
    end

    BE->>DB: find user by id (from token)
    BE->>BE: verify password against stored hash
    alt password incorrect
        BE-->>FE: 400 Bad Request <br> ErrorResponse
    end

    BE->>DB: check if deletion_requested_at already set
    alt deletion already requested
        BE-->>FE: 409 Conflict <br> ErrorResponse
    end

    BE->>BE: generate deletion_undo_token (256-bit, hex)
    BE->>DB: set deletion_requested_at=now(), <br> deletion_scheduled_at=now()+14d, <br> deletion_undo_token={token}
    BE->>DB: invalidate all refresh tokens for user <br> (delete from refresh_token where user_id=?)
    BE->>BE: send email via Mailgun <br> "Account scheduled for deletion — undo link"
    BE->>BE: emit audit log: gdpr.delete.requested {userId}

    BE->>-FE: 200 OK <br> DeleteAccountResponse

    FE->>FE: clear localStorage (access token + refresh token)
    FE->>-User: redirect to /login with query ?deleted=1 <br> Login page shows: "Your account is scheduled for deletion. Check your email."

    %% --- Login blocked during grace period ---
    User->>+FE: tries to log in
    FE->>+BE: POST /api/v1/auth/login <br> LoginRequest

    BE->>DB: find user by email
    BE->>BE: check deletion_requested_at is set
    alt user is in deletion grace period
        BE-->>FE: 403 Forbidden <br> ErrorResponse (code=ACCOUNT_SCHEDULED_FOR_DELETION)
    end
    BE->>-FE: (normal login flow if not in grace period)
    FE->>-User: show error: "Account scheduled for deletion. You can undo until {deletion_scheduled_at}."

    %% --- Cancel from profile page (while still logged in via fresh session) ---
    User->>+FE: clicks "Cancel deletion" button (visible in Danger Zone while grace active)

    FE->>+BE: POST /api/v1/users/me/gdpr/delete/cancel <br> Authorization: Bearer {accessToken}

    BE->>BE: validate JWT access token
    alt access token invalid or missing
        BE-->>FE: 401 Unauthorized <br> ErrorResponse
    end

    BE->>DB: find user by id
    BE->>BE: check deletion_requested_at is set and grace period not yet expired
    alt no pending deletion
        BE-->>FE: 409 Conflict <br> ErrorResponse
    end

    BE->>DB: clear deletion_requested_at, deletion_scheduled_at, deletion_undo_token
    BE->>BE: emit audit log: gdpr.delete.cancelled {userId}

    BE->>-FE: 200 OK <br> (no body)

    FE->>-User: show toast "Account deletion cancelled. Your account is restored."

    %% --- Undo via email link ---
    User->>+FE: opens email, clicks undo link <br> POST /api/v1/gdpr/delete/{token}/confirm-undo

    FE->>+BE: POST /api/v1/gdpr/delete/{token}/confirm-undo <br> (no auth — public token endpoint)

    BE->>DB: find user by deletion_undo_token
    alt token not found
        BE-->>FE: 404 Not Found <br> ErrorResponse
    end

    BE->>BE: check deletion_scheduled_at > now()
    alt grace period already expired (user already purged or hard purge ran)
        BE-->>FE: 410 Gone <br> ErrorResponse
    end

    BE->>DB: clear deletion_requested_at, deletion_scheduled_at, deletion_undo_token
    BE->>BE: emit audit log: gdpr.delete.cancelled {userId}

    BE->>-FE: 200 OK <br> UndoDeleteResponse

    FE->>-User: show confirmation page: "Account deletion cancelled. You can log in normally."

    %% --- Nightly hard purge job ---
    Note over BE,DB: Scheduled job — runs daily at 03:00 UTC

    BE->>DB: SELECT users WHERE deletion_scheduled_at < now()
    loop for each user
        BE->>DB: cascade DELETE all user data <br> (projects, conversations, messages, usage_events, <br> budget, topups, gdpr_export_request, …)
        BE->>BE: drop cluster B schemas for each project <br> (talkide_dataplane: tk_t{tenantId}_p{slug}_{env})
        BE->>BE: delete K8s Deployments + Ingresses <br> for published apps (provisioner cleanup)
        BE->>DB: DELETE FROM users WHERE id = ?
        BE->>BE: send final email: "Your data has been permanently deleted"
        BE->>BE: emit audit log: gdpr.delete.purged {userId}
    end

POST /api/v1/users/me/gdpr/delete

POST /api/v1/users/me/gdpr/delete DeleteAccountRequest:

{
  "password": "myCurrentPassword123"
}

Note: confirmationText (the “DELETE” text field) is validated client-side only and is NOT sent in the request body.

200 OK DeleteAccountResponse:

{
  "data": {
    "deletionScheduledAt": "2026-06-06T10:00:00Z",
    "message": "Your account is scheduled for permanent deletion on 2026-06-06. Check your email for the undo link."
  }
}

400 Bad Request (wrong password) ErrorResponse:

{
  "status": 400,
  "code": "VALIDATION_ERROR",
  "message": "Validation failed",
  "errors": [
    { "field": "password", "message": "current password is incorrect" }
  ]
}

401 Unauthorized (missing or invalid access token) ErrorResponse:

{
  "status": 401,
  "code": "AUTHENTICATION_FAILED",
  "message": "Access token is missing or invalid"
}

409 Conflict (deletion already requested) ErrorResponse:

{
  "status": 409,
  "code": "CONFLICT_GDPR_DELETE",
  "message": "Account deletion is already scheduled for this account."
}

POST /api/v1/users/me/gdpr/delete/cancel

POST /api/v1/users/me/gdpr/delete/cancel (no request body)

200 OK (no body)

401 Unauthorized ErrorResponse:

{
  "status": 401,
  "code": "AUTHENTICATION_FAILED",
  "message": "Access token is missing or invalid"
}

409 Conflict (no pending deletion) ErrorResponse:

{
  "status": 409,
  "code": "CONFLICT_GDPR_DELETE_CANCEL",
  "message": "No pending deletion request found for this account."
}

POST /api/v1/gdpr/delete/{token}/confirm-undo

POST /api/v1/gdpr/delete/{token}/confirm-undo (no request body, public — no auth)

200 OK UndoDeleteResponse:

{
  "data": {
    "message": "Your account deletion has been cancelled. You can log in normally."
  }
}

404 Not Found (token does not exist) ErrorResponse:

{
  "status": 404,
  "code": "NOT_FOUND",
  "message": "Undo link not found or already used."
}

410 Gone (grace period already expired) ErrorResponse:

{
  "status": 410,
  "code": "GONE_GDPR_DELETE",
  "message": "The undo period has expired. Your account data has been permanently deleted."
}

403 from login during grace period

POST /api/v1/auth/login — when user is in grace period:

403 Forbidden ErrorResponse:

{
  "status": 403,
  "code": "ACCOUNT_SCHEDULED_FOR_DELETION",
  "message": "Account scheduled for deletion. Use undo link in email.",
  "data": {
    "deletionScheduledAt": "2026-06-06T10:00:00Z"
  }
}

FE formats the deletion date from data.deletionScheduledAt — do NOT parse the message field to extract the date.

DB Schema

New columns in users table

Liquibase migration: 0049-add-user-deletion-fields.xml

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
    xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">

  <changeSet id="0049-add-user-deletion-fields" author="talkide">
    <addColumn tableName="users">
      <column name="deletion_requested_at" type="timestamptz">
        <constraints nullable="true"/>
      </column>
      <column name="deletion_undo_token" type="varchar(64)">
        <constraints nullable="true" unique="true"/>
      </column>
      <column name="deletion_scheduled_at" type="timestamptz">
        <constraints nullable="true"/>
      </column>
    </addColumn>

    <createIndex tableName="users" indexName="idx_users_deletion_scheduled_at">
      <column name="deletion_scheduled_at"/>
    </createIndex>

    <createIndex tableName="users" indexName="idx_users_deletion_undo_token">
      <column name="deletion_undo_token"/>
    </createIndex>
  </changeSet>

</databaseChangeLog>

Deletion lifecycle fields

ColumnTypeDescription
deletion_requested_atTIMESTAMPTZ NULLWhen the deletion was requested. NULL = account active.
deletion_undo_tokenVARCHAR(64) NULL256-bit cryptographic token (hex). Used in the email undo link. Cleared after undo or purge.
deletion_scheduled_atTIMESTAMPTZ NULLPurge date = deletion_requested_at + 14 days. Indexed for the nightly purge job.

Hard purge cascade order

FK cascade is NOT set at the DB level — deletion is explicit in the application layer for full control and auditability.

The scheduled job deletes data in the following explicit order:

  1. usage_event where user_id = ?
  2. credit_topup where user_id = ?
  3. user_budget where user_id = ?
  4. gdpr_export_request where user_id = ? + DO Spaces objects
  5. message where conversation_id IN (SELECT id FROM conversation WHERE project_id IN (SELECT id FROM project WHERE user_id = ?))
  6. conversation where project_id IN (SELECT id FROM project WHERE user_id = ?)
  7. refresh_token where user_id = ?
  8. Cluster B schemas in talkide_dataplane: DROP SCHEMA IF EXISTS tk_t{tenantId}_p{slug}_{env} CASCADE for each project
  9. K8s Deployments + Ingresses for published apps (provisioner cleanup)
  10. project where user_id = ?
  11. tenant where id = user.tenant_id (only if personal tenant)
  12. users where id = ?
  13. Stripe: no delete (invoices retained 7 years per Czech tax law) — only internal reference cleared

Frontend

UX Guidelines

Delete Account card in DangerSection.vue:

Styling: background: var(--rose-soft), border: 1px solid var(--rose-line). Delete button: background: var(--rose) (solid rose).

Flow — submitting delete request:

  1. User clicks “Delete account” button.
  2. Modal opens with title “Delete your account”. Body:
    • Warning paragraph: “This action is irreversible. Your account will be permanently deleted on {deletion_scheduled_at} (14 days from now). You’ll receive an undo link by email.”
    • Field 1 — confirmation text: label “Type DELETE to confirm”, <input type="text" placeholder="DELETE">. FE validates: value must equal exactly "DELETE" (case-sensitive).
    • Field 2 — password: label “Enter your current password”, <input type="password">. FE validates: not blank.
    • Two buttons: “Cancel” (ghost) and “Delete my account” (rose primary, disabled until both fields valid).
  3. On submit: POST /api/v1/users/me/gdpr/delete.
    • On 200 OK: clear localStorage, redirect to /login?deleted=1. Login page shows banner: “Your account is scheduled for deletion on {date}. Check your email for the undo link.”
    • On 400 (wrong password): show field error “Incorrect password” on password field, keep modal open.
    • On 409 (already requested): show info toast “Account deletion is already scheduled. Check your email.”
  4. “Delete my account” button shows loading spinner while request is in flight.

Flow — cancelling from profile (while grace period active):

  • If GET /api/v1/users/me returns a user with deletionScheduledAt set, the Danger Zone section shows a dismissible amber banner: “Your account is scheduled for deletion on {date}. You can cancel below.”
  • A “Cancel deletion” ghost button replaces the “Delete account” button.
  • On confirm: POST /api/v1/users/me/gdpr/delete/cancel. On 200: show success toast “Account deletion cancelled. Your account is restored.”

Flow — undo via email link (public page /gdpr/undo/{token}):

  • Vue Router route /gdpr/undo/:token — the Vue component calls POST /api/v1/gdpr/delete/{token}/confirm-undo automatically in onMounted(). No form is shown; the user sees a loading spinner while the request is in flight, then transitions to a success or error screen.
  • On page refresh, the POST is attempted again — this is safe because the endpoint is idempotent: if the token has already been consumed (deletion fields cleared), the next call returns 404 (token no longer exists in DB).
    • 200 OK: show “Your account deletion has been cancelled. You can log in normally.” with a “Go to login” button.
    • 404: show “This undo link is invalid or has already been used.”
    • 410: show “The undo deadline has passed. Your account data has been permanently deleted.”

Validations

FieldConstraintsSizePatternNote
confirmationTextnot_blankmust equal “DELETE”case-sensitive exact match; validated client-side only
passwordnot_blankpassword is verified server-side; FE only checks not_blank

Backend

Validations

FieldConstraintsSizeNote
JWT tokenmust be valid, non-expired401 AUTHENTICATION_FAILED otherwise
passwordnot_blank400 VALIDATION_ERROR otherwise
passwordmust match stored hash400 VALIDATION_ERROR (field: password) if wrong
deletion_requested_atmust be NULL409 CONFLICT_GDPR_DELETE if already set
confirmationTextnot sent to serverFE-only validation — field is NOT part of DeleteAccountRequest DTO

Security Considerations

  • Password re-auth at request time prevents unauthorized deletion (e.g., stolen access token alone is not sufficient).
  • deletion_undo_token must be at least 256 bits of cryptographic randomness — not derived from user ID or timestamp. Generated using SecureRandom.
  • Token must be single-use conceptually: once the undo is confirmed, clear all three deletion fields atomically.
  • The public undo endpoint (POST /api/v1/gdpr/delete/{token}/confirm-undo) does not require a valid JWT — security relies solely on token unpredictability. Do NOT log the token at INFO level.
  • Refresh tokens are invalidated immediately upon delete request — the user cannot re-authenticate during the grace period.
  • Login endpoint must check deletion_requested_at IS NOT NULL before issuing tokens and return 403 ACCOUNT_SCHEDULED_FOR_DELETION with the undo deadline in the error payload.

Scheduled Purge Job

  • Cron: @Scheduled(cron = "0 0 3 * * *") — runs daily at 03:00 UTC.
  • Processes users where deletion_scheduled_at IS NOT NULL AND deletion_scheduled_at < now().
  • Each user is purged in a separate DB transaction. If purge for one user fails, other users in the batch are still processed (fail-per-user, not fail-all).
  • K8s Deployment/Ingress cleanup: call provisioner cleanup API for each project. If K8s API is unavailable, log ERROR and continue — admin must manually clean up. See Known Limitations below.
  • Cluster B schema drop: DROP SCHEMA IF EXISTS tk_t{tenantId}_p{slug}_{env} CASCADE for each project of the user.
  • After successful purge: send final confirmation email via Mailgun. If Mailgun unavailable: log ERROR, continue (data is already gone — email is best-effort at this stage).
  • Emit structured audit log: gdpr.delete.purged {userId: …, projectsDropped: N, schemasDropped: N}.

Test Cases

GIVENWHENTHENScope
authenticated user, correct password, no pending deletionPOST /gdpr/delete is called200 OK, deletion fields set in DB, refresh tokens invalidated, email sentintegration
authenticated user, wrong passwordPOST /gdpr/delete is called400 VALIDATION_ERROR (field: password) returnedintegration
authenticated user, blank password bodyPOST /gdpr/delete is called400 VALIDATION_ERROR returnedunit
authenticated user, deletion already requestedPOST /gdpr/delete is called409 CONFLICT_GDPR_DELETE returnedintegration
no Authorization headerPOST /gdpr/delete is called401 AUTHENTICATION_FAILED returnedunit
authenticated user, pending deletion within grace periodPOST /gdpr/delete/cancel is called200 OK, deletion fields cleared in DBintegration
authenticated user, no pending deletionPOST /gdpr/delete/cancel is called409 CONFLICT_GDPR_DELETE_CANCEL returnedintegration
no Authorization headerPOST /gdpr/delete/cancel is called401 AUTHENTICATION_FAILED returnedunit
valid undo token, grace period activePOST /gdpr/delete/{token}/confirm-undo200 OK, deletion fields clearedintegration
non-existent undo tokenPOST /gdpr/delete/{token}/confirm-undo404 NOT_FOUND returnedintegration
valid undo token, grace period expiredPOST /gdpr/delete/{token}/confirm-undo410 Gone returnedintegration
user in grace periodPOST /auth/login with correct credentials403 ACCOUNT_SCHEDULED_FOR_DELETION returnedintegration
user NOT in grace periodPOST /auth/login with correct credentialsnormal 200 OK login flowintegration
user with deletion_scheduled_at < now() existsscheduled purge job runsuser data fully purged from DB, cluster B schemas dropped, K8s resources deleted, confirmation email sent, audit log emittedintegration
K8s API unavailable during purgescheduled purge job runsuser data purged from DB, ERROR logged for K8s step, job continues with remaining usersintegration
user fills DELETE + password, submitsdelete modal submitPOST called, localStorage cleared, redirect to /login?deleted=1e2e
user clicks undo link in email/gdpr/undo/{token} pageconfirm-undo POST called, success page showne2e

Known Limitations

  • K8s Deployment/Ingress cleanup during hard purge — The nightly purge job attempts to delete K8s Deployments and Ingresses for all published apps of the deleted user. If the K8s API is unavailable at purge time, these resources are NOT deleted and require manual admin cleanup. This is tracked as a known limitation and logged at ERROR level. Admin runbook: kubectl delete deployment -n talkide -l talkide.app/user-id={userId}.
  • Stripe invoice retention — Invoices are NOT deleted from Stripe during purge (Czech tax law requires 7-year retention). The Stripe Customer object and associated invoices remain. Local users row reference is removed but Stripe Customer ID is not stored post-purge. This is intentional.
  • Cluster B schema drop on provisioner error — If the data-plane PgBouncer is unreachable, DROP SCHEMA may fail. Job logs ERROR and continues. Manual cleanup: DROP SCHEMA IF EXISTS tk_t{tenantId}_p{slug}_{env} CASCADE on cluster B.
  • No self-service during grace period — The user cannot log in, access their data, or submit a new export request during the 14-day grace period. Only the undo action is available (via email link or — if they retained a valid access token — the cancel endpoint).
  • Grace period is not extendable — Once set, deletion_scheduled_at is fixed at requested_at + 14 days. No UI or API to extend.

Was this page helpful?

Thanks for the feedback.