From 95636f96a8a71ea4023a18a666bc1561d4ac51ff Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Mon, 20 Oct 2025 12:16:20 +0530 Subject: [PATCH] Feat: ability to delete the account and clean up all resources --- .../app/components/sidebar/nav-user.tsx | 2 +- apps/webapp/app/models/user.server.ts | 124 ++++++ apps/webapp/app/routes/api.v1.user.delete.tsx | 71 +++ apps/webapp/app/routes/settings.account.tsx | 216 ++++++++++ apps/webapp/app/routes/settings.billing.tsx | 406 +++++++++--------- apps/webapp/app/routes/settings.tsx | 3 +- apps/webapp/app/services/stripe.server.ts | 14 + 7 files changed, 639 insertions(+), 197 deletions(-) create mode 100644 apps/webapp/app/routes/api.v1.user.delete.tsx create mode 100644 apps/webapp/app/routes/settings.account.tsx diff --git a/apps/webapp/app/components/sidebar/nav-user.tsx b/apps/webapp/app/components/sidebar/nav-user.tsx index a346003..c41c43a 100644 --- a/apps/webapp/app/components/sidebar/nav-user.tsx +++ b/apps/webapp/app/components/sidebar/nav-user.tsx @@ -53,7 +53,7 @@ export function NavUser({ user }: { user: ExtendedUser }) { navigate("/settings/api")} + onClick={() => navigate("/settings/account")} > Settings diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index f8073c2..39d568a 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -238,3 +238,127 @@ export async function grantUserCloudAccess({ }, }); } + +export async function deleteUser(id: User["id"]) { + // Get user's workspace + const user = await prisma.user.findUnique({ + where: { id }, + include: { + Workspace: true, + }, + }); + + if (!user) { + throw new Error("User not found"); + } + + // If workspace exists, delete all workspace-related data + // Most models DON'T have onDelete: Cascade, so we must delete manually + if (user.Workspace) { + const workspaceId = user.Workspace.id; + + // 1. Delete nested conversation data + await prisma.conversationExecutionStep.deleteMany({ + where: { + conversationHistory: { + conversation: { workspaceId }, + }, + }, + }); + + await prisma.conversationHistory.deleteMany({ + where: { + conversation: { workspaceId }, + }, + }); + + await prisma.conversation.deleteMany({ + where: { workspaceId }, + }); + + // 2. Delete space patterns (nested under Space) + await prisma.spacePattern.deleteMany({ + where: { + space: { workspaceId }, + }, + }); + + await prisma.space.deleteMany({ + where: { workspaceId }, + }); + + // 3. Delete webhook delivery logs (nested under WebhookConfiguration) + await prisma.webhookDeliveryLog.deleteMany({ + where: { + webhookConfiguration: { workspaceId }, + }, + }); + + await prisma.webhookConfiguration.deleteMany({ + where: { workspaceId }, + }); + + // 4. Delete ingestion data + await prisma.ingestionQueue.deleteMany({ + where: { workspaceId }, + }); + + await prisma.ingestionRule.deleteMany({ + where: { workspaceId }, + }); + + // 5. Delete integration accounts + await prisma.integrationAccount.deleteMany({ + where: { workspaceId }, + }); + + await prisma.integrationDefinitionV2.deleteMany({ + where: { workspaceId }, + }); + + // 6. Delete recall logs + await prisma.recallLog.deleteMany({ + where: { workspaceId }, + }); + + // 7. Delete activities + await prisma.activity.deleteMany({ + where: { workspaceId }, + }); + + // 8. Delete MCP sessions + await prisma.mCPSession.deleteMany({ + where: { workspaceId }, + }); + + // 9. Delete billing history (nested under Subscription) + await prisma.billingHistory.deleteMany({ + where: { + subscription: { workspaceId }, + }, + }); + + await prisma.subscription.deleteMany({ + where: { workspaceId }, + }); + + // 10. Delete the workspace (this will CASCADE delete OAuth models automatically) + await prisma.workspace.delete({ + where: { id: workspaceId }, + }); + } + + // Delete user-specific data + await prisma.personalAccessToken.deleteMany({ + where: { userId: id }, + }); + + await prisma.userUsage.deleteMany({ + where: { userId: id }, + }); + + // Finally, delete the user + return prisma.user.delete({ + where: { id }, + }); +} diff --git a/apps/webapp/app/routes/api.v1.user.delete.tsx b/apps/webapp/app/routes/api.v1.user.delete.tsx new file mode 100644 index 0000000..dc21c46 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.user.delete.tsx @@ -0,0 +1,71 @@ +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import { requireUser } from "~/services/session.server"; +import { deleteUser, getUserById } from "~/models/user.server"; +import { sessionStorage } from "~/services/sessionStorage.server"; +import { cancelSubscriptionImmediately } from "~/services/stripe.server"; +import { isBillingEnabled } from "~/config/billing.server"; +import { prisma } from "~/db.server"; +import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const { action, loader } = createHybridActionApiRoute( + { + corsStrategy: "all", + allowJWT: true, + method: "DELETE", + authorization: { + action: "delete", + }, + }, + async ({ authentication, request }) => { + try { + const user = await getUserById(authentication.userId); + + if (!user || !user.Workspace) { + throw new Error("No user or workspace found"); + } + + // If billing is enabled, cancel any active subscriptions + if (isBillingEnabled()) { + const subscription = await prisma.subscription.findUnique({ + where: { workspaceId: user?.Workspace?.id! }, + }); + + if (subscription?.stripeSubscriptionId) { + try { + await cancelSubscriptionImmediately( + subscription.stripeSubscriptionId, + ); + } catch (error) { + console.error("Failed to cancel Stripe subscription:", error); + // Continue with deletion even if Stripe cancellation fails + } + } + } + + // Delete the user and all associated data + await deleteUser(user.id); + + // Destroy the session + const session = await sessionStorage.getSession( + request.headers.get("Cookie"), + ); + + return json( + { success: true }, + { + headers: { + "Set-Cookie": await sessionStorage.destroySession(session), + }, + }, + ); + } catch (error) { + console.error("Error deleting user:", error); + return json( + { error: "Failed to delete account. Please try again." }, + { status: 500 }, + ); + } + }, +); + +export { action, loader }; diff --git a/apps/webapp/app/routes/settings.account.tsx b/apps/webapp/app/routes/settings.account.tsx new file mode 100644 index 0000000..5042785 --- /dev/null +++ b/apps/webapp/app/routes/settings.account.tsx @@ -0,0 +1,216 @@ +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { useLoaderData, useFetcher, useNavigate } from "@remix-run/react"; +import { requireUser } from "~/services/session.server"; +import { Card } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { AlertTriangle } from "lucide-react"; +import { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { SettingSection } from "~/components/setting-section"; + +interface SuccessDataResponse { + success: boolean; +} + +interface ErrorDataResponse { + error: string; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + + return json({ + user, + }); +}; + +export default function AccountSettings() { + const { user } = useLoaderData(); + const fetcher = useFetcher(); + const navigate = useNavigate(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [confirmText, setConfirmText] = useState(""); + const isDeleting = fetcher.state === "submitting"; + + const handleDeleteAccount = () => { + fetcher.submit( + {}, + { + method: "DELETE", + action: "/api/v1/user/delete", + }, + ); + }; + + // Redirect to login after successful deletion + if (fetcher.data && "success" in fetcher.data && fetcher.data.success) { + setTimeout(() => { + navigate("/login"); + }, 1000); + } + + const canDelete = confirmText === user.email; + + return ( +
+ + <> + {/* Account Information */} +
+

Account Information

+ +
+
+ +

{user.email}

+
+ {user.name && ( +
+ +

{user.name}

+
+ )} + {user.displayName && ( +
+ +

{user.displayName}

+
+ )} +
+ +

+ {new Date(user.createdAt).toLocaleDateString()} +

+
+
+
+
+ + {/* Danger Zone */} +
+

+ Danger Zone +

+ +
+ +
+

+ Delete Account +

+

+ Permanently delete your account and all associated data. + This action cannot be undone. +

+
    +
  • All your memories and conversations will be deleted
  • +
  • All integration connections will be removed
  • +
  • All API keys and webhooks will be revoked
  • +
  • + Your workspace and all its data will be permanently lost +
  • +
  • Active subscriptions will be cancelled immediately
  • +
+ +
+
+
+
+ +
+ + {/* Delete Confirmation Dialog */} + + + + Are you absolutely sure? + +
+

+ This action cannot be undone. This will + permanently delete your account and remove all your data from + our servers. +

+
+ + setConfirmText(e.target.value)} + placeholder="Enter your email" + className="mt-2" + autoComplete="off" + /> +
+
+
+
+ + { + setConfirmText(""); + }} + disabled={isDeleting} + > + Cancel + + + {isDeleting ? "Deleting..." : "Delete Account Permanently"} + + +
+
+ + {/* Success Message */} + {fetcher.data && "success" in fetcher.data && fetcher.data.success && ( +
+ Account deleted successfully. Redirecting... +
+ )} + + {/* Error Message */} + {fetcher.data && "error" in fetcher.data && ( +
+ {fetcher.data.error} +
+ )} +
+ ); +} diff --git a/apps/webapp/app/routes/settings.billing.tsx b/apps/webapp/app/routes/settings.billing.tsx index 69c913b..b2fc0df 100644 --- a/apps/webapp/app/routes/settings.billing.tsx +++ b/apps/webapp/app/routes/settings.billing.tsx @@ -36,6 +36,7 @@ import { } from "~/components/ui/alert-dialog"; import { prisma } from "~/db.server"; import { isBillingEnabled } from "~/config/billing.server"; +import { SettingSection } from "~/components/setting-section"; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = await requireUser(request); @@ -230,218 +231,233 @@ export default function BillingSettings() { } return ( -
- {/* Header */} -
-

Billing

-

- Manage your subscription, usage, and billing history -

-
+
+ + <> + {/* Usage Section */} +
+

Current Usage

- {/* Usage Section */} -
-

Current Usage

- -
- {/* Credits Card */} - -
- Credits - -
-
- - {usageSummary.credits.available} - - - {" "} - / {usageSummary.credits.monthly} - -
- -

- {usageSummary.credits.percentageUsed}% used this period -

-
- - {/* Usage Breakdown */} - -
- - Usage Breakdown - - -
-
-
- Facts - - {usageSummary.usage.episodes} - -
-
- Searches - - {usageSummary.usage.searches} - -
-
- Chat - {usageSummary.usage.chat} -
-
-
- - {/* Billing Cycle */} - -
- - Billing Cycle - - -
-
- - {usageSummary.billingCycle.daysRemaining} - - days left -
-

- Resets on{" "} - {new Date(usageSummary.billingCycle.end).toLocaleDateString()} -

-
-
- - {/* Overage Warning */} - {usageSummary.credits.overage > 0 && ( - -
- -
-

- Overage Usage Detected -

-

- You've used {usageSummary.credits.overage} additional credits - beyond your monthly allocation. - {usageSummary.overage.enabled && - usageSummary.overage.pricePerCredit && ( - <> - {" "} - This will cost $ - {( - usageSummary.credits.overage * - usageSummary.overage.pricePerCredit - ).toFixed(2)}{" "} - extra this month. - - )} +

+ {/* Credits Card */} + +
+ Credits + +
+
+ + {usageSummary.credits.available} + + + {" "} + / {usageSummary.credits.monthly} + +
+ +

+ {usageSummary.credits.percentageUsed}% used this period

-
-
- - )} -
+
- {/* Plan Section */} -
-
-

Plan

- -
- - -
-
-
-

{usageSummary.plan.name}

- - {usageSummary.plan.type} - -
-

- {usageSummary.credits.monthly} credits/month - {usageSummary.overage.enabled && ( - <> + ${usageSummary.overage.pricePerCredit}/credit overage - )} -

- {subscription?.status === "CANCELED" && - subscription.planType !== "FREE" && ( -
- -

- Downgrading to FREE plan on{" "} - - {new Date( - subscription.currentPeriodEnd, - ).toLocaleDateString()} - - . Your current credits and plan will remain active until - then. -

+ {/* Usage Breakdown */} + +
+ + Usage Breakdown + + +
+
+
+ Facts + + {usageSummary.usage.episodes} +
- )} +
+ Searches + + {usageSummary.usage.searches} + +
+
+ Chat + + {usageSummary.usage.chat} + +
+
+
+ + {/* Billing Cycle */} + +
+ + Billing Cycle + + +
+
+ + {usageSummary.billingCycle.daysRemaining} + + days left +
+

+ Resets on{" "} + {new Date(usageSummary.billingCycle.end).toLocaleDateString()} +

+
-
- -
- {/* Invoices Section */} -
-

Invoices

- - {billingHistory.length === 0 ? ( - -

No invoices yet

-
- ) : ( - -
- {billingHistory.map((invoice) => ( -
+ {/* Overage Warning */} + {usageSummary.credits.overage > 0 && ( + +
+
-

- {new Date(invoice.periodStart).toLocaleDateString()} -{" "} - {new Date(invoice.periodEnd).toLocaleDateString()} +

+ Overage Usage Detected +

+

+ You've used {usageSummary.credits.overage} additional + credits beyond your monthly allocation. + {usageSummary.overage.enabled && + usageSummary.overage.pricePerCredit && ( + <> + {" "} + This will cost $ + {( + usageSummary.credits.overage * + usageSummary.overage.pricePerCredit + ).toFixed(2)}{" "} + extra this month. + + )}

-
-

- ${invoice.totalAmount.toFixed(2)} -

+
+ + )} +
+ + {/* Plan Section */} +
+
+

Plan

+ +
+ + +
+
+
+

+ {usageSummary.plan.name} +

- {invoice.stripePaymentStatus || "pending"} + {usageSummary.plan.type}
+

+ {usageSummary.credits.monthly} credits/month + {usageSummary.overage.enabled && ( + <> + {" "} + + ${usageSummary.overage.pricePerCredit}/credit overage + + )} +

+ {subscription?.status === "CANCELED" && + subscription.planType !== "FREE" && ( +
+ +

+ Downgrading to FREE plan on{" "} + + {new Date( + subscription.currentPeriodEnd, + ).toLocaleDateString()} + + . Your current credits and plan will remain active + until then. +

+
+ )}
- ))} -
-
- )} -
+
+ +
+ + {/* Invoices Section */} +
+

Invoices

+ + {billingHistory.length === 0 ? ( + +

+ No invoices yet +

+
+ ) : ( + +
+ {billingHistory.map((invoice) => ( +
+
+

+ {new Date(invoice.periodStart).toLocaleDateString()} -{" "} + {new Date(invoice.periodEnd).toLocaleDateString()} +

+
+
+

+ ${invoice.totalAmount.toFixed(2)} +

+ + {invoice.stripePaymentStatus || "pending"} + +
+
+ ))} +
+
+ )} +
+ + {/* Plans Modal */} diff --git a/apps/webapp/app/routes/settings.tsx b/apps/webapp/app/routes/settings.tsx index d30767b..26f3f70 100644 --- a/apps/webapp/app/routes/settings.tsx +++ b/apps/webapp/app/routes/settings.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Code, Webhook, Cable, CreditCard } from "lucide-react"; +import { ArrowLeft, Code, Webhook, Cable, CreditCard, User } from "lucide-react"; import { Sidebar, @@ -41,6 +41,7 @@ export default function Settings() { const data = { nav: [ // { name: "Workspace", icon: Building }, + { name: "Account", icon: User }, { name: "Billing", icon: CreditCard }, { name: "API", icon: Code }, { name: "Webhooks", icon: Webhook }, diff --git a/apps/webapp/app/services/stripe.server.ts b/apps/webapp/app/services/stripe.server.ts index 8ad571f..2758038 100644 --- a/apps/webapp/app/services/stripe.server.ts +++ b/apps/webapp/app/services/stripe.server.ts @@ -170,6 +170,20 @@ export async function cancelSubscription(workspaceId: string): Promise { }); } +/** + * Cancel a subscription immediately (for account deletion) + */ +export async function cancelSubscriptionImmediately( + subscriptionId: string, +): Promise { + if (!stripe || !isStripeConfigured()) { + throw new Error("Stripe is not configured"); + } + + // Cancel immediately + await stripe.subscriptions.cancel(subscriptionId); +} + /** * Reactivate a canceled subscription */