Archived
Compare commits
2
Commits
66a53f25f2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7174fa60 | ||
|
|
5f6987b84b |
@@ -82,13 +82,15 @@ bun run db:push # fast iteration during development
|
||||
# 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
|
||||
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
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -197,6 +197,13 @@
|
||||
"when": 1786740000000,
|
||||
"tag": "0027_disable_public_demo_password",
|
||||
"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 [isLinking, setIsLinking] = useState(false);
|
||||
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||
const { colorMode, updateAppearance, isUpdating: appearanceUpdating } =
|
||||
useAppearance();
|
||||
const {
|
||||
colorMode,
|
||||
updateAppearance,
|
||||
isUpdating: appearanceUpdating,
|
||||
} = useAppearance();
|
||||
const utils = api.useUtils();
|
||||
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
|
||||
const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
|
||||
@@ -221,8 +224,11 @@ export function SettingsContent({
|
||||
};
|
||||
|
||||
// Queries
|
||||
const { data: profile, refetch: refetchProfile, isFetched: profileFetched } =
|
||||
api.settings.getProfile.useQuery();
|
||||
const {
|
||||
data: profile,
|
||||
refetch: refetchProfile,
|
||||
isFetched: profileFetched,
|
||||
} = api.settings.getProfile.useQuery();
|
||||
const isAdmin = profile?.role === "admin";
|
||||
const { data: dataStats } = api.settings.getDataStats.useQuery();
|
||||
|
||||
@@ -285,10 +291,13 @@ export function SettingsContent({
|
||||
},
|
||||
});
|
||||
|
||||
const deleteDataMutation = api.settings.deleteAllData.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("All data has been permanently deleted");
|
||||
const deleteAccountMutation = api.settings.deleteAccount.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Your account and data have been permanently deleted");
|
||||
setDeleteConfirmText("");
|
||||
await authClient.signOut().catch(() => undefined);
|
||||
router.replace("/login");
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
toast.error(`Delete failed: ${error.message}`);
|
||||
@@ -401,12 +410,12 @@ export function SettingsContent({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAllData = () => {
|
||||
if (deleteConfirmText !== "delete all my data") {
|
||||
toast.error("Please type 'delete all my data' to confirm");
|
||||
const handleDeleteAccount = () => {
|
||||
if (deleteConfirmText !== "DELETE MY ACCOUNT") {
|
||||
toast.error("Please type 'DELETE MY ACCOUNT' to confirm");
|
||||
return;
|
||||
}
|
||||
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
|
||||
deleteAccountMutation.mutate({ confirmText: deleteConfirmText });
|
||||
};
|
||||
|
||||
// Set initial name value once when profile loads
|
||||
@@ -706,10 +715,7 @@ export function SettingsContent({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{colorModes.map((modeOption) => (
|
||||
<SelectItem
|
||||
key={modeOption.value}
|
||||
value={modeOption.value}
|
||||
>
|
||||
<SelectItem key={modeOption.value} value={modeOption.value}>
|
||||
{modeOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -1283,37 +1289,43 @@ export function SettingsContent({
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full sm:w-auto">
|
||||
Delete All Data
|
||||
Delete Account
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete
|
||||
your account and remove your data from our servers.
|
||||
This action cannot be undone. It permanently deletes your
|
||||
account, invoices, clients, businesses, expenses, time
|
||||
entries, uploaded files, and sign-in data from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="my-4 space-y-2">
|
||||
<Label htmlFor="confirm-delete">
|
||||
Type <span className="font-bold">delete all my data</span>{" "}
|
||||
to confirm
|
||||
Type <span className="font-bold">DELETE MY ACCOUNT</span> to
|
||||
confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-delete"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="delete all my data"
|
||||
placeholder="DELETE MY ACCOUNT"
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteAllData}
|
||||
onClick={handleDeleteAccount}
|
||||
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>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
LEGAL_PRIVACY_EMAIL,
|
||||
LEGAL_WEBSITE,
|
||||
} from "~/lib/legal";
|
||||
import { LEGAL_PRIVACY_EMAIL, LEGAL_WEBSITE } from "~/lib/legal";
|
||||
import { brand } from "~/lib/branding";
|
||||
import {
|
||||
LegalDocument,
|
||||
@@ -16,9 +13,9 @@ const sections: LegalSection[] = [
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
This Privacy Policy explains how {brand.name} collects, uses, and protects
|
||||
information when you use our invoicing platform, including the web app and mobile
|
||||
app (the “Service”).
|
||||
This Privacy Policy explains how {brand.name} collects, uses, and
|
||||
protects information when you use our invoicing platform, including
|
||||
the web app and mobile app (the “Service”).
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
If you have questions about this policy, email us at{" "}
|
||||
@@ -33,19 +30,21 @@ const sections: LegalSection[] = [
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
When you create an account and use the Service, you provide information such as
|
||||
your name, email address, business details, client records, invoice content, and
|
||||
time entries. This is the data you enter to run your invoicing workflow.
|
||||
When you create an account and use the Service, you provide
|
||||
information such as your name, email address, business details, client
|
||||
records, invoice content, and time entries. This is the data you enter
|
||||
to run your invoicing workflow.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
You may also add payment instructions that appear on invoices, such as bank
|
||||
transfer details. We do not process card payments on your behalf.
|
||||
You may also add payment instructions that appear on invoices, such as
|
||||
bank transfer details. We do not process card payments on your behalf.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We also collect some technical information automatically so the Service stays
|
||||
secure and reliable. This can include your IP address, device and browser or app
|
||||
details, log and diagnostic data, and session cookies that keep you signed in.
|
||||
Some deployments may use optional, privacy-focused analytics.
|
||||
We also collect some technical information automatically so the
|
||||
Service stays secure and reliable. This can include your IP address,
|
||||
device and browser or app details, log and diagnostic data, and
|
||||
session cookies that keep you signed in. Some deployments may use
|
||||
optional, privacy-focused analytics.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
@@ -56,9 +55,10 @@ const sections: LegalSection[] = [
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
We use your information to provide and operate the Service, authenticate your
|
||||
account, send transactional messages such as password resets, respond to support
|
||||
requests, monitor security and performance, and meet legal obligations.
|
||||
We use your information to provide and operate the Service,
|
||||
authenticate your account, send transactional messages such as
|
||||
password resets, respond to support requests, monitor security and
|
||||
performance, and meet legal obligations.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
@@ -69,24 +69,24 @@ const sections: LegalSection[] = [
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
We do not sell your personal information. We share it only when needed to run the
|
||||
Service or when the law requires it.
|
||||
We do not sell your personal information. We share it only when needed
|
||||
to run the Service or when the law requires it.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We work with service providers that host our infrastructure, deliver transactional
|
||||
email, support single sign-on when enabled on your instance, and optionally provide
|
||||
privacy-focused analytics. These vendors may process your information only to
|
||||
perform services for us.
|
||||
We work with service providers that host our infrastructure, deliver
|
||||
transactional email, support single sign-on when enabled on your
|
||||
instance, and optionally provide privacy-focused analytics. These
|
||||
vendors may process your information only to perform services for us.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We may disclose information if we believe it is reasonably necessary to comply with
|
||||
law, respond to a valid legal request, or protect the security and integrity of the
|
||||
Service.
|
||||
We may disclose information if we believe it is reasonably necessary
|
||||
to comply with law, respond to a valid legal request, or protect the
|
||||
security and integrity of the Service.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
If we are involved in a merger, acquisition, or sale of assets, your information
|
||||
may be transferred as part of that transaction, subject to continued protection
|
||||
consistent with this policy.
|
||||
If we are involved in a merger, acquisition, or sale of assets, your
|
||||
information may be transferred as part of that transaction, subject to
|
||||
continued protection consistent with this policy.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
@@ -97,14 +97,25 @@ const sections: LegalSection[] = [
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
We use reasonable safeguards to protect information, including encryption in
|
||||
transit, access controls, and secure authentication. No method of transmission or
|
||||
storage is completely secure.
|
||||
We use reasonable safeguards to protect information, including
|
||||
encryption in transit, access controls, and secure authentication. No
|
||||
method of transmission or storage is completely secure.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We retain information for as long as you have an account or as needed to provide
|
||||
the Service. We may keep certain records longer when required by law or for
|
||||
legitimate purposes such as fraud prevention or dispute resolution.
|
||||
We retain information for as long as you have an account or as needed
|
||||
to provide the Service. We may keep certain records longer when
|
||||
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>
|
||||
</>
|
||||
),
|
||||
@@ -115,13 +126,16 @@ const sections: LegalSection[] = [
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
Depending on where you live, you may have the right to access, correct, delete, or
|
||||
export your personal information, or to object to or restrict certain processing.
|
||||
Depending on where you live, you may have the right to access,
|
||||
correct, delete, or export your personal information, or to object to
|
||||
or restrict certain processing.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
To exercise these rights, contact us at{" "}
|
||||
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>. We will
|
||||
respond within a reasonable timeframe and as required by applicable law.
|
||||
You can delete your account directly in Settings. To exercise another
|
||||
privacy right, or if you cannot access your account, contact us at{" "}
|
||||
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>.
|
||||
We will respond within a reasonable timeframe and as required by
|
||||
applicable law.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
@@ -132,13 +146,13 @@ const sections: LegalSection[] = [
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
We use cookies and similar technologies to keep you signed in, remember
|
||||
preferences such as theme, and, when enabled on a deployment, measure usage with
|
||||
privacy-focused analytics.
|
||||
We use cookies and similar technologies to keep you signed in,
|
||||
remember preferences such as theme, and, when enabled on a deployment,
|
||||
measure usage with privacy-focused analytics.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
You can control cookies through your browser settings. If you disable essential
|
||||
cookies, some parts of the Service may not work correctly.
|
||||
You can control cookies through your browser settings. If you disable
|
||||
essential cookies, some parts of the Service may not work correctly.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
@@ -149,22 +163,26 @@ const sections: LegalSection[] = [
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
The Service may link to third-party websites or integrate with services you
|
||||
configure, such as single sign-on. Those services have their own privacy policies,
|
||||
and we are not responsible for their practices.
|
||||
The Service may link to third-party websites or integrate with
|
||||
services you configure, such as single sign-on. Those services have
|
||||
their own privacy policies, and we are not responsible for their
|
||||
practices.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
The Service is not intended for children under 13. If you believe a child has
|
||||
provided us personal information, contact us and we will delete it.
|
||||
The Service is not intended for children under 13. If you believe a
|
||||
child has provided us personal information, contact us and we will
|
||||
delete it.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
Your information may be processed in countries other than your own. Where required,
|
||||
we use appropriate safeguards for international transfers.
|
||||
Your information may be processed in countries other than your own.
|
||||
Where required, we use appropriate safeguards for international
|
||||
transfers.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We may update this policy from time to time. If we make material changes, we will
|
||||
post the updated policy on the Service and may notify you by email. Continued use
|
||||
after changes take effect means you accept the updated policy.
|
||||
We may update this policy from time to time. If we make material
|
||||
changes, we will post the updated policy on the Service and may notify
|
||||
you by email. Continued use after changes take effect means you accept
|
||||
the updated policy.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
@@ -176,7 +194,8 @@ const sections: LegalSection[] = [
|
||||
<>
|
||||
<LegalParagraph>
|
||||
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">
|
||||
{LEGAL_WEBSITE.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { and, count, eq, isNull } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { logAuditEvent } from "~/lib/audit-log";
|
||||
import { deleteObject } from "~/lib/object-storage";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
import { requireAdmin } from "~/server/api/require-admin";
|
||||
import {
|
||||
accounts,
|
||||
apiKeys,
|
||||
auditLog,
|
||||
users,
|
||||
clients,
|
||||
businesses,
|
||||
@@ -20,10 +23,14 @@ import {
|
||||
invoicePayments,
|
||||
invoiceTemplates,
|
||||
expenses,
|
||||
expenseReceipts,
|
||||
recurringInvoices,
|
||||
recurringInvoiceItems,
|
||||
sessions,
|
||||
ssoProviders,
|
||||
timeEntries,
|
||||
platformSettings,
|
||||
verificationTokens,
|
||||
} from "~/server/db/schema";
|
||||
import {
|
||||
colorModeSchema,
|
||||
@@ -703,7 +710,8 @@ 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: {
|
||||
client: { columns: { name: true } },
|
||||
@@ -721,7 +729,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const userExpenses = await ctx.db.query.expenses.findMany({
|
||||
where: eq(expenses.createdById, userId),
|
||||
@@ -1101,7 +1110,9 @@ export const settingsRouter = createTRPCRouter({
|
||||
...(input.user.animationSpeedMultiplier !== undefined && {
|
||||
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
|
||||
}),
|
||||
...(input.user.theme !== undefined && { theme: input.user.theme }),
|
||||
...(input.user.theme !== undefined && {
|
||||
theme: input.user.theme,
|
||||
}),
|
||||
...(input.user.onboardingCompletedAt !== undefined && {
|
||||
onboardingCompletedAt: input.user.onboardingCompletedAt,
|
||||
}),
|
||||
@@ -1137,7 +1148,9 @@ export const settingsRouter = createTRPCRouter({
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
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)
|
||||
deleteAllData: sessionProcedure
|
||||
// Permanently delete the signed-in account and all associated data.
|
||||
deleteAccount: sessionProcedure
|
||||
.input(
|
||||
z.object({
|
||||
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
|
||||
message: "You must type 'DELETE ALL DATA' to confirm",
|
||||
confirmText: z.string().refine((val) => val === "DELETE MY ACCOUNT", {
|
||||
message: "You must type 'DELETE MY ACCOUNT' to confirm",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
return await ctx.db.transaction(async (tx) => {
|
||||
// Delete in order due to foreign key constraints
|
||||
// 1. Invoice items (cascade should handle this, but being explicit)
|
||||
const userInvoiceIds = await tx
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.createdById, userId));
|
||||
const [receiptObjects, logoObjects] = await Promise.all([
|
||||
ctx.db
|
||||
.select({ storageKey: expenseReceipts.storageKey })
|
||||
.from(expenseReceipts)
|
||||
.innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
|
||||
.where(eq(expenses.createdById, userId)),
|
||||
ctx.db
|
||||
.select({ storageKey: businesses.logoStorageKey })
|
||||
.from(businesses)
|
||||
.where(eq(businesses.createdById, userId)),
|
||||
]);
|
||||
|
||||
if (userInvoiceIds.length > 0) {
|
||||
for (const invoice of userInvoiceIds) {
|
||||
// Delete uploaded personal data before removing its database pointers. If object
|
||||
// storage is unavailable, the account remains intact so the user can retry.
|
||||
await Promise.all(
|
||||
[...receiptObjects, ...logoObjects].flatMap(({ storageKey }) =>
|
||||
storageKey ? [deleteObject(storageKey)] : [],
|
||||
),
|
||||
);
|
||||
|
||||
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(invoiceItems)
|
||||
.where(eq(invoiceItems.invoiceId, invoice.id));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Invoices
|
||||
.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));
|
||||
|
||||
// 3. Clients
|
||||
await tx.delete(timeEntries).where(eq(timeEntries.createdById, userId));
|
||||
await tx
|
||||
.delete(invoiceTemplates)
|
||||
.where(eq(invoiceTemplates.createdById, userId));
|
||||
await tx.delete(clients).where(eq(clients.createdById, userId));
|
||||
|
||||
// 4. Businesses
|
||||
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 };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user