From c9b657e44c59c58eadbfc0212fe1a04ecfb7bba2 Mon Sep 17 00:00:00 2001
From: m5r <mokht@rmi.al>
Date: Sat, 25 Sep 2021 07:09:20 +0800
Subject: [PATCH] implement update user, update password and delete account

---
 app/auth/mutations/change-password.ts         | 24 -------
 app/auth/mutations/logout.ts                  |  2 +-
 app/auth/validations.ts                       |  7 +-
 app/settings/api/queue/delete-user-data.ts    | 72 +++++++++++++++++++
 app/settings/api/queue/notify-email-change.ts | 28 ++++++++
 app/settings/components/danger-zone.tsx       | 12 +++-
 .../components/profile-informations.tsx       | 14 ++--
 app/settings/components/update-password.tsx   | 69 ++++++++----------
 app/settings/mutations/change-password.ts     | 36 ++++++++++
 app/settings/mutations/delete-user.ts         | 14 ++++
 app/settings/mutations/update-user.ts         | 25 +++++++
 11 files changed, 220 insertions(+), 83 deletions(-)
 delete mode 100644 app/auth/mutations/change-password.ts
 create mode 100644 app/settings/api/queue/delete-user-data.ts
 create mode 100644 app/settings/api/queue/notify-email-change.ts
 create mode 100644 app/settings/mutations/change-password.ts
 create mode 100644 app/settings/mutations/delete-user.ts
 create mode 100644 app/settings/mutations/update-user.ts

