Compare commits

..
2 Commits
Author SHA1 Message Date
soconnor 1e7174fa60 Add permanent account deletion 2026-08-15 13:46:42 -04:00
soconnor 5f6987b84b Restore public demo review password 2026-08-15 00:17:57 -04:00
6 changed files with 218 additions and 126 deletions
+4 -2
View File
@@ -82,13 +82,15 @@ bun run db:push # fast iteration during development
# bun run db:migrate # same migrations the Docker image runs in production # bun run db:migrate # same migrations the Docker image runs in production
``` ```
**Demo account.** For App Store review and local testing, `bun run db:migrate` creates a pre-populated but locked `demo@example.com` account (`db:push` does not). Provision a private, temporary password when review access is needed: **Demo account.** For App Store review and local testing, `bun run db:migrate` creates a pre-populated `demo@example.com` account (`db:push` does not) with the public password `demo123`.
To rotate the credential temporarily, provision a private password:
```bash ```bash
DEMO_ACCOUNT_PASSWORD='<private 12+ character password>' bun run demo:provision DEMO_ACCOUNT_PASSWORD='<private 12+ character password>' bun run demo:provision
``` ```
Provisioning rotates the credential and invalidates prior sessions. Do not commit or publish the password. Re-run the command to rotate it; migration 0027 disables the previously public credential. The account includes a sample business, clients, and invoices (draft, sent, and paid). Provisioning rotates the credential and invalidates prior sessions. Do not commit or publish a private replacement password. The account includes a sample business, clients, and invoices (draft, sent, and paid).
### 4. Run ### 4. Run
@@ -0,0 +1,16 @@
-- Restore the public App Store review credential for the seeded demo account.
-- Password: demo123
UPDATE "beenvoice_user"
SET "password" = '$2b$12$90U31okgkhOwSQD5RDqHwO0QpcC.pkKsqKb1IPnHfKUZm/2A9hzs6',
"updatedAt" = NOW()
WHERE "id" = 'a0000000-0000-4000-8000-000000000001'
AND "email" = 'demo@example.com';
UPDATE "beenvoice_account"
SET "password" = '$2b$12$90U31okgkhOwSQD5RDqHwO0QpcC.pkKsqKb1IPnHfKUZm/2A9hzs6',
"updatedAt" = NOW()
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001'
AND "providerId" = 'credential';
DELETE FROM "beenvoice_session"
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001';
+7
View File
@@ -197,6 +197,13 @@
"when": 1786740000000, "when": 1786740000000,
"tag": "0027_disable_public_demo_password", "tag": "0027_disable_public_demo_password",
"breakpoints": true "breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1786766968000,
"tag": "0028_enable_public_demo_password",
"breakpoints": true
} }
] ]
} }
@@ -162,8 +162,11 @@ export function SettingsContent({
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLinking, setIsLinking] = useState(false); const [isLinking, setIsLinking] = useState(false);
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const { colorMode, updateAppearance, isUpdating: appearanceUpdating } = const {
useAppearance(); colorMode,
updateAppearance,
isUpdating: appearanceUpdating,
} = useAppearance();
const utils = api.useUtils(); const utils = api.useUtils();
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(); const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({ const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
@@ -221,8 +224,11 @@ export function SettingsContent({
}; };
// Queries // Queries
const { data: profile, refetch: refetchProfile, isFetched: profileFetched } = const {
api.settings.getProfile.useQuery(); data: profile,
refetch: refetchProfile,
isFetched: profileFetched,
} = api.settings.getProfile.useQuery();
const isAdmin = profile?.role === "admin"; const isAdmin = profile?.role === "admin";
const { data: dataStats } = api.settings.getDataStats.useQuery(); const { data: dataStats } = api.settings.getDataStats.useQuery();
@@ -285,10 +291,13 @@ export function SettingsContent({
}, },
}); });
const deleteDataMutation = api.settings.deleteAllData.useMutation({ const deleteAccountMutation = api.settings.deleteAccount.useMutation({
onSuccess: () => { onSuccess: async () => {
toast.success("All data has been permanently deleted"); toast.success("Your account and data have been permanently deleted");
setDeleteConfirmText(""); setDeleteConfirmText("");
await authClient.signOut().catch(() => undefined);
router.replace("/login");
router.refresh();
}, },
onError: (error: { message: string }) => { onError: (error: { message: string }) => {
toast.error(`Delete failed: ${error.message}`); toast.error(`Delete failed: ${error.message}`);
@@ -401,12 +410,12 @@ export function SettingsContent({
} }
}; };
const handleDeleteAllData = () => { const handleDeleteAccount = () => {
if (deleteConfirmText !== "delete all my data") { if (deleteConfirmText !== "DELETE MY ACCOUNT") {
toast.error("Please type 'delete all my data' to confirm"); toast.error("Please type 'DELETE MY ACCOUNT' to confirm");
return; return;
} }
deleteDataMutation.mutate({ confirmText: deleteConfirmText }); deleteAccountMutation.mutate({ confirmText: deleteConfirmText });
}; };
// Set initial name value once when profile loads // Set initial name value once when profile loads
@@ -706,10 +715,7 @@ export function SettingsContent({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{colorModes.map((modeOption) => ( {colorModes.map((modeOption) => (
<SelectItem <SelectItem key={modeOption.value} value={modeOption.value}>
key={modeOption.value}
value={modeOption.value}
>
{modeOption.label} {modeOption.label}
</SelectItem> </SelectItem>
))} ))}
@@ -1283,37 +1289,43 @@ export function SettingsContent({
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto"> <Button variant="destructive" className="w-full sm:w-auto">
Delete All Data Delete Account
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete This action cannot be undone. It permanently deletes your
your account and remove your data from our servers. account, invoices, clients, businesses, expenses, time
entries, uploaded files, and sign-in data from our servers.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="my-4 space-y-2"> <div className="my-4 space-y-2">
<Label htmlFor="confirm-delete"> <Label htmlFor="confirm-delete">
Type <span className="font-bold">delete all my data</span>{" "} Type <span className="font-bold">DELETE MY ACCOUNT</span> to
to confirm confirm
</Label> </Label>
<Input <Input
id="confirm-delete" id="confirm-delete"
value={deleteConfirmText} value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)} onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete all my data" placeholder="DELETE MY ACCOUNT"
/> />
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDeleteAllData} onClick={handleDeleteAccount}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteConfirmText !== "delete all my data"} disabled={
deleteConfirmText !== "DELETE MY ACCOUNT" ||
deleteAccountMutation.isPending
}
> >
Delete Account {deleteAccountMutation.isPending
? "Deleting…"
: "Delete Account"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+77 -58
View File
@@ -1,7 +1,4 @@
import { import { LEGAL_PRIVACY_EMAIL, LEGAL_WEBSITE } from "~/lib/legal";
LEGAL_PRIVACY_EMAIL,
LEGAL_WEBSITE,
} from "~/lib/legal";
import { brand } from "~/lib/branding"; import { brand } from "~/lib/branding";
import { import {
LegalDocument, LegalDocument,
@@ -16,9 +13,9 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
This Privacy Policy explains how {brand.name} collects, uses, and protects This Privacy Policy explains how {brand.name} collects, uses, and
information when you use our invoicing platform, including the web app and mobile protects information when you use our invoicing platform, including
app (the Service). the web app and mobile app (the Service).
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
If you have questions about this policy, email us at{" "} If you have questions about this policy, email us at{" "}
@@ -33,19 +30,21 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
When you create an account and use the Service, you provide information such as When you create an account and use the Service, you provide
your name, email address, business details, client records, invoice content, and information such as your name, email address, business details, client
time entries. This is the data you enter to run your invoicing workflow. records, invoice content, and time entries. This is the data you enter
to run your invoicing workflow.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
You may also add payment instructions that appear on invoices, such as bank You may also add payment instructions that appear on invoices, such as
transfer details. We do not process card payments on your behalf. bank transfer details. We do not process card payments on your behalf.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
We also collect some technical information automatically so the Service stays We also collect some technical information automatically so the
secure and reliable. This can include your IP address, device and browser or app Service stays secure and reliable. This can include your IP address,
details, log and diagnostic data, and session cookies that keep you signed in. device and browser or app details, log and diagnostic data, and
Some deployments may use optional, privacy-focused analytics. session cookies that keep you signed in. Some deployments may use
optional, privacy-focused analytics.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -56,9 +55,10 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
We use your information to provide and operate the Service, authenticate your We use your information to provide and operate the Service,
account, send transactional messages such as password resets, respond to support authenticate your account, send transactional messages such as
requests, monitor security and performance, and meet legal obligations. password resets, respond to support requests, monitor security and
performance, and meet legal obligations.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -69,24 +69,24 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
We do not sell your personal information. We share it only when needed to run the We do not sell your personal information. We share it only when needed
Service or when the law requires it. to run the Service or when the law requires it.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
We work with service providers that host our infrastructure, deliver transactional We work with service providers that host our infrastructure, deliver
email, support single sign-on when enabled on your instance, and optionally provide transactional email, support single sign-on when enabled on your
privacy-focused analytics. These vendors may process your information only to instance, and optionally provide privacy-focused analytics. These
perform services for us. vendors may process your information only to perform services for us.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
We may disclose information if we believe it is reasonably necessary to comply with We may disclose information if we believe it is reasonably necessary
law, respond to a valid legal request, or protect the security and integrity of the to comply with law, respond to a valid legal request, or protect the
Service. security and integrity of the Service.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
If we are involved in a merger, acquisition, or sale of assets, your information If we are involved in a merger, acquisition, or sale of assets, your
may be transferred as part of that transaction, subject to continued protection information may be transferred as part of that transaction, subject to
consistent with this policy. continued protection consistent with this policy.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -97,14 +97,25 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
We use reasonable safeguards to protect information, including encryption in We use reasonable safeguards to protect information, including
transit, access controls, and secure authentication. No method of transmission or encryption in transit, access controls, and secure authentication. No
storage is completely secure. method of transmission or storage is completely secure.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
We retain information for as long as you have an account or as needed to provide We retain information for as long as you have an account or as needed
the Service. We may keep certain records longer when required by law or for to provide the Service. We may keep certain records longer when
legitimate purposes such as fraud prevention or dispute resolution. required by law or for legitimate purposes such as fraud prevention or
dispute resolution.
</LegalParagraph>
<LegalParagraph>
You can permanently delete your account from Settings in the mobile
app or web app. Account deletion removes your account record and
associated Service data, including invoices, clients, businesses,
expenses, time entries, uploaded files, access keys, and active
sessions. The action cannot be undone. Limited information may be
retained only when required by law, and residual copies may remain in
secure backups until those backups are overwritten through our normal
retention cycle.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -115,13 +126,16 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
Depending on where you live, you may have the right to access, correct, delete, or Depending on where you live, you may have the right to access,
export your personal information, or to object to or restrict certain processing. correct, delete, or export your personal information, or to object to
or restrict certain processing.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
To exercise these rights, contact us at{" "} You can delete your account directly in Settings. To exercise another
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>. We will privacy right, or if you cannot access your account, contact us at{" "}
respond within a reasonable timeframe and as required by applicable law. <a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>.
We will respond within a reasonable timeframe and as required by
applicable law.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -132,13 +146,13 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
We use cookies and similar technologies to keep you signed in, remember We use cookies and similar technologies to keep you signed in,
preferences such as theme, and, when enabled on a deployment, measure usage with remember preferences such as theme, and, when enabled on a deployment,
privacy-focused analytics. measure usage with privacy-focused analytics.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
You can control cookies through your browser settings. If you disable essential You can control cookies through your browser settings. If you disable
cookies, some parts of the Service may not work correctly. essential cookies, some parts of the Service may not work correctly.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -149,22 +163,26 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
The Service may link to third-party websites or integrate with services you The Service may link to third-party websites or integrate with
configure, such as single sign-on. Those services have their own privacy policies, services you configure, such as single sign-on. Those services have
and we are not responsible for their practices. their own privacy policies, and we are not responsible for their
practices.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
The Service is not intended for children under 13. If you believe a child has The Service is not intended for children under 13. If you believe a
provided us personal information, contact us and we will delete it. child has provided us personal information, contact us and we will
delete it.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
Your information may be processed in countries other than your own. Where required, Your information may be processed in countries other than your own.
we use appropriate safeguards for international transfers. Where required, we use appropriate safeguards for international
transfers.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
We may update this policy from time to time. If we make material changes, we will We may update this policy from time to time. If we make material
post the updated policy on the Service and may notify you by email. Continued use changes, we will post the updated policy on the Service and may notify
after changes take effect means you accept the updated policy. you by email. Continued use after changes take effect means you accept
the updated policy.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -176,7 +194,8 @@ const sections: LegalSection[] = [
<> <>
<LegalParagraph> <LegalParagraph>
For privacy questions or requests, email{" "} For privacy questions or requests, email{" "}
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a> or visit{" "} <a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a> or
visit{" "}
<a href={LEGAL_WEBSITE} target="_blank" rel="noopener noreferrer"> <a href={LEGAL_WEBSITE} target="_blank" rel="noopener noreferrer">
{LEGAL_WEBSITE.replace(/^https?:\/\//, "")} {LEGAL_WEBSITE.replace(/^https?:\/\//, "")}
</a> </a>
+78 -42
View File
@@ -3,6 +3,7 @@ import { and, count, eq, isNull } from "drizzle-orm";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { logAuditEvent } from "~/lib/audit-log"; import { logAuditEvent } from "~/lib/audit-log";
import { deleteObject } from "~/lib/object-storage";
import { import {
createTRPCRouter, createTRPCRouter,
protectedProcedure, protectedProcedure,
@@ -12,6 +13,8 @@ import {
import { requireAdmin } from "~/server/api/require-admin"; import { requireAdmin } from "~/server/api/require-admin";
import { import {
accounts, accounts,
apiKeys,
auditLog,
users, users,
clients, clients,
businesses, businesses,
@@ -20,10 +23,14 @@ import {
invoicePayments, invoicePayments,
invoiceTemplates, invoiceTemplates,
expenses, expenses,
expenseReceipts,
recurringInvoices, recurringInvoices,
recurringInvoiceItems, recurringInvoiceItems,
sessions,
ssoProviders,
timeEntries, timeEntries,
platformSettings, platformSettings,
verificationTokens,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { import {
colorModeSchema, colorModeSchema,
@@ -703,25 +710,27 @@ export const settingsRouter = createTRPCRouter({
}, },
}); });
const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany({ const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany(
where: eq(recurringInvoices.createdById, userId), {
with: { where: eq(recurringInvoices.createdById, userId),
client: { columns: { name: true } }, with: {
business: { columns: { name: true, nickname: true } }, client: { columns: { name: true } },
items: { business: { columns: { name: true, nickname: true } },
columns: { items: {
description: true, columns: {
hours: true, description: true,
rate: true, hours: true,
position: true, rate: true,
position: true,
},
orderBy: (items, { asc }) => [
asc(items.position),
asc(items.createdAt),
],
}, },
orderBy: (items, { asc }) => [
asc(items.position),
asc(items.createdAt),
],
}, },
}, },
}); );
const userExpenses = await ctx.db.query.expenses.findMany({ const userExpenses = await ctx.db.query.expenses.findMany({
where: eq(expenses.createdById, userId), where: eq(expenses.createdById, userId),
@@ -1101,7 +1110,9 @@ export const settingsRouter = createTRPCRouter({
...(input.user.animationSpeedMultiplier !== undefined && { ...(input.user.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: input.user.animationSpeedMultiplier, animationSpeedMultiplier: input.user.animationSpeedMultiplier,
}), }),
...(input.user.theme !== undefined && { theme: input.user.theme }), ...(input.user.theme !== undefined && {
theme: input.user.theme,
}),
...(input.user.onboardingCompletedAt !== undefined && { ...(input.user.onboardingCompletedAt !== undefined && {
onboardingCompletedAt: input.user.onboardingCompletedAt, onboardingCompletedAt: input.user.onboardingCompletedAt,
}), }),
@@ -1137,7 +1148,9 @@ export const settingsRouter = createTRPCRouter({
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: message:
error instanceof Error ? error.message : "Import failed unexpectedly", error instanceof Error
? error.message
: "Import failed unexpectedly",
}); });
} }
}), }),
@@ -1171,44 +1184,67 @@ export const settingsRouter = createTRPCRouter({
}; };
}), }),
// Delete all user data (for account deletion) // Permanently delete the signed-in account and all associated data.
deleteAllData: sessionProcedure deleteAccount: sessionProcedure
.input( .input(
z.object({ z.object({
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", { confirmText: z.string().refine((val) => val === "DELETE MY ACCOUNT", {
message: "You must type 'DELETE ALL DATA' to confirm", message: "You must type 'DELETE MY ACCOUNT' to confirm",
}), }),
}), }),
) )
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
return await ctx.db.transaction(async (tx) => { const [receiptObjects, logoObjects] = await Promise.all([
// Delete in order due to foreign key constraints ctx.db
// 1. Invoice items (cascade should handle this, but being explicit) .select({ storageKey: expenseReceipts.storageKey })
const userInvoiceIds = await tx .from(expenseReceipts)
.select({ id: invoices.id }) .innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
.from(invoices) .where(eq(expenses.createdById, userId)),
.where(eq(invoices.createdById, userId)); ctx.db
.select({ storageKey: businesses.logoStorageKey })
.from(businesses)
.where(eq(businesses.createdById, userId)),
]);
if (userInvoiceIds.length > 0) { // Delete uploaded personal data before removing its database pointers. If object
for (const invoice of userInvoiceIds) { // storage is unavailable, the account remains intact so the user can retry.
await tx await Promise.all(
.delete(invoiceItems) [...receiptObjects, ...logoObjects].flatMap(({ storageKey }) =>
.where(eq(invoiceItems.invoiceId, invoice.id)); storageKey ? [deleteObject(storageKey)] : [],
} ),
} );
// 2. Invoices await ctx.db.transaction(async (tx) => {
// Dependents without cascading foreign keys must be removed first.
await tx.delete(auditLog).where(eq(auditLog.actorUserId, userId));
await tx
.delete(recurringInvoices)
.where(eq(recurringInvoices.createdById, userId));
await tx.delete(expenses).where(eq(expenses.createdById, userId));
await tx
.delete(invoicePayments)
.where(eq(invoicePayments.createdById, userId));
await tx.delete(invoices).where(eq(invoices.createdById, userId)); await tx.delete(invoices).where(eq(invoices.createdById, userId));
await tx.delete(timeEntries).where(eq(timeEntries.createdById, userId));
// 3. Clients await tx
.delete(invoiceTemplates)
.where(eq(invoiceTemplates.createdById, userId));
await tx.delete(clients).where(eq(clients.createdById, userId)); await tx.delete(clients).where(eq(clients.createdById, userId));
// 4. Businesses
await tx.delete(businesses).where(eq(businesses.createdById, userId)); await tx.delete(businesses).where(eq(businesses.createdById, userId));
return { success: true }; // Authentication, access, and verification records.
await tx.delete(apiKeys).where(eq(apiKeys.userId, userId));
await tx.delete(ssoProviders).where(eq(ssoProviders.userId, userId));
await tx.delete(accounts).where(eq(accounts.userId, userId));
await tx.delete(sessions).where(eq(sessions.userId, userId));
await tx
.delete(verificationTokens)
.where(eq(verificationTokens.identifier, ctx.session.user.email));
await tx.delete(users).where(eq(users.id, userId));
}); });
return { success: true };
}), }),
}); });