Skip to content

Commit 0794235

Browse files
committed
feat: add deleteUser endpoint.
1 parent e5188b6 commit 0794235

File tree

14 files changed

+336
-11
lines changed

14 files changed

+336
-11
lines changed

jest.setup.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,18 @@
11
import "@testing-library/jest-dom";
2+
import React from "react";
3+
4+
// Polyfill fetch for tests
5+
if (!global.fetch) {
6+
global.fetch = jest.fn();
7+
}
8+
9+
// Mock @iconify/react Icon component
10+
jest.mock("@iconify/react", () => ({
11+
Icon: ({ icon, ...props }: any) => {
12+
return React.createElement("span", {
13+
"data-testid": "icon",
14+
"data-icon": icon,
15+
...props,
16+
});
17+
},
18+
}));
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- DropForeignKey
2+
ALTER TABLE "ampdresume"."Position" DROP CONSTRAINT "Position_companyId_fkey";
3+
4+
-- DropForeignKey
5+
ALTER TABLE "ampdresume"."Project" DROP CONSTRAINT "Project_positionId_fkey";
6+
7+
-- AddForeignKey
8+
ALTER TABLE "ampdresume"."Project" ADD CONSTRAINT "Project_positionId_fkey" FOREIGN KEY ("positionId") REFERENCES "ampdresume"."Position"("id") ON DELETE CASCADE ON UPDATE CASCADE;
9+
10+
-- AddForeignKey
11+
ALTER TABLE "ampdresume"."Position" ADD CONSTRAINT "Position_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "ampdresume"."Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Please do not edit this file manually
22
# It should be added in your version-control system (e.g., Git)
3-
provider = "postgresql"
3+
provider = "postgresql"

