Archived
Add permanent account deletion
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user