Enhancement/cal 708 delete account (#1403)
* --WIP * --WIP * --WIP * added prisma migration and delete cascade for user * stripe customer removal and other --wip * --wip * added stripe user delete * removed log remnants * fixed signout import * cleanup * Changes requested * fixed common-json apostrophe * Simplifies account deletion logic and add e2e tests * Cleanup Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
parent
e5f8437282
commit
fac4de1144
12 changed files with 235 additions and 34 deletions
|
@ -1,7 +1,7 @@
|
||||||
import { ExclamationIcon } from "@heroicons/react/outline";
|
import { ExclamationIcon } from "@heroicons/react/outline";
|
||||||
import { CheckIcon } from "@heroicons/react/solid";
|
import { CheckIcon } from "@heroicons/react/solid";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import React, { PropsWithChildren } from "react";
|
import React, { PropsWithChildren, ReactNode } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ import { DialogClose, DialogContent } from "@components/Dialog";
|
||||||
import { Button } from "@components/ui/Button";
|
import { Button } from "@components/ui/Button";
|
||||||
|
|
||||||
export type ConfirmationDialogContentProps = {
|
export type ConfirmationDialogContentProps = {
|
||||||
|
confirmBtn?: ReactNode;
|
||||||
confirmBtnText?: string;
|
confirmBtnText?: string;
|
||||||
cancelBtnText?: string;
|
cancelBtnText?: string;
|
||||||
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||||
|
@ -21,6 +22,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
variety,
|
variety,
|
||||||
|
confirmBtn = null,
|
||||||
confirmBtnText = t("confirm"),
|
confirmBtnText = t("confirm"),
|
||||||
cancelBtnText = t("cancel"),
|
cancelBtnText = t("cancel"),
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
@ -33,34 +35,34 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
||||||
{variety && (
|
{variety && (
|
||||||
<div className="mr-3 mt-0.5">
|
<div className="mr-3 mt-0.5">
|
||||||
{variety === "danger" && (
|
{variety === "danger" && (
|
||||||
<div className="text-center p-2 rounded-full mx-auto bg-red-100">
|
<div className="p-2 mx-auto text-center bg-red-100 rounded-full">
|
||||||
<ExclamationIcon className="w-5 h-5 text-red-600" />
|
<ExclamationIcon className="w-5 h-5 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{variety === "warning" && (
|
{variety === "warning" && (
|
||||||
<div className="text-center p-2 rounded-full mx-auto bg-orange-100">
|
<div className="p-2 mx-auto text-center bg-orange-100 rounded-full">
|
||||||
<ExclamationIcon className="w-5 h-5 text-orange-600" />
|
<ExclamationIcon className="w-5 h-5 text-orange-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{variety === "success" && (
|
{variety === "success" && (
|
||||||
<div className="text-center p-2 rounded-full mx-auto bg-green-100">
|
<div className="p-2 mx-auto text-center bg-green-100 rounded-full">
|
||||||
<CheckIcon className="w-5 h-5 text-green-600" />
|
<CheckIcon className="w-5 h-5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<DialogPrimitive.Title className="font-cal text-xl font-bold text-gray-900">
|
<DialogPrimitive.Title className="text-xl font-bold text-gray-900 font-cal">
|
||||||
{title}
|
{title}
|
||||||
</DialogPrimitive.Title>
|
</DialogPrimitive.Title>
|
||||||
<DialogPrimitive.Description className="text-neutral-500 text-sm">
|
<DialogPrimitive.Description className="text-sm text-neutral-500">
|
||||||
{children}
|
{children}
|
||||||
</DialogPrimitive.Description>
|
</DialogPrimitive.Description>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 sm:mt-8 sm:flex sm:flex-row-reverse gap-x-2">
|
<div className="mt-5 sm:mt-8 sm:flex sm:flex-row-reverse gap-x-2">
|
||||||
<DialogClose onClick={onConfirm} asChild>
|
<DialogClose onClick={onConfirm} asChild>
|
||||||
<Button color="primary">{confirmBtnText}</Button>
|
{confirmBtn || <Button color="primary">{confirmBtnText}</Button>}
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button color="secondary">{cancelBtnText}</Button>
|
<Button color="secondary">{cancelBtnText}</Button>
|
||||||
|
|
|
@ -168,4 +168,45 @@ async function handleRefundError(opts: { event: CalendarEvent; reason: string; p
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userType = Prisma.validator<Prisma.UserArgs>()({
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
metadata: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type UserType = Prisma.UserGetPayload<typeof userType>;
|
||||||
|
export async function getStripeCustomerId(user: UserType): Promise<string | null> {
|
||||||
|
let customerId: string | null = null;
|
||||||
|
|
||||||
|
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
|
||||||
|
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
|
||||||
|
} else {
|
||||||
|
/* We fallback to finding the customer by email (which is not optimal) */
|
||||||
|
const customersReponse = await stripe.customers.list({
|
||||||
|
email: user.email,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
if (customersReponse.data[0]?.id) {
|
||||||
|
customerId = customersReponse.data[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteStripeCustomer(user: UserType): Promise<string | null> {
|
||||||
|
const customerId = await getStripeCustomerId(user);
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
console.warn("No stripe customer found for user:" + user.email);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
//delete stripe customer
|
||||||
|
const deletedCustomer = await stripe.customers.del(customerId);
|
||||||
|
|
||||||
|
return deletedCustomer.id;
|
||||||
|
}
|
||||||
|
|
||||||
export default stripe;
|
export default stripe;
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import stripe from "@ee/lib/stripe/server";
|
import stripe, { getStripeCustomerId } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
@ -33,20 +32,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
message: "User email not found",
|
message: "User email not found",
|
||||||
});
|
});
|
||||||
|
|
||||||
let customerId = "";
|
const customerId = await getStripeCustomerId(user);
|
||||||
|
|
||||||
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
|
|
||||||
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
|
|
||||||
} else {
|
|
||||||
/* We fallback to finding the customer by email (which is not optimal) */
|
|
||||||
const customersReponse = await stripe.customers.list({
|
|
||||||
email: user.email,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
if (customersReponse.data[0]?.id) {
|
|
||||||
customerId = customersReponse.data[0].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customerId)
|
if (!customerId)
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
|
|
|
@ -32,10 +32,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(405).json({ message: "Method Not Allowed" });
|
return res.status(405).json({ message: "Method Not Allowed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "DELETE") {
|
|
||||||
return res.status(405).json({ message: "Method Not Allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "PATCH") {
|
if (req.method === "PATCH") {
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
|
|
42
pages/api/user/me.ts
Normal file
42
pages/api/user/me.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { deleteStripeCustomer } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
|
import { getSession } from "@lib/auth";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const session = await getSession({ req });
|
||||||
|
|
||||||
|
if (!session?.user.id) {
|
||||||
|
return res.status(401).json({ message: "Not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== "DELETE") {
|
||||||
|
return res.status(405).json({ message: "Method Not Allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
// Get user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
rejectOnNotFound: true,
|
||||||
|
where: {
|
||||||
|
id: session.user?.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
metadata: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Delete from stripe
|
||||||
|
await deleteStripeCustomer(user).catch(console.warn);
|
||||||
|
// Delete from Cal
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: {
|
||||||
|
id: session?.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
import { InformationCircleIcon } from "@heroicons/react/outline";
|
import { InformationCircleIcon } from "@heroicons/react/outline";
|
||||||
|
import { TrashIcon } from "@heroicons/react/solid";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
|
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
|
@ -17,10 +19,11 @@ import prisma from "@lib/prisma";
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@components/Dialog";
|
||||||
import ImageUploader from "@components/ImageUploader";
|
import ImageUploader from "@components/ImageUploader";
|
||||||
import SettingsShell from "@components/SettingsShell";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import { TextField } from "@components/form/fields";
|
import { TextField } from "@components/form/fields";
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
|
@ -112,6 +115,19 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteAccount = async () => {
|
||||||
|
await fetch("/api/user/me", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error(`Error Removing user: ${props.user.id}, email: ${props.user.email} :`, e);
|
||||||
|
});
|
||||||
|
// signout;
|
||||||
|
signOut({ callbackUrl: "/auth/logout" });
|
||||||
|
};
|
||||||
|
|
||||||
const localeOptions = useMemo(() => {
|
const localeOptions = useMemo(() => {
|
||||||
return (router.locales || []).map((locale) => ({
|
return (router.locales || []).map((locale) => ({
|
||||||
value: locale,
|
value: locale,
|
||||||
|
@ -409,6 +425,34 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="font-bold leading-6 text-red-700 mt-7 text-md">{t("danger_zone")}</h3>
|
||||||
|
<div>
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="warn"
|
||||||
|
StartIcon={TrashIcon}
|
||||||
|
className="text-red-700 border-2 border-red-700"
|
||||||
|
data-testid="delete-account">
|
||||||
|
{t("delete_account")}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<ConfirmationDialogContent
|
||||||
|
variety="danger"
|
||||||
|
title={t("delete_account")}
|
||||||
|
confirmBtn={
|
||||||
|
<Button color="warn" data-testid="delete-account-confirm">
|
||||||
|
{t("confirm_delete_account")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onConfirm={() => deleteAccount()}>
|
||||||
|
{t("delete_account_confirmation_message")}
|
||||||
|
</ConfirmationDialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr className="mt-8" />
|
<hr className="mt-8" />
|
||||||
|
@ -460,6 +504,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
theme: true,
|
theme: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
|
metadata: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
27
playwright/auth/delete-account.test.ts
Normal file
27
playwright/auth/delete-account.test.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("Can delete user account", async ({ page }) => {
|
||||||
|
// Login to account to delete
|
||||||
|
await page.goto(`/auth/login`);
|
||||||
|
// Click input[name="email"]
|
||||||
|
await page.click('input[name="email"]');
|
||||||
|
// Fill input[name="email"]
|
||||||
|
await page.fill('input[name="email"]', `delete-me@example.com`);
|
||||||
|
// Press Tab
|
||||||
|
await page.press('input[name="email"]', "Tab");
|
||||||
|
// Fill input[name="password"]
|
||||||
|
await page.fill('input[name="password"]', "delete-me");
|
||||||
|
// Press Enter
|
||||||
|
await page.press('input[name="password"]', "Enter");
|
||||||
|
await page.waitForSelector("[data-testid=dashboard-shell]");
|
||||||
|
|
||||||
|
await page.goto(`/settings/profile`);
|
||||||
|
await page.click("[data-testid=delete-account]");
|
||||||
|
expect(page.locator(`[data-testid=delete-account-confirm]`)).toBeVisible();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForNavigation({ url: "/auth/logout" }),
|
||||||
|
await page.click("[data-testid=delete-account-confirm]"),
|
||||||
|
]);
|
||||||
|
expect(page.locator(`[id="modal-title"]`)).toHaveText("You've been logged out");
|
||||||
|
});
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Availability" DROP CONSTRAINT "Availability_userId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Credential" DROP CONSTRAINT "Credential_userId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Membership" DROP CONSTRAINT "Membership_userId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Schedule" DROP CONSTRAINT "Schedule_userId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "SelectedCalendar" DROP CONSTRAINT "SelectedCalendar_userId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Webhook" DROP CONSTRAINT "Webhook_userId_fkey";
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Availability" ADD CONSTRAINT "Availability_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SelectedCalendar" ADD CONSTRAINT "SelectedCalendar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -61,7 +61,7 @@ model Credential {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
type String
|
type String
|
||||||
key Json
|
key Json
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
userId Int?
|
userId Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,7 +156,7 @@ model Membership {
|
||||||
accepted Boolean @default(false)
|
accepted Boolean @default(false)
|
||||||
role MembershipRole
|
role MembershipRole
|
||||||
team Team @relation(fields: [teamId], references: [id])
|
team Team @relation(fields: [teamId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([userId, teamId])
|
@@id([userId, teamId])
|
||||||
}
|
}
|
||||||
|
@ -234,7 +234,7 @@ model Booking {
|
||||||
|
|
||||||
model Schedule {
|
model Schedule {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
userId Int?
|
userId Int?
|
||||||
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
||||||
eventTypeId Int?
|
eventTypeId Int?
|
||||||
|
@ -245,7 +245,7 @@ model Schedule {
|
||||||
model Availability {
|
model Availability {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
label String?
|
label String?
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
userId Int?
|
userId Int?
|
||||||
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
||||||
eventTypeId Int?
|
eventTypeId Int?
|
||||||
|
@ -256,7 +256,7 @@ model Availability {
|
||||||
}
|
}
|
||||||
|
|
||||||
model SelectedCalendar {
|
model SelectedCalendar {
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
userId Int
|
userId Int
|
||||||
integration String
|
integration String
|
||||||
externalId String
|
externalId String
|
||||||
|
@ -334,5 +334,5 @@ model Webhook {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
eventTriggers WebhookTriggerEvents[]
|
eventTriggers WebhookTriggerEvents[]
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
|
@ -550,6 +550,9 @@
|
||||||
"delete_event_type_description": "Are you sure you want to delete this event type? Anyone who you've shared this link with will no longer be able to book using it.",
|
"delete_event_type_description": "Are you sure you want to delete this event type? Anyone who you've shared this link with will no longer be able to book using it.",
|
||||||
"delete_event_type": "Delete Event Type",
|
"delete_event_type": "Delete Event Type",
|
||||||
"confirm_delete_event_type": "Yes, delete event type",
|
"confirm_delete_event_type": "Yes, delete event type",
|
||||||
|
"delete_account": "Delete account",
|
||||||
|
"confirm_delete_account": "Yes, delete account",
|
||||||
|
"delete_account_confirmation_message":"Are you sure you want to delete your Cal.com account? Anyone who you've shared your account link with will no longer be able to book using it and any preferences you have saved will be lost.",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"event_type_moved_successfully": "Event type has been moved successfully",
|
"event_type_moved_successfully": "Event type has been moved successfully",
|
||||||
|
|
|
@ -154,6 +154,17 @@ async function createTeamAndAddUsers(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
await createUserAndEventType({
|
||||||
|
user: {
|
||||||
|
email: "delete-me@example.com",
|
||||||
|
password: "delete-me",
|
||||||
|
username: "delete-me",
|
||||||
|
name: "delete-me",
|
||||||
|
plan: "FREE",
|
||||||
|
},
|
||||||
|
eventTypes: [],
|
||||||
|
});
|
||||||
|
|
||||||
await createUserAndEventType({
|
await createUserAndEventType({
|
||||||
user: {
|
user: {
|
||||||
email: "onboarding@example.com",
|
email: "onboarding@example.com",
|
||||||
|
|
|
@ -102,6 +102,19 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
return me;
|
return me;
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
.mutation("deleteMe", {
|
||||||
|
async resolve({ ctx }) {
|
||||||
|
// Remove me from Stripe
|
||||||
|
|
||||||
|
// Remove my account
|
||||||
|
await ctx.prisma.user.delete({
|
||||||
|
where: {
|
||||||
|
id: ctx.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
})
|
||||||
.mutation("away", {
|
.mutation("away", {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
away: z.boolean(),
|
away: z.boolean(),
|
||||||
|
|
Loading…
Reference in a new issue