prisma/schema.prisma

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ model Project {
166166
positionId String
167167
/// Used for sorting projects
168168
sortIndex Int @default(0)
169-
position Position @relation(fields: [positionId], references: [id])
169+
position Position @relation(fields: [positionId], references: [id], onDelete: Cascade)
170170
skillsForProject SkillForProject[]
171171
172172
@@schema("ampdresume")
@@ -180,7 +180,7 @@ model Position {
180180
startDate DateTime
181181
endDate DateTime?
182182
companyId String
183-
company Company @relation(fields: [companyId], references: [id])
183+
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
184184
projects Project[]
185185
186186
@@schema("ampdresume")

src/app/edit/profile/__snapshots__/page.test.tsx.snap

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,45 @@ exports[`Page renders correctly for authenticated users 1`] = `
799799
Save
800800
</button>
801801
</div>
802+
<div
803+
class="MuiBox-root css-1txpdkw"
804+
>
805+
<h6
806+
class="MuiTypography-root MuiTypography-h6 css-1hd3wng-MuiTypography-root"
807+
>
808+
Danger Zone
809+
</h6>
810+
<div
811+
class="MuiBox-root css-0"
812+
>
813+
<button
814+
class="MuiButtonBase-root MuiButton-root MuiButton-outlined MuiButton-outlinedSecondary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButton-colorSecondary MuiButton-root MuiButton-outlined MuiButton-outlinedSecondary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButton-colorSecondary css-1xpw6mv-MuiButtonBase-root-MuiButton-root"
815+
tabindex="0"
816+
type="button"
817+
>
818+
Delete Account
819+
</button>
820+
<button
821+
aria-label="Permanently delete your account and all data"
822+
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-lk7kvc-MuiButtonBase-root-MuiIconButton-root"
823+
data-mui-internal-clone-element="true"
824+
tabindex="0"
825+
type="button"
826+
>
827+
<svg
828+
aria-hidden="true"
829+
class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-120dh41-MuiSvgIcon-root"
830+
data-testid="InfoOutlinedIcon"
831+
focusable="false"
832+
viewBox="0 0 24 24"
833+
>
834+
<path
835+
d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
836+
/>
837+
</svg>
838+
</button>
839+
</div>
840+
</div>
802841
</form>
803842
</div>
804843
</div>

src/app/edit/profile/components/AccountForm.test.tsx

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { fireEvent, render, waitFor } from "@testing-library/react";
44

55
import { AccountForm } from "./AccountForm";
66
import React from "react";
7+
import { SessionProvider } from "next-auth/react";
78
import { useIsDesktop } from "@/hooks/useIsDesktop";
89

910
jest.mock("@/hooks/useIsDesktop", () => ({
@@ -19,6 +20,30 @@ jest.mock("./SocialsForm", () => ({
1920
SocialsForm: () => <div>SocialsForm</div>,
2021
}));
2122

23+
jest.mock("@/graphql/deleteUser", () => ({
24+
deleteUser: jest.fn(),
25+
}));
26+
27+
jest.mock("next-auth/react", () => ({
28+
...jest.requireActual("next-auth/react"),
29+
useSession: () => ({
30+
data: {
31+
user: {
32+
id: "test-user-id",
33+
34+
},
35+
},
36+
status: "authenticated",
37+
}),
38+
signOut: jest.fn(),
39+
}));
40+
41+
jest.mock("next/navigation", () => ({
42+
useRouter: () => ({
43+
push: jest.fn(),
44+
}),
45+
}));
46+
2247
const mockProps = {
2348
name: "John Doe",
2449
slug: "john-doe",
@@ -30,14 +55,18 @@ const mockProps = {
3055
siteImage: "https://example.com/image.png",
3156
};
3257

58+
const renderWithSession = (component: React.ReactElement) => {
59+
return render(<SessionProvider>{component}</SessionProvider>);
60+
};
61+
3362
describe("AccountForm", () => {
3463
beforeEach(() => {
3564
jest.clearAllMocks();
3665
(useIsDesktop as jest.Mock).mockReturnValue(true);
3766
});
3867

3968
it("renders correctly", () => {
40-
const { container, getByLabelText } = render(<AccountForm {...mockProps} />);
69+
const { container, getByLabelText } = renderWithSession(<AccountForm {...mockProps} />);
4170

4271
expect(getByLabelText("Full Name")).toBeInTheDocument();
4372
expect(getByLabelText("URL Name")).toBeInTheDocument();
@@ -49,7 +78,7 @@ describe("AccountForm", () => {
4978
});
5079

5180
it("handles input changes and validation", async () => {
52-
const { getByLabelText, getByText } = render(<AccountForm {...mockProps} />);
81+
const { getByLabelText, getByText } = renderWithSession(<AccountForm {...mockProps} />);
5382

5483
const slugInput = getByLabelText("URL Name");
5584
fireEvent.change(slugInput, { target: { value: "invalid slug" } });
@@ -69,7 +98,7 @@ describe("AccountForm", () => {
6998
}),
7099
) as jest.Mock;
71100

72-
const { getByLabelText, getByText } = render(<AccountForm {...mockProps} />);
101+
const { getByLabelText, getByText } = renderWithSession(<AccountForm {...mockProps} />);
73102

74103
fireEvent.change(getByLabelText("Full Name"), { target: { value: "Jane Doe" } });
75104
fireEvent.change(getByLabelText("URL Name"), { target: { value: "jane-doe" } });
@@ -90,7 +119,7 @@ describe("AccountForm", () => {
90119
}),
91120
) as jest.Mock;
92121

93-
const { getByLabelText, getByText } = render(<AccountForm {...mockProps} />);
122+
const { getByLabelText, getByText } = renderWithSession(<AccountForm {...mockProps} />);
94123

95124
fireEvent.change(getByLabelText("Full Name"), { target: { value: "Jane Doe" } });
96125
fireEvent.change(getByLabelText("URL Name"), { target: { value: "jane-doe" } });
@@ -104,7 +133,7 @@ describe("AccountForm", () => {
104133
});
105134

106135
it("displays error message on form submission failure for invalid name", async () => {
107-
const { getByLabelText, getByText } = render(<AccountForm {...mockProps} />);
136+
const { getByLabelText, getByText } = renderWithSession(<AccountForm {...mockProps} />);
108137

109138
fireEvent.change(getByLabelText("Full Name"), { target: { value: " " } });
110139
fireEvent.click(getByText("Save"));
@@ -115,7 +144,7 @@ describe("AccountForm", () => {
115144
});
116145

117146
it("displays error message on form submission failure for invalid slug", async () => {
118-
const { getByLabelText, getByText } = render(<AccountForm {...mockProps} />);
147+
const { getByLabelText, getByText } = renderWithSession(<AccountForm {...mockProps} />);
119148

120149
fireEvent.change(getByLabelText("URL Name"), { target: { value: " " } });
121150
fireEvent.click(getByText("Save"));
@@ -126,7 +155,7 @@ describe("AccountForm", () => {
126155
});
127156

128157
it("displays error message on form submission failure for invalid email", async () => {
129-
const { getByLabelText, getByText } = render(<AccountForm {...mockProps} />);
158+
const { getByLabelText, getByText } = renderWithSession(<AccountForm {...mockProps} />);
130159

131160
const emailInput = getByLabelText("Display Email");
132161
fireEvent.change(emailInput, { target: { value: "invalid-email" } });

src/app/edit/profile/components/AccountForm.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import React, { useEffect, useState } from "react";
66

77
import AccountBoxIcon from "@mui/icons-material/AccountBox";
88
import BadgeIcon from "@mui/icons-material/Badge";
9+
import { DeleteWithConfirmation } from "../../components/DeleteWithConfirmation";
910
import LanguageIcon from "@mui/icons-material/Language";
1011
import LinkIcon from "@mui/icons-material/Link";
1112
import { LoadingOverlay } from "@/components/LoadingOverlay";
@@ -15,7 +16,11 @@ import { MessageDialog } from "@/components/MessageDialog";
1516
import { SocialsForm } from "./SocialsForm";
1617
import TocIcon from "@mui/icons-material/Toc";
1718
import { UserAssetInput } from "../../components/UserAssetInput";
19+
import { deleteUser } from "@/graphql/deleteUser";
20+
import { signOut } from "next-auth/react";
1821
import { useIsDesktop } from "@/hooks/useIsDesktop";
22+
import { useRouter } from "next/navigation";
23+
import { useSession } from "next-auth/react";
1924

2025
const AccountForm = ({
2126
name,
@@ -50,8 +55,11 @@ const AccountForm = ({
5055
const [loading, setLoading] = useState(false);
5156
const [message, setMessage] = useState("");
5257
const [showSlugPopup, setShowSlugPopup] = useState(slug.length === 0);
58+
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
5359
const isDesktop = useIsDesktop();
5460
const slugInputRef = React.useRef<HTMLInputElement>(null);
61+
const { data: session } = useSession();
62+
const router = useRouter();
5563

5664
const [siteImageUrl, setSiteImageUrl] = useState(siteImage);
5765

@@ -161,14 +169,39 @@ const AccountForm = ({
161169
setTimeout(() => slugInputRef.current?.focus(), 100);
162170
};
163171

172+
const handleDeleteAccount = async () => {
173+
if (!session?.user?.id) {
174+
setMessage("You need to be signed in to delete your account.");
175+
return;
176+
}
177+
178+
setIsDeletingAccount(true);
179+
setLoading(true);
180+
181+
try {
182+
await deleteUser({ userId: session.user.id });
183+
184+
// Clear auth and redirect to homepage
185+
await signOut({ callbackUrl: "/" });
186+
router.push("/");
187+
} catch {
188+
setMessage("Failed to delete account. Please try again.");
189+
setIsDeletingAccount(false);
190+
setLoading(false);
191+
}
192+
};
193+
164194
return (
165195
<Box
166196
sx={{
167197
display: "flex",
168198
flexDirection: "column",
169199
}}
170200
>
171-
<LoadingOverlay open={loading} message="Saving..." />
201+
<LoadingOverlay
202+
open={loading}
203+
message={isDeletingAccount ? "Deleting account..." : "Saving..."}
204+
/>
172205
<MessageDialog
173206
open={message?.length > 0}
174207
message={message}
@@ -401,6 +434,40 @@ const AccountForm = ({
401434
Save
402435
</Button>
403436
</Box>
437+
<Box
438+
sx={{
439+
display: "flex",
440+
justifyContent: "center",
441+
alignItems: "center",
442+
flexDirection: "column",
443+
padding: "32px 0",
444+
borderTop: "1px solid",
445+
borderColor: "divider",
446+
}}
447+
>
448+
<Typography variant="h6" sx={{ mb: 2 }}>
449+
Danger Zone
450+
</Typography>
451+
<DeleteWithConfirmation
452+
onConfirmDelete={handleDeleteAccount}
453+
buttonLabel="Delete Account"
454+
tooltip="Permanently delete your account and all data"
455+
dialogTitle="Delete Account"
456+
dialogMessage={
457+
<Typography>
458+
Are you sure you want to delete your account? This action cannot be undone and will
459+
permanently remove:
460+
<br />• Your profile and all personal information
461+
<br />• All your skills, projects, work experience, and education
462+
<br />• Your authentication history
463+
<br />• All associated data
464+
</Typography>
465+
}
466+
confirmLabel="Yes, Delete My Account"
467+
cancelLabel="Cancel"
468+
disabled={isDeletingAccount}
469+
/>
470+
</Box>
404471
</Box>
405472
</Box>
406473
);

src/app/edit/profile/components/__snapshots__/AccountForm.test.tsx.snap

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,45 @@ exports[`AccountForm renders correctly 1`] = `
630630
Save
631631
</button>
632632
</div>
633+
<div
634+
class="MuiBox-root css-1txpdkw"
635+
>
636+
<h6
637+
class="MuiTypography-root MuiTypography-h6 css-1hd3wng-MuiTypography-root"
638+
>
639+
Danger Zone
640+
</h6>
641+
<div
642+
class="MuiBox-root css-0"
643+
>
644+
<button
645+
class="MuiButtonBase-root MuiButton-root MuiButton-outlined MuiButton-outlinedSecondary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButton-colorSecondary MuiButton-root MuiButton-outlined MuiButton-outlinedSecondary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButton-colorSecondary css-1xpw6mv-MuiButtonBase-root-MuiButton-root"
646+
tabindex="0"
647+
type="button"
648+
>
649+
Delete Account
650+
</button>
651+
<button
652+
aria-label="Permanently delete your account and all data"
653+
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-lk7kvc-MuiButtonBase-root-MuiIconButton-root"
654+
data-mui-internal-clone-element="true"
655+
tabindex="0"
656+
type="button"
657+
>
658+
<svg
659+
aria-hidden="true"
660+
class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-120dh41-MuiSvgIcon-root"
661+
data-testid="InfoOutlinedIcon"
662+
focusable="false"
663+
viewBox="0 0 24 24"
664+
>
665+
<path
666+
d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
667+
/>
668+
</svg>
669+
</button>
670+
</div>
671+
</div>
633672
</form>
634673
</div>
635674
</div>

src/app/edit/profile/page.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ import Page from "./page";
77
import { getSession } from "@/lib/auth";
88
import { prisma } from "@/lib/prisma";
99
import { useSession } from "next-auth/react";
10+
import { useRouter } from "next/navigation";
1011

1112
jest.mock("next-auth/react", () => ({
1213
useSession: jest.fn(),
1314
}));
1415

16+
jest.mock("next/navigation", () => ({
17+
useRouter: jest.fn(),
18+
}));
19+
1520
jest.mock("@tanstack/react-query", () => ({
1621
useQueryClient: jest.fn(),
1722
useQuery: jest.fn(),
@@ -50,6 +55,7 @@ describe("Page", () => {
5055
(useQuery as jest.Mock).mockReturnValue({ isPending: false, data: [] });
5156
(useMutation as jest.Mock).mockReturnValue({ mutate: jest.fn() });
5257
(useQueryClient as jest.Mock).mockReturnValue({ invalidateQueries: jest.fn() });
58+
(useRouter as jest.Mock).mockReturnValue({ push: jest.fn() });
5359

5460
(getSession as jest.Mock).mockResolvedValue({
5561
user: { id: "user-id" },

0 commit comments

Comments
 (0)