feat: account deletion for Dokploy Cloud

Self-service deletion from Settings > Profile confirmed with the account
password (when present) plus a 6-digit code sent by email, and a root-only
"delete by email" tool in the impersonation bar to fulfil GDPR requests.

Both paths run the same flow: cancel every Stripe subscription (customer is
kept for invoicing), delete the user and all owned organizations in one
transaction, anonymize audit trail left in other organizations, reassign
pending invitations to the org owner, remove the HubSpot contact when
HUBSPOT_ACCESS_TOKEN is configured, and log a JSON deletion record.

Also blocks better-auth's raw /admin/remove-user endpoint and makes the
Stripe webhook tolerate subscription/customer events for already deleted
users.
This commit is contained in:
Mauricio Siu
2026-09-14 11:01:47 -06:00
parent 9fa98de9c1
commit a87a5c06f0
16 changed files with 1122 additions and 22 deletions
@@ -123,3 +123,48 @@ export async function submitToHubSpot(
return false;
}
}
export type HubSpotDeletionResult =
| "deleted"
| "not_found"
| "skipped"
| "failed";
export async function deleteHubSpotContactByEmail(
email: string,
accessToken = process.env.HUBSPOT_ACCESS_TOKEN,
): Promise<HubSpotDeletionResult> {
if (!accessToken) {
return "skipped";
}
try {
const response = await fetch(
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ idProperty: "email", objectId: email }),
},
);
if (response.ok) {
return "deleted";
}
if (response.status === 404) {
return "not_found";
}
console.error(
"HubSpot GDPR delete error:",
response.status,
await response.text(),
);
return "failed";
} catch (error) {
console.error("Error deleting HubSpot contact:", error);
return "failed";
}
}