diff --git a/app/auth/mutations/change-password.ts b/app/auth/mutations/change-password.ts
deleted file mode 100644
index 6c2cc95..0000000
--- a/app/auth/mutations/change-password.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { NotFoundError, SecurePassword, resolver } from "blitz";
-
-import db from "../../../db";
-import { authenticateUser } from "./login";
-import { ChangePassword } from "../validations";
-
-export default resolver.pipe(
-	resolver.zod(ChangePassword),
-	resolver.authorize(),
-	async ({ currentPassword, newPassword }, ctx) => {
-		const user = await db.user.findFirst({ where: { id: ctx.session.userId! } });
-		if (!user) throw new NotFoundError();
-
-		await authenticateUser(user.email, currentPassword);
-
-		const hashedPassword = await SecurePassword.hash(newPassword.trim());
-		await db.user.update({
-			where: { id: user.id },
-			data: { hashedPassword },
-		});
-
-		return true;
-	},
-);
diff --git a/app/auth/mutations/logout.ts b/app/auth/mutations/logout.ts
index ac3a9af..1622650 100644
--- a/app/auth/mutations/logout.ts
+++ b/app/auth/mutations/logout.ts
@@ -1,4 +1,4 @@
-import { Ctx } from "blitz";
+import type { Ctx } from "blitz";
 
 export default async function logout(_ = null, ctx: Ctx) {
 	return await ctx.session.$revoke();
diff --git a/app/auth/validations.ts b/app/auth/validations.ts
index e220688..34c66f5 100644
--- a/app/auth/validations.ts
+++ b/app/auth/validations.ts
@@ -1,6 +1,6 @@
 import { z } from "zod";
 
-const password = z.string().min(10).max(100);
+export const password = z.string().min(10).max(100);
 
 export const Signup = z.object({
 	email: z.string().email(),
@@ -26,8 +26,3 @@ export const ResetPassword = z
 		message: "Passwords don't match",
 		path: ["passwordConfirmation"], // set the path of the error
 	});
-
-export const ChangePassword = z.object({
-	currentPassword: z.string(),
-	newPassword: password,
-});
diff --git a/app/settings/api/queue/delete-user-data.ts b/app/settings/api/queue/delete-user-data.ts
new file mode 100644
index 0000000..26f3171
--- /dev/null
+++ b/app/settings/api/queue/delete-user-data.ts
@@ -0,0 +1,72 @@
+import { Queue } from "quirrel/blitz";
+
+import db, { MembershipRole } from "../../../../db";
+import appLogger from "../../../../integrations/logger";
+
+const logger = appLogger.child({ queue: "delete-user-data" });
+
+type Payload = {
+	userId: string;
+};
+
+const deleteUserData = Queue<Payload>("api/queue/delete-user-data", async ({ userId }) => {
+	const user = await db.user.findFirst({
+		where: { id: userId },
+		include: {
+			memberships: {
+				include: {
+					organization: {
+						include: { memberships: { include: { user: true } } },
+					},
+				},
+			},
+		},
+	});
+	if (!user) {
+		return;
+	}
+
+	switch (user.memberships[0]!.role) {
+		case MembershipRole.OWNER: {
+			const organization = user.memberships[0]!.organization;
+			const where = { organizationId: organization.id };
+			await Promise.all<unknown>([
+				db.notificationSubscription.deleteMany({ where }),
+				db.phoneCall.deleteMany({ where }),
+				db.message.deleteMany({ where }),
+				db.processingPhoneNumber.deleteMany({ where }),
+			]);
+			await db.phoneNumber.deleteMany({ where });
+
+			const orgMembers = organization.memberships
+				.map((membership) => membership.user!)
+				.filter((user) => user !== null);
+			await Promise.all(
+				orgMembers.map((member) =>
+					Promise.all([
+						db.token.deleteMany({ where: { userId: member.id } }),
+						db.session.deleteMany({ where: { userId: member.id } }),
+						db.membership.deleteMany({ where: { userId: member.id } }),
+						db.user.delete({ where: { id: member.id } }),
+					]),
+				),
+			);
+			await db.organization.delete({ where: { id: organization.id } });
+			break;
+		}
+		case MembershipRole.USER: {
+			await Promise.all([
+				db.token.deleteMany({ where: { userId: user.id } }),
+				db.session.deleteMany({ where: { userId: user.id } }),
+				db.user.delete({ where: { id: user.id } }),
+				db.membership.deleteMany({ where: { userId: user.id } }),
+			]);
+			break;
+		}
+		case MembershipRole.ADMIN:
+			// nothing to do here?
+			break;
+	}
+});
+
+export default deleteUserData;
diff --git a/app/settings/api/queue/notify-email-change.ts b/app/settings/api/queue/notify-email-change.ts
new file mode 100644
index 0000000..18aa3f7
--- /dev/null
+++ b/app/settings/api/queue/notify-email-change.ts
@@ -0,0 +1,28 @@
+import { Queue } from "quirrel/blitz";
+
+import appLogger from "../../../../integrations/logger";
+import { sendEmail } from "../../../../integrations/ses";
+
+const logger = appLogger.child({ queue: "notify-email-change" });
+
+type Payload = {
+	oldEmail: string;
+	newEmail: string;
+};
+
+const notifyEmailChangeQueue = Queue<Payload>("api/queue/notify-email-change", async ({ oldEmail, newEmail }) => {
+	await Promise.all([
+		sendEmail({
+			recipients: [oldEmail],
+			subject: "",
+			body: "",
+		}),
+		sendEmail({
+			recipients: [newEmail],
+			subject: "",
+			body: "",
+		}),
+	]);
+});
+
+export default notifyEmailChangeQueue;
diff --git a/app/settings/components/danger-zone.tsx b/app/settings/components/danger-zone.tsx
index d59c59f..694b9de 100644
--- a/app/settings/components/danger-zone.tsx
+++ b/app/settings/components/danger-zone.tsx
@@ -1,11 +1,14 @@
 import { useRef, useState } from "react";
+import { useMutation } from "blitz";
 import clsx from "clsx";
 
 import Button from "./button";
 import SettingsSection from "./settings-section";
 import Modal, { ModalTitle } from "./modal";
+import deleteUser from "../mutations/delete-user";
 
 export default function DangerZone() {
+	const deleteUserMutation = useMutation(deleteUser)[0];
 	const [isDeletingUser, setIsDeletingUser] = useState(false);
 	const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
 	const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
@@ -19,14 +22,17 @@ export default function DangerZone() {
 	};
 	const onConfirm = () => {
 		setIsDeletingUser(true);
-		// user.deleteUser();
+		return deleteUserMutation();
 	};
 
 	return (
 		<SettingsSection title="Danger Zone" description="Highway to the Danger Zone 𝅘𝅥𝅮">
 			<div className="shadow border border-red-300 sm:rounded-md sm:overflow-hidden">
-				<div className="flex justify-between items-center flex-row px-4 py-5 bg-white sm:p-6">
-					<p>Once you delete your account, all of its data will be permanently deleted.</p>
+				<div className="flex justify-between items-center flex-row px-4 py-5 space-x-2 bg-white sm:p-6">
+					<p>
+						Once you delete your account, all of its data will be permanently deleted and any ongoing
+						subscription will be cancelled.
+					</p>
 
 					<span className="text-base font-medium">
 						<Button variant="error" type="button" onClick={() => setIsConfirmationModalOpen(true)}>
diff --git a/app/settings/components/profile-informations.tsx b/app/settings/components/profile-informations.tsx
index 6dbbf3e..039ee2c 100644
--- a/app/settings/components/profile-informations.tsx
+++ b/app/settings/components/profile-informations.tsx
@@ -1,8 +1,9 @@
 import type { FunctionComponent } from "react";
 import { useEffect, useState } from "react";
-import { useRouter } from "blitz";
+import { useMutation } from "blitz";
 import { useForm } from "react-hook-form";
 
+import updateUser from "../mutations/update-user";
 import Alert from "./alert";
 import Button from "./button";
 import SettingsSection from "./settings-section";
@@ -19,7 +20,7 @@ const logger = appLogger.child({ module: "profile-settings" });
 
 const ProfileInformations: FunctionComponent = () => {
 	const { user } = useCurrentUser();
-	const router = useRouter();
+	const updateUserMutation = useMutation(updateUser)[0];
 	const {
 		register,
 		handleSubmit,
@@ -39,16 +40,9 @@ const ProfileInformations: FunctionComponent = () => {
 		}
 
 		try {
-			// TODO
-			// await updateUser({ email, data: { name } });
+			await updateUserMutation({ email, name });
 		} catch (error: any) {
 			logger.error(error.response, "error updating user infos");
-
-			if (error.response.status === 401) {
-				logger.error("session expired, redirecting to sign in page");
-				return router.push("/auth/sign-in");
-			}
-
 			setErrorMessage(error.response.data.errorMessage);
 		}
 	});
diff --git a/app/settings/components/update-password.tsx b/app/settings/components/update-password.tsx
index 48ca2de..98a5dd7 100644
--- a/app/settings/components/update-password.tsx
+++ b/app/settings/components/update-password.tsx
@@ -1,6 +1,6 @@
 import type { FunctionComponent } from "react";
 import { useState } from "react";
-import { useRouter } from "blitz";
+import { useMutation } from "blitz";
 import { useForm } from "react-hook-form";
 
 import Alert from "./alert";
@@ -8,16 +8,17 @@ import Button from "./button";
 import SettingsSection from "./settings-section";
 
 import appLogger from "../../../integrations/logger";
+import changePassword from "../mutations/change-password";
 
 const logger = appLogger.child({ module: "update-password" });
 
 type Form = {
+	currentPassword: string;
 	newPassword: string;
-	newPasswordConfirmation: string;
 };
 
 const UpdatePassword: FunctionComponent = () => {
-	const router = useRouter();
+	const changePasswordMutation = useMutation(changePassword)[0];
 	const {
 		register,
 		handleSubmit,
@@ -25,28 +26,18 @@ const UpdatePassword: FunctionComponent = () => {
 	} = useForm<Form>();
 	const [errorMessage, setErrorMessage] = useState("");
 
-	const onSubmit = handleSubmit(async ({ newPassword, newPasswordConfirmation }) => {
+	const onSubmit = handleSubmit(async ({ currentPassword, newPassword }) => {
 		if (isSubmitting) {
 			return;
 		}
 
-		if (newPassword !== newPasswordConfirmation) {
-			setErrorMessage("New passwords don't match");
-			return;
-		}
+		setErrorMessage("");
 
 		try {
-			// TODO
-			// await customer.updateUser({ password: newPassword });
+			await changePasswordMutation({ currentPassword, newPassword });
 		} catch (error: any) {
-			logger.error(error.response, "error updating user infos");
-
-			if (error.response.status === 401) {
-				logger.error("session expired, redirecting to sign in page");
-				return router.push("/auth/sign-in");
-			}
-
-			setErrorMessage(error.response.data.errorMessage);
+			logger.error(error, "error updating user infos");
+			setErrorMessage(error.message);
 		}
 	});
 
@@ -62,7 +53,7 @@ const UpdatePassword: FunctionComponent = () => {
 					</div>
 				) : null}
 
-				{isSubmitSuccessful ? (
+				{!isSubmitting && isSubmitSuccessful && !errorMessage ? (
 					<div className="mb-8">
 						<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
 					</div>
@@ -70,6 +61,25 @@ const UpdatePassword: FunctionComponent = () => {
 
 				<div className="shadow sm:rounded-md sm:overflow-hidden">
 					<div className="px-4 py-5 bg-white space-y-6 sm:p-6">
+						<div>
+							<label
+								htmlFor="currentPassword"
+								className="flex justify-between text-sm font-medium leading-5 text-gray-700"
+							>
+								<div>Current password</div>
+							</label>
+							<div className="mt-1 rounded-md shadow-sm">
+								<input
+									id="currentPassword"
+									type="password"
+									tabIndex={3}
+									className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
+									{...register("currentPassword")}
+									required
+								/>
+							</div>
+						</div>
+
 						<div>
 							<label
 								htmlFor="newPassword"
@@ -81,28 +91,9 @@ const UpdatePassword: FunctionComponent = () => {
 								<input
 									id="newPassword"
 									type="password"
-									tabIndex={3}
-									className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
-									{...register("newPassword")}
-									required
-								/>
-							</div>
-						</div>
-
-						<div>
-							<label
-								htmlFor="newPasswordConfirmation"
-								className="flex justify-between text-sm font-medium leading-5 text-gray-700"
-							>
-								<div>Confirm new password</div>
-							</label>
-							<div className="mt-1 rounded-md shadow-sm">
-								<input
-									id="newPasswordConfirmation"
-									type="password"
 									tabIndex={4}
 									className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
-									{...register("newPasswordConfirmation")}
+									{...register("newPassword")}
 									required
 								/>
 							</div>
diff --git a/app/settings/mutations/change-password.ts b/app/settings/mutations/change-password.ts
new file mode 100644
index 0000000..7354d84
--- /dev/null
+++ b/app/settings/mutations/change-password.ts
@@ -0,0 +1,36 @@
+import { AuthenticationError, NotFoundError, resolver, SecurePassword } from "blitz";
+import { z } from "zod";
+
+import db from "../../../db";
+import { authenticateUser } from "../../auth/mutations/login";
+import { password } from "../../auth/validations";
+
+const Body = z.object({
+	currentPassword: z.string(),
+	newPassword: password,
+});
+
+export default resolver.pipe(
+	resolver.zod(Body),
+	resolver.authorize(),
+	async ({ currentPassword, newPassword }, ctx) => {
+		const user = await db.user.findFirst({ where: { id: ctx.session.userId! } });
+		if (!user) throw new NotFoundError();
+
+		try {
+			await authenticateUser(user.email, currentPassword);
+		} catch (error) {
+			if (error instanceof AuthenticationError) {
+				throw new Error("Current password is incorrect");
+			}
+
+			throw error;
+		}
+
+		const hashedPassword = await SecurePassword.hash(newPassword.trim());
+		await db.user.update({
+			where: { id: user.id },
+			data: { hashedPassword },
+		});
+	},
+);
diff --git a/app/settings/mutations/delete-user.ts b/app/settings/mutations/delete-user.ts
new file mode 100644
index 0000000..a41b799
--- /dev/null
+++ b/app/settings/mutations/delete-user.ts
@@ -0,0 +1,14 @@
+import { NotFoundError, resolver } from "blitz";
+
+import db from "../../../db";
+import logout from "../../auth/mutations/logout";
+import deleteUserData from "../api/queue/delete-user-data";
+
+export default resolver.pipe(resolver.authorize(), async (_ = null, ctx) => {
+	const user = await db.user.findFirst({ where: { id: ctx.session.userId! } });
+	if (!user) throw new NotFoundError();
+
+	await db.user.update({ where: { id: user.id }, data: { hashedPassword: "pending deletion" } });
+	await deleteUserData.enqueue({ userId: user.id });
+	await logout(null, ctx);
+});
diff --git a/app/settings/mutations/update-user.ts b/app/settings/mutations/update-user.ts
new file mode 100644
index 0000000..0d267cd
--- /dev/null
+++ b/app/settings/mutations/update-user.ts
@@ -0,0 +1,25 @@
+import { NotFoundError, resolver } from "blitz";
+import { z } from "zod";
+
+import db from "../../../db";
+import notifyEmailChangeQueue from "../api/queue/notify-email-change";
+
+const Body = z.object({
+	email: z.string().email(),
+	name: z.string(),
+});
+
+export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ email, name }, ctx) => {
+	const user = await db.user.findFirst({ where: { id: ctx.session.userId! } });
+	if (!user) throw new NotFoundError();
+
+	const oldEmail = user.email;
+	await db.user.update({
+		where: { id: user.id },
+		data: { email, name },
+	});
+
+	if (oldEmail !== email) {
+		// await notifyEmailChangeQueue.enqueue({ newEmail: email, oldEmail: user.email });
+	}
+});