Build GDPR compliance system: data export, account deletion, consent
- Data export (Art. 15/20): GET /api/gdpr/export serves a full JSON export of the user's data (credentials/tokens excluded, exclusions declared) - Right to erasure (Art. 17): self-service deletion with 30-day grace period (Settings -> Privacy & Data), cancellable; daily /api/cron/gdpr drain cancels Stripe billing, purges Spaces files, cascade-deletes the account, anonymizes consent rows, and writes audit evidence - Migration 0011: account_deletion_requests (partial unique index = one pending per user) + FK-less consent_log (survives erasure) - Consent: terms/privacy acceptance logged at signup (email + Google); cookie banner with analytics opt-out (umami.disabled), choices logged server-side for signed-in users via POST /api/gdpr/consent - Admin deleteUser upgraded to the same full purge (was leaving Spaces files and Stripe subscriptions orphaned) - /gdpr legal page now points at the self-service tools - scripts/verify-gdpr.ts: end-to-end verification vs live dev DB (22/22) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0d11018019
commit
5a555c715e
+26
-1
@@ -3,8 +3,9 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
import { nextCookies } from "better-auth/next-js"
|
||||
import { admin } from "better-auth/plugins"
|
||||
import { db } from "@/lib/db"
|
||||
import { user, session, account, verification, profiles } from "@/lib/db/schema"
|
||||
import { user, session, account, verification, profiles, consent_log } from "@/lib/db/schema"
|
||||
import { sendEmail, resetPasswordHtml, verifyEmailHtml } from "@/lib/email/send"
|
||||
import { LEGAL } from "@/lib/legal"
|
||||
|
||||
// Bootstrap superadmins from env — no API path lets a user self-promote.
|
||||
const ADMIN_USER_IDS = (process.env.ADMIN_USER_IDS ?? "")
|
||||
@@ -80,6 +81,30 @@ export const auth = betterAuth({
|
||||
} catch {
|
||||
// Never block sign-up on profile creation.
|
||||
}
|
||||
// GDPR proof of acceptance: the signup form states that creating an
|
||||
// account means agreeing to the Terms and Privacy Policy.
|
||||
try {
|
||||
await db.insert(consent_log).values([
|
||||
{
|
||||
user_id: u.id,
|
||||
email: u.email,
|
||||
kind: "terms" as const,
|
||||
granted: true,
|
||||
policy_version: LEGAL.lastUpdated,
|
||||
source: "signup",
|
||||
},
|
||||
{
|
||||
user_id: u.id,
|
||||
email: u.email,
|
||||
kind: "privacy" as const,
|
||||
granted: true,
|
||||
policy_version: LEGAL.lastUpdated,
|
||||
source: "signup",
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
// Never block sign-up on consent logging.
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE "account_deletion_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"email" text,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"reason" text,
|
||||
"scheduled_for" timestamp with time zone NOT NULL,
|
||||
"cancelled_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "consent_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text,
|
||||
"email" text,
|
||||
"kind" text NOT NULL,
|
||||
"granted" boolean NOT NULL,
|
||||
"policy_version" text,
|
||||
"source" text,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "account_deletion_requests_pending_user_idx" ON "account_deletion_requests" USING btree ("user_id") WHERE status = 'pending';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
||||
"when": 1783017593260,
|
||||
"tag": "0010_esign_connections",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1783071567630,
|
||||
"tag": "0011_gdpr",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
date,
|
||||
jsonb,
|
||||
doublePrecision,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
// ============================================================
|
||||
@@ -715,6 +716,58 @@ export const webhook_deliveries = pgTable("webhook_deliveries", {
|
||||
updated_at: updatedAt(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// ACCOUNT DELETION REQUESTS (GDPR right to erasure)
|
||||
// ============================================================
|
||||
// A user's self-service "delete my account" request. Deletion is deferred by a
|
||||
// grace period (LEGAL.dataDeletionDays) during which the user can cancel; the
|
||||
// gdpr cron then hard-deletes the account, its data, and its stored files.
|
||||
// user_id is intentionally NOT a cascading FK — the completed request must
|
||||
// survive the user's deletion as evidence the DSAR was honored. `email` is
|
||||
// kept only while the request is pending (to notify) and nulled on completion.
|
||||
export const account_deletion_requests = pgTable(
|
||||
"account_deletion_requests",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id").notNull(),
|
||||
email: text("email"),
|
||||
status: text("status").$type<"pending" | "cancelled" | "completed">().notNull().default("pending"),
|
||||
reason: text("reason"),
|
||||
scheduled_for: tstz("scheduled_for").notNull(),
|
||||
cancelled_at: tstz("cancelled_at"),
|
||||
completed_at: tstz("completed_at"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
created_at: createdAt(),
|
||||
updated_at: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
// At most ONE open request per user — the request/cancel flow relies on this.
|
||||
uniqueIndex("account_deletion_requests_pending_user_idx")
|
||||
.on(t.user_id)
|
||||
.where(sql`status = 'pending'`),
|
||||
]
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// CONSENT LOG (GDPR proof of consent / acceptance)
|
||||
// ============================================================
|
||||
// Records when a person accepted the Terms/Privacy Policy (at signup) or made a
|
||||
// cookie/marketing consent choice. user_id has no FK so the record survives
|
||||
// account deletion as compliance evidence; identifying fields (email, ip) are
|
||||
// anonymized by the deletion flow.
|
||||
export const consent_log = pgTable("consent_log", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id"),
|
||||
email: text("email"),
|
||||
kind: text("kind").$type<"terms" | "privacy" | "cookies" | "marketing">().notNull(),
|
||||
granted: boolean("granted").notNull(),
|
||||
policy_version: text("policy_version"),
|
||||
source: text("source"),
|
||||
ip_address: text("ip_address"),
|
||||
user_agent: text("user_agent"),
|
||||
created_at: createdAt(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// RELATIONS (for Drizzle relational queries)
|
||||
// ============================================================
|
||||
|
||||
@@ -221,6 +221,51 @@ export function teamInviteHtml({
|
||||
})
|
||||
}
|
||||
|
||||
export function accountDeletionRequestedHtml({
|
||||
name,
|
||||
scheduledDate,
|
||||
graceDays,
|
||||
}: {
|
||||
name: string
|
||||
scheduledDate: string
|
||||
graceDays: number
|
||||
}) {
|
||||
return emailShell({
|
||||
preheader: `Your account is scheduled for permanent deletion on ${scheduledDate}.`,
|
||||
eyebrow: "Account deletion",
|
||||
accent: BRAND.red,
|
||||
title: "Your account deletion is scheduled",
|
||||
intro: `Hi ${escapeHtml(name)}, we received your request to delete your account and all associated data.`,
|
||||
body:
|
||||
detailTable(
|
||||
[
|
||||
{ label: "Deletion date", value: scheduledDate, accent: true },
|
||||
{ label: "Grace period", value: `${graceDays} days` },
|
||||
],
|
||||
BRAND.red
|
||||
) +
|
||||
paragraph(
|
||||
"Until then your account stays fully usable, and you can cancel the deletion at any time from Settings → Privacy & Data. After the deletion date, ALL your properties, tenants, payments, documents, and uploaded files are permanently erased — this cannot be undone."
|
||||
),
|
||||
footerNote:
|
||||
"If you did not request this, sign in and cancel the deletion immediately, then change your password.",
|
||||
})
|
||||
}
|
||||
|
||||
export function accountDeletionCompletedHtml() {
|
||||
return emailShell({
|
||||
preheader: "Your account and personal data have been permanently deleted.",
|
||||
eyebrow: "Account deletion",
|
||||
title: "Your account has been deleted",
|
||||
intro:
|
||||
"As requested, your account and the personal data associated with it have been permanently deleted from our systems.",
|
||||
body: paragraph(
|
||||
"Financial records we are legally required to retain (for example, invoices held by our payment processor) are kept only for as long as the law requires. Everything else — your properties, tenants, documents, and uploaded files — is gone."
|
||||
),
|
||||
footerNote: "Thanks for having used Property Management Network. You're welcome back anytime.",
|
||||
})
|
||||
}
|
||||
|
||||
export function followUpHtml(message: string) {
|
||||
return emailShell({
|
||||
preheader: message.slice(0, 140),
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { and, eq, lte } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import {
|
||||
user as userTable,
|
||||
profiles,
|
||||
verification,
|
||||
consent_log,
|
||||
admin_audit_log,
|
||||
account_deletion_requests,
|
||||
} from "@/lib/db/schema"
|
||||
import { deleteUserStorage } from "@/lib/storage"
|
||||
import { stripe } from "@/lib/stripe/client"
|
||||
import { sendEmail, accountDeletionCompletedHtml } from "@/lib/email/send"
|
||||
|
||||
// ============================================================================
|
||||
// GDPR account deletion (Article 17 — right to erasure).
|
||||
//
|
||||
// The DB schema does most of the cascading for us: every data table references
|
||||
// profiles(id) ON DELETE CASCADE, and profiles references user(id) ON DELETE
|
||||
// CASCADE — so deleting the auth user row erases the entire portfolio,
|
||||
// sessions, and linked accounts in one statement. What the cascade CANNOT
|
||||
// reach lives here: uploaded files in object storage, the Stripe
|
||||
// subscription/customer, email-keyed verification rows, and PII embedded in
|
||||
// the FK-less compliance tables (consent_log).
|
||||
// ============================================================================
|
||||
|
||||
export type DeletionOutcome = {
|
||||
userId: string
|
||||
filesDeleted: number
|
||||
stripeSubscription: "cancelled" | "none" | "error"
|
||||
stripeCustomer: "deleted" | "none" | "error"
|
||||
/** PayPal has no server-side cancel integration — surfaced so ops can follow up. */
|
||||
paypalSubscriptionLeftActive: string | null
|
||||
userRowDeleted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Irreversibly delete a user's account, data, files, and billing. Safe to call
|
||||
* for an already-deleted user (it still purges storage and returns cleanly).
|
||||
*/
|
||||
export async function executeAccountDeletion(userId: string): Promise<DeletionOutcome> {
|
||||
const outcome: DeletionOutcome = {
|
||||
userId,
|
||||
filesDeleted: 0,
|
||||
stripeSubscription: "none",
|
||||
stripeCustomer: "none",
|
||||
paypalSubscriptionLeftActive: null,
|
||||
userRowDeleted: false,
|
||||
}
|
||||
|
||||
const profile = await db.query.profiles.findFirst({
|
||||
where: eq(profiles.id, userId),
|
||||
columns: {
|
||||
email: true,
|
||||
stripe_subscription_id: true,
|
||||
stripe_customer_id: true,
|
||||
paypal_subscription_id: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 1. Billing — stop money moving before the data disappears.
|
||||
if (profile?.stripe_subscription_id) {
|
||||
try {
|
||||
await stripe.subscriptions.cancel(profile.stripe_subscription_id)
|
||||
outcome.stripeSubscription = "cancelled"
|
||||
} catch (e) {
|
||||
// Already-cancelled subscriptions throw; treat "not found"-style errors as done.
|
||||
outcome.stripeSubscription = isStripeGone(e) ? "cancelled" : "error"
|
||||
}
|
||||
}
|
||||
if (profile?.stripe_customer_id) {
|
||||
try {
|
||||
await stripe.customers.del(profile.stripe_customer_id)
|
||||
outcome.stripeCustomer = "deleted"
|
||||
} catch (e) {
|
||||
outcome.stripeCustomer = isStripeGone(e) ? "deleted" : "error"
|
||||
}
|
||||
}
|
||||
if (profile?.paypal_subscription_id) {
|
||||
outcome.paypalSubscriptionLeftActive = profile.paypal_subscription_id
|
||||
}
|
||||
|
||||
// 2. Stored files (Spaces / local disk) — outside the DB cascade.
|
||||
try {
|
||||
outcome.filesDeleted = await deleteUserStorage(userId)
|
||||
} catch (e) {
|
||||
console.error(`[gdpr] storage purge failed for ${userId}:`, e)
|
||||
}
|
||||
|
||||
// 3. PII in FK-less compliance tables: keep the consent facts, drop identifiers.
|
||||
await db
|
||||
.update(consent_log)
|
||||
.set({ email: null, ip_address: null, user_agent: null })
|
||||
.where(eq(consent_log.user_id, userId))
|
||||
|
||||
// 4. Email-keyed verification tokens (password reset / email verification).
|
||||
if (profile?.email) {
|
||||
await db.delete(verification).where(eq(verification.identifier, profile.email))
|
||||
}
|
||||
|
||||
// 5. The user row — cascades profiles → every portfolio table, sessions, accounts.
|
||||
const deleted = await db.delete(userTable).where(eq(userTable.id, userId)).returning({ id: userTable.id })
|
||||
outcome.userRowDeleted = deleted.length > 0
|
||||
|
||||
// Close out any open self-service request (covers admin-initiated deletes too).
|
||||
await db
|
||||
.update(account_deletion_requests)
|
||||
.set({ status: "completed", completed_at: new Date().toISOString(), email: null })
|
||||
.where(
|
||||
and(
|
||||
eq(account_deletion_requests.user_id, userId),
|
||||
eq(account_deletion_requests.status, "pending")
|
||||
)
|
||||
)
|
||||
|
||||
// 6. Immutable evidence that the erasure ran (admin_id null = system action).
|
||||
await db.insert(admin_audit_log).values({
|
||||
admin_id: null,
|
||||
action: "gdpr_delete_account",
|
||||
target_user_id: userId,
|
||||
metadata: { ...outcome },
|
||||
})
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
function isStripeGone(e: unknown): boolean {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return /no such|already.*cancel|resource_missing/i.test(msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process deletion requests whose grace period has elapsed. Called by the
|
||||
* daily gdpr cron. Failures stay `pending` (with the error recorded) so the
|
||||
* next run retries them.
|
||||
*/
|
||||
export async function processDueDeletions(limit = 25): Promise<{ processed: number; deleted: number }> {
|
||||
const due = await db
|
||||
.select()
|
||||
.from(account_deletion_requests)
|
||||
.where(
|
||||
and(
|
||||
eq(account_deletion_requests.status, "pending"),
|
||||
lte(account_deletion_requests.scheduled_for, new Date().toISOString())
|
||||
)
|
||||
)
|
||||
.limit(limit)
|
||||
|
||||
let deleted = 0
|
||||
for (const request of due) {
|
||||
const email = request.email
|
||||
try {
|
||||
const outcome = await executeAccountDeletion(request.user_id)
|
||||
await db
|
||||
.update(account_deletion_requests)
|
||||
.set({
|
||||
status: "completed",
|
||||
completed_at: new Date().toISOString(),
|
||||
// Data minimization: the request itself must not keep PII after erasure.
|
||||
email: null,
|
||||
metadata: { ...request.metadata, outcome },
|
||||
})
|
||||
.where(eq(account_deletion_requests.id, request.id))
|
||||
deleted++
|
||||
|
||||
if (email) {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: "Your account and data have been deleted",
|
||||
html: accountDeletionCompletedHtml(),
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[gdpr] deletion failed for ${request.user_id}:`, e)
|
||||
await db
|
||||
.update(account_deletion_requests)
|
||||
.set({
|
||||
metadata: {
|
||||
...request.metadata,
|
||||
last_error: e instanceof Error ? e.message : String(e),
|
||||
last_error_at: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
.where(eq(account_deletion_requests.id, request.id))
|
||||
}
|
||||
}
|
||||
|
||||
return { processed: due.length, deleted }
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { desc, eq, or } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import {
|
||||
user as userTable,
|
||||
session,
|
||||
account,
|
||||
profiles,
|
||||
properties,
|
||||
units,
|
||||
tenants,
|
||||
rent_payments,
|
||||
maintenance_requests,
|
||||
leases,
|
||||
expenses,
|
||||
documents,
|
||||
notifications,
|
||||
usage_events,
|
||||
vendors,
|
||||
inspections,
|
||||
ai_recommendations,
|
||||
ai_predictions,
|
||||
follow_up_rules,
|
||||
follow_up_log,
|
||||
activity_log,
|
||||
admin_audit_log,
|
||||
account_members,
|
||||
api_keys,
|
||||
accounting_connections,
|
||||
esign_connections,
|
||||
signature_requests,
|
||||
webhook_endpoints,
|
||||
consent_log,
|
||||
account_deletion_requests,
|
||||
} from "@/lib/db/schema"
|
||||
|
||||
// ============================================================================
|
||||
// GDPR data export (Articles 15 & 20 — access + portability).
|
||||
//
|
||||
// Produces a single machine-readable JSON object containing every record the
|
||||
// platform stores about a user, EXCLUDING credentials and third-party secrets
|
||||
// (password hashes, OAuth/API tokens, session tokens). What's excluded is
|
||||
// declared in `omitted` so the export is honest about its own boundaries.
|
||||
// ============================================================================
|
||||
|
||||
export async function buildUserDataExport(userId: string) {
|
||||
const [
|
||||
authUser,
|
||||
profile,
|
||||
sessions,
|
||||
linkedAccounts,
|
||||
propertyRows,
|
||||
unitRows,
|
||||
tenantRows,
|
||||
paymentRows,
|
||||
maintenanceRows,
|
||||
leaseRows,
|
||||
expenseRows,
|
||||
documentRows,
|
||||
vendorRows,
|
||||
inspectionRows,
|
||||
] = await Promise.all([
|
||||
db.query.user.findFirst({
|
||||
where: eq(userTable.id, userId),
|
||||
columns: { id: true, name: true, email: true, emailVerified: true, image: true, createdAt: true },
|
||||
}),
|
||||
db.query.profiles.findFirst({ where: eq(profiles.id, userId) }),
|
||||
db.query.session.findMany({
|
||||
where: eq(session.userId, userId),
|
||||
columns: { token: false }, // active credential — never exported
|
||||
orderBy: desc(session.createdAt),
|
||||
}),
|
||||
db.query.account.findMany({
|
||||
where: eq(account.userId, userId),
|
||||
columns: { id: true, providerId: true, accountId: true, scope: true, createdAt: true },
|
||||
}),
|
||||
db.query.properties.findMany({ where: eq(properties.user_id, userId) }),
|
||||
db.query.units.findMany({ where: eq(units.user_id, userId) }),
|
||||
db.query.tenants.findMany({ where: eq(tenants.user_id, userId) }),
|
||||
db.query.rent_payments.findMany({ where: eq(rent_payments.user_id, userId) }),
|
||||
db.query.maintenance_requests.findMany({ where: eq(maintenance_requests.user_id, userId) }),
|
||||
db.query.leases.findMany({ where: eq(leases.user_id, userId) }),
|
||||
db.query.expenses.findMany({ where: eq(expenses.user_id, userId) }),
|
||||
db.query.documents.findMany({ where: eq(documents.user_id, userId) }),
|
||||
db.query.vendors.findMany({ where: eq(vendors.user_id, userId) }),
|
||||
db.query.inspections.findMany({ where: eq(inspections.user_id, userId) }),
|
||||
])
|
||||
|
||||
const [
|
||||
notificationRows,
|
||||
usageRows,
|
||||
activityRows,
|
||||
recommendationRows,
|
||||
predictionRows,
|
||||
followUpRuleRows,
|
||||
followUpLogRows,
|
||||
apiKeyRows,
|
||||
webhookRows,
|
||||
accountingRows,
|
||||
esignRows,
|
||||
signatureRows,
|
||||
teamRows,
|
||||
adminActionsOnUser,
|
||||
consentRows,
|
||||
deletionRows,
|
||||
] = await Promise.all([
|
||||
db.query.notifications.findMany({ where: eq(notifications.user_id, userId) }),
|
||||
db.query.usage_events.findMany({ where: eq(usage_events.user_id, userId) }),
|
||||
db.query.activity_log.findMany({ where: eq(activity_log.user_id, userId) }),
|
||||
db.query.ai_recommendations.findMany({ where: eq(ai_recommendations.user_id, userId) }),
|
||||
db.query.ai_predictions.findMany({ where: eq(ai_predictions.user_id, userId) }),
|
||||
db.query.follow_up_rules.findMany({ where: eq(follow_up_rules.user_id, userId) }),
|
||||
db.query.follow_up_log.findMany({ where: eq(follow_up_log.user_id, userId) }),
|
||||
db.query.api_keys.findMany({
|
||||
where: eq(api_keys.user_id, userId),
|
||||
columns: { key_hash: false },
|
||||
}),
|
||||
db.query.webhook_endpoints.findMany({ where: eq(webhook_endpoints.user_id, userId) }),
|
||||
db.query.accounting_connections.findMany({
|
||||
where: eq(accounting_connections.user_id, userId),
|
||||
columns: { access_token: false, refresh_token: false },
|
||||
}),
|
||||
db.query.esign_connections.findMany({
|
||||
where: eq(esign_connections.user_id, userId),
|
||||
columns: { access_token: false, refresh_token: false },
|
||||
}),
|
||||
db.query.signature_requests.findMany({ where: eq(signature_requests.user_id, userId) }),
|
||||
db.query.account_members.findMany({
|
||||
where: or(eq(account_members.owner_id, userId), eq(account_members.member_id, userId)),
|
||||
columns: { invite_token: false },
|
||||
}),
|
||||
db.query.admin_audit_log.findMany({
|
||||
where: eq(admin_audit_log.target_user_id, userId),
|
||||
columns: { action: true, created_at: true },
|
||||
}),
|
||||
db.query.consent_log.findMany({ where: eq(consent_log.user_id, userId) }),
|
||||
db.query.account_deletion_requests.findMany({
|
||||
where: eq(account_deletion_requests.user_id, userId),
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
format: "propertymanagement.network/data-export",
|
||||
version: 1,
|
||||
generated_at: new Date().toISOString(),
|
||||
omitted: [
|
||||
"password hashes, OAuth/refresh tokens, session tokens, and API-key hashes (credentials are never exported)",
|
||||
"webhook delivery logs (operational copies of the event records included above)",
|
||||
"uploaded file BYTES — file metadata is under portfolio.documents; download the files themselves from the Documents page",
|
||||
],
|
||||
data_subject: { user: authUser ?? null, profile: profile ?? null },
|
||||
security: { sessions, linked_sign_in_providers: linkedAccounts },
|
||||
portfolio: {
|
||||
properties: propertyRows,
|
||||
units: unitRows,
|
||||
tenants: tenantRows,
|
||||
rent_payments: paymentRows,
|
||||
maintenance_requests: maintenanceRows,
|
||||
leases: leaseRows,
|
||||
expenses: expenseRows,
|
||||
documents: documentRows,
|
||||
vendors: vendorRows,
|
||||
inspections: inspectionRows,
|
||||
},
|
||||
communications: { notifications: notificationRows, follow_up_log: followUpLogRows },
|
||||
automation: {
|
||||
follow_up_rules: followUpRuleRows,
|
||||
webhook_endpoints: webhookRows,
|
||||
api_keys: apiKeyRows,
|
||||
},
|
||||
ai: { recommendations: recommendationRows, predictions: predictionRows },
|
||||
integrations: {
|
||||
accounting_connections: accountingRows,
|
||||
esign_connections: esignRows,
|
||||
signature_requests: signatureRows,
|
||||
},
|
||||
team: { memberships: teamRows },
|
||||
activity: { activity_log: activityRows, usage_events: usageRows },
|
||||
privacy: {
|
||||
consent_log: consentRows,
|
||||
deletion_requests: deletionRows,
|
||||
admin_actions_affecting_you: adminActionsOnUser,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type UserDataExport = Awaited<ReturnType<typeof buildUserDataExport>>
|
||||
@@ -319,6 +319,36 @@ export async function deleteFile(key: string, ownerId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete EVERY stored object in a user's namespace (`<userId>/…`),
|
||||
* across whichever backend is active. Used by GDPR account deletion — there is
|
||||
* no undo. Returns the number of objects removed (best effort; local-disk
|
||||
* removals aren't counted individually).
|
||||
*/
|
||||
export async function deleteUserStorage(userId: string): Promise<number> {
|
||||
const prefix = `${sanitizeSegment(userId)}/`
|
||||
|
||||
if (usingSpaces()) {
|
||||
let deleted = 0
|
||||
let token: string | undefined
|
||||
do {
|
||||
const res = await s3().send(
|
||||
new ListObjectsV2Command({ Bucket: SPACES_BUCKET, Prefix: prefix, ContinuationToken: token })
|
||||
)
|
||||
for (const obj of res.Contents ?? []) {
|
||||
if (!obj.Key) continue
|
||||
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: obj.Key }))
|
||||
deleted++
|
||||
}
|
||||
token = res.IsTruncated ? res.NextContinuationToken : undefined
|
||||
} while (token)
|
||||
return deleted
|
||||
}
|
||||
|
||||
await fs.rm(path.join(STORAGE_DIR, sanitizeSegment(userId)), { recursive: true, force: true })
|
||||
return 0
|
||||
}
|
||||
|
||||
async function walkDirSize(dir: string): Promise<number> {
|
||||
let entries
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user