From cc524e2ae73827731fb819a9357a6b5240096cce Mon Sep 17 00:00:00 2001 From: phukon Date: Sun, 24 Mar 2024 15:54:57 +0530 Subject: [PATCH] feat(account): add account deletion action, create delete account button and form components, fix API limit bug --- clack-cloudfare-worker/src/index.ts | 61 ++++++---- .../20240324064402_api_limits/migration.sql | 16 +++ prisma/schema.prisma | 34 ++++-- src/actions/auth/deleteAccount.ts | 40 +++++++ src/actions/auth/register.ts | 6 + src/app/(static)/link-notion-page/page.tsx | 11 +- src/app/api/generate/route.ts | 16 ++- src/app/api/note/route.ts | 1 + .../api/webhooks/stripePaymentIntent/route.ts | 7 ++ src/app/dash/settings/edit/page.tsx | 4 + src/auth.ts | 6 + .../deleteAccount/DeleteAccountButton.tsx | 26 +++++ .../deleteAccount/DeleteAccountForm.tsx | 104 ++++++++++++++++++ src/lib/api-limit.ts | 68 ++++++++++++ src/lib/constants.ts | 2 + src/schemas/index.ts | 4 + 16 files changed, 360 insertions(+), 46 deletions(-) create mode 100644 prisma/migrations/20240324064402_api_limits/migration.sql create mode 100644 src/actions/auth/deleteAccount.ts create mode 100644 src/components/deleteAccount/DeleteAccountButton.tsx create mode 100644 src/components/deleteAccount/DeleteAccountForm.tsx create mode 100644 src/lib/api-limit.ts diff --git a/clack-cloudfare-worker/src/index.ts b/clack-cloudfare-worker/src/index.ts index d097de3..0f2e937 100644 --- a/clack-cloudfare-worker/src/index.ts +++ b/clack-cloudfare-worker/src/index.ts @@ -9,10 +9,9 @@ */ export interface Env { - - clackkv: KVNamespace; + clackkv: KVNamespace; SECURITY_KEY: string; - API_HOST: string; + API_HOST: string; // Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/ // MY_KV_NAMESPACE: KVNamespace; // @@ -31,19 +30,22 @@ export interface Env { export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + if (!isAuthorized(request, env)) { + return new Response('Unauthorized', { status: 401 }); + } - if (!isAuthorized(request, env)) { - return new Response('Unauthorized', { status: 401 }); - } - - const reqUrl = new URL(request.url); const key = reqUrl.searchParams.get('key'); const getAllFromUser = reqUrl.searchParams.get('getAllFromUser'); - - if(reqUrl.searchParams.has('sayonara')) { - return await deleteEverything(env) - } + + if (reqUrl.searchParams.has('sayonara')) { + return await deleteEverything(env); + } + + if (reqUrl.searchParams.has('deleteAllUserNotes')) { + const userEmail = reqUrl.searchParams.get('deleteAllUserNotes'); + return await deleteAllNotesOfUser(env, userEmail!); + } if (getAllFromUser) { return await getAllKeys(env, getAllFromUser); @@ -98,17 +100,28 @@ async function handleDelete(env: Env, key: string): Promise { } async function handleGet(env: Env, key: string): Promise { - const data = await env.clackkv.get(key); - if (!data) { - return new Response('Not found', { status: 404 }); - } - return new Response(data); + const data = await env.clackkv.get(key); + if (!data) { + return new Response('Not found', { status: 404 }); + } + return new Response(data); +} + +async function deleteEverything(env: Env): Promise { + const keys = await env.clackkv.list(); + for (const key of keys.keys) { + await env.clackkv.delete(key.name.toString()); + } + return new Response('Deleted everything', { status: 200 }); } -async function deleteEverything(env: Env): Promise{ - const keys = await env.clackkv.list(); - for (const key of keys.keys) { - await env.clackkv.delete(key.name.toString()); - } - return new Response('Deleted everything', {status: 200}) -} \ No newline at end of file +async function deleteAllNotesOfUser(env: Env, userEmail: string): Promise { + const prefix = userEmail + '-'; + const keys = await env.clackkv.list({ prefix }); + + for (const key of keys.keys) { + await env.clackkv.delete(key.name); + } + + return new Response('Deleted all notes of the user', { status: 200 }); +} diff --git a/prisma/migrations/20240324064402_api_limits/migration.sql b/prisma/migrations/20240324064402_api_limits/migration.sql new file mode 100644 index 0000000..70968f9 --- /dev/null +++ b/prisma/migrations/20240324064402_api_limits/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "UserApiLimit" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserApiLimit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserApiLimit_userId_key" ON "UserApiLimit"("userId"); + +-- AddForeignKey +ALTER TABLE "UserApiLimit" ADD CONSTRAINT "UserApiLimit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca1002c..029130a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -85,8 +85,9 @@ model User { years Year[] contributions Contribution[] notes Note[] + userApiLimit UserApiLimit[] wordCountRef Int? - notionDetails Json? // The Notion O-Auth response containing access token that is received after posting the temporary code to the auth url. + notionDetails Json? // The Notion O-Auth response containing access token that is received after posting the temporary code to the auth url. // Stripe stripeCustomerId String? @unique @map(name: "stripe_customer_id") @@ -101,6 +102,16 @@ model User { stripePaymentDate DateTime? } +model UserApiLimit { + id String @id @default(cuid()) + userId String @unique + count Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + enum NoteType { CLACK NOTION @@ -108,16 +119,17 @@ enum NoteType { } model Note { - id String @id @default(cuid()) - name String? - type NoteType @default(CLACK) - url String? - wordCount Int? - userId String - data Json? // I may use an edge KV store - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String? + type NoteType @default(CLACK) + url String? + wordCount Int? + userId String + data Json? // I may use an edge KV store + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([url]) } diff --git a/src/actions/auth/deleteAccount.ts b/src/actions/auth/deleteAccount.ts new file mode 100644 index 0000000..b938145 --- /dev/null +++ b/src/actions/auth/deleteAccount.ts @@ -0,0 +1,40 @@ +"use server"; + +import { getUserById } from "@/data/user"; +import { currentUser } from "@/lib/auth"; +import { db } from "@/lib/db"; + +export const deleteAccount = async () => { + try { + const user = await currentUser(); + if (!user || !user.id) { + throw new Error("Unauthorized: User not authenticated."); + } + + const dbUser = await getUserById(user.id); + if (!dbUser) { + throw new Error("Unauthorized: User not found in the database."); + } + + await db.user.delete({ + where: { id: dbUser.id }, + }); + + const getResponse = await fetch( + `${process.env.WORKER_BASE_URL}?deleteAllUserNotes=${dbUser.email}`, + { + method: "GET", + headers: { + "X-Custom-Auth-Key": `${process.env.SECURITY_KEY}`, + }, + } + ); + + const data = await getResponse.text(); + console.log(data); + + return { success: "Your account and data has been removed from Clack." }; + } catch (e: any) { + return { error: `There was a problem deleting your account ${e.message} ` }; + } +}; diff --git a/src/actions/auth/register.ts b/src/actions/auth/register.ts index 6ef5a34..6f8febc 100644 --- a/src/actions/auth/register.ts +++ b/src/actions/auth/register.ts @@ -34,6 +34,12 @@ export const register = async (values: z.infer) => { }, }); + await db.userApiLimit.create({ + data: { + userId: user.id, + }, + }); + const currentYear = new Date().getFullYear(); const createdYear = await db.year.create({ diff --git a/src/app/(static)/link-notion-page/page.tsx b/src/app/(static)/link-notion-page/page.tsx index e1916b4..7f068f3 100644 --- a/src/app/(static)/link-notion-page/page.tsx +++ b/src/app/(static)/link-notion-page/page.tsx @@ -6,15 +6,8 @@ import { RiNotionFill } from "react-icons/ri"; import { Plus as PlusSmallIcon, Minus as MinusSmallIcon, - RefreshCw as ArrowPathIcon, - GitPullRequestArrow as CloudArrowUpIcon, - Settings as Cog6ToothIcon, - Fingerprint as FingerPrintIcon, - Lock as LockClosedIcon, - HardDrive as ServerIcon, } from "lucide-react"; import Link from "next/link"; -import Image from "next/image"; const faqs = [ { @@ -64,7 +57,7 @@ export default function Home() { Link a Notion Page

- Connect the Clack integration Notion and add the page link in{" "} + Connect the Clack Notion integration and add the page link in{" "} Clack dashboard.

{/*
@@ -132,7 +125,7 @@ export default function Home() {
*/}

- A prompt apperas from Notion, it describes the integration's capabilities. + A prompt appears from Notion, it describes the integration's capabilities.

diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index caee572..0c44eb3 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -7,6 +7,7 @@ import OpenAI from "openai"; import { OpenAIStream, StreamingTextResponse } from "ai"; import { kv } from "@vercel/kv"; import { Ratelimit } from "@upstash/ratelimit"; +import { checkApiLimit, incrementApiLimit } from "@/lib/api-limit"; // Create an OpenAI API client (that's edge friendly!) const openai = new OpenAI({ @@ -61,17 +62,28 @@ export async function POST(req: Request): Promise { // const subscriptionPlan = await getUserSubscriptionPlan(); const isPaidUser = await getUserPaymentStatus(); + const freeTrial = await checkApiLimit(); if (typeof isPaidUser === "boolean") { // Handle boolean response if (!isPaidUser) { - return new Response("Upgrade to Clack Pro to use the AI writing assistant ✨", { + if (!freeTrial) { + return new Response("Upgrade to Clack Pro to use the AI writing assistant ✨", { + status: 200, + }); + } else { + await incrementApiLimit(); + } + } else if (!freeTrial) { + return new Response("Buy credits to use the AI writing assistant ✨", { status: 200, }); + } else { + await incrementApiLimit(); } } else { return new Response("Error: " + isPaidUser.error, { - status: 200, + status: 403, }); } diff --git a/src/app/api/note/route.ts b/src/app/api/note/route.ts index 155f8c4..8bcf07b 100644 --- a/src/app/api/note/route.ts +++ b/src/app/api/note/route.ts @@ -157,6 +157,7 @@ export async function DELETE(req: Request): Promise { }); const data = await deleteResponse.text(); + console.log(data); if (deleteResponse.status !== 200) { return new Response(data, { diff --git a/src/app/api/webhooks/stripePaymentIntent/route.ts b/src/app/api/webhooks/stripePaymentIntent/route.ts index 48f7637..dc4710b 100644 --- a/src/app/api/webhooks/stripePaymentIntent/route.ts +++ b/src/app/api/webhooks/stripePaymentIntent/route.ts @@ -50,6 +50,13 @@ export async function POST(request: Request) { stripePaymentDate: paymentDate, }, }); + + await db.userApiLimit.update({ + where: { + id: userId, + }, + data: { count: 50 }, + }); } return new Response(null, { status: 200 }); diff --git a/src/app/dash/settings/edit/page.tsx b/src/app/dash/settings/edit/page.tsx index dcdc60a..5025271 100644 --- a/src/app/dash/settings/edit/page.tsx +++ b/src/app/dash/settings/edit/page.tsx @@ -24,6 +24,7 @@ import { useCurrentUser } from "@/hooks/use-current-user"; import FormError from "@/components/form-error"; import FormSuccess from "@/components/form-success"; import Navbar from "../navbar"; +import { DeleteAccountButton } from "@/components/deleteAccount/DeleteAccountButton"; export default function Edit() { const user = useCurrentUser(); @@ -208,6 +209,9 @@ export default function Edit() { +
+ Delete your account +
); } diff --git a/src/auth.ts b/src/auth.ts index a348bd5..467cb74 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -27,6 +27,12 @@ export const { data: { emailVerified: new Date(), wordCountRef: 0 }, }); + await db.userApiLimit.create({ + data: { + userId: user.id!, + }, + }); + const currentYear = new Date().getFullYear(); const createdYear = await db.year.create({ data: { diff --git a/src/components/deleteAccount/DeleteAccountButton.tsx b/src/components/deleteAccount/DeleteAccountButton.tsx new file mode 100644 index 0000000..2cd0eb2 --- /dev/null +++ b/src/components/deleteAccount/DeleteAccountButton.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { DeleteAccountForm } from "./DeleteAccountForm"; + +interface LoginButtonProps { + children: React.ReactNode; + mode?: "modal" | "redirect"; + asChild?: boolean; +} + +export const DeleteAccountButton = ({ children, asChild }: LoginButtonProps) => { + return ( + + + {children} + + + + + + ); +}; diff --git a/src/components/deleteAccount/DeleteAccountForm.tsx b/src/components/deleteAccount/DeleteAccountForm.tsx new file mode 100644 index 0000000..ef10db5 --- /dev/null +++ b/src/components/deleteAccount/DeleteAccountForm.tsx @@ -0,0 +1,104 @@ +"use client"; +import React, { useState, useTransition } from "react"; +import * as z from "zod"; +import { useForm } from "react-hook-form"; +import CardWrapper from "../auth/CardWrapper"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { DeleteFormSchema } from "@/schemas"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "../ui/input"; +import FormSuccess from "../form-success"; +import FormError from "../form-error"; +import { Button } from "../ui/button"; +import { deleteAccount } from "@/actions/auth/deleteAccount"; +import { logout } from "@/actions/auth/logout"; + +export const DeleteAccountForm = () => { + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [isPending, startTransition] = useTransition(); + + const form = useForm>({ + resolver: zodResolver(DeleteFormSchema), + defaultValues: { + text: "", + }, + }); + + const onSubmit = (values: z.infer) => { + setError(""); + setSuccess(""); + startTransition(() => { + if (values.text === "DELETE MY ACCOUNT") { + deleteAccount() + .then((d) => { + if (d?.error) { + form.reset(); + setError(d.error as string | undefined); + } + + if (d?.success) { + form.reset(); + setSuccess(d.success); + setTimeout(() => { + setSuccess("Redirecting in 5 seconds"); + + setTimeout(() => { + logout(); + }, 5_000); + }, 3_000); + } + }) + .catch(() => { + setTimeout(() => { + setError("Something went wrong."); + }, 7_000); // TODO: Fix default error case. The error message shows even on successful logins after which the user is redirected, which can be confusing for the user. Hence delaying the default message. + }); + } else { + setError("Text doesn't match"); + } + }); + }; + + return ( + +
+ +
+ <> + ( + + Type “DELETE MY ACCOUNT“ + + + + + + )} + /> + +
+ + + + + +
+ ); +}; diff --git a/src/lib/api-limit.ts b/src/lib/api-limit.ts new file mode 100644 index 0000000..c8cdad4 --- /dev/null +++ b/src/lib/api-limit.ts @@ -0,0 +1,68 @@ +import { MAX_FREE_COUNTS } from "@/lib/constants"; +import { db } from "./db"; +import { currentUser } from "./auth"; +import { getUserById } from "@/data/user"; + +export const incrementApiLimit = async () => { + const user = await currentUser(); + if (!user) throw new Error("Unauthorized"); + if (!user.id) throw new Error("Invalid user ID"); + + const dbUser = await getUserById(user.id); + if (!dbUser) throw new Error("Unauthorized"); + + const userApiLimit = await db.userApiLimit.findUnique({ + where: { userId: dbUser.id }, + }); + + if (userApiLimit) { + await db.userApiLimit.update({ + where: { userId: dbUser.id }, + data: { count: userApiLimit.count + 1 }, + }); + } else { + await db.userApiLimit.create({ + data: { userId: dbUser.id, count: 1 }, + }); + } +}; + +export const checkApiLimit = async () => { + const user = await currentUser(); + if (!user) throw new Error("Unauthorized"); + if (!user.id) throw new Error("Invalid user ID"); + + const dbUser = await getUserById(user.id); + if (!dbUser) throw new Error("Unauthorized"); + + const userApiLimit = await db.userApiLimit.findUnique({ + where: { userId: dbUser.id }, + }); + + if (!userApiLimit || userApiLimit.count < MAX_FREE_COUNTS) { + return true; + } else { + return false; + } +}; + +export const getApiLimitCount = async () => { + const user = await currentUser(); + if (!user) throw new Error("Unauthorized"); + if (!user.id) throw new Error("Invalid user ID"); + + const dbUser = await getUserById(user.id); + if (!dbUser) throw new Error("Unauthorized"); + + const userApiLimit = await db.userApiLimit.findUnique({ + where: { + userId: dbUser.id, + }, + }); + + if (!userApiLimit) { + return 0; + } + + return userApiLimit.count; +}; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 028583d..4653c17 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -5,6 +5,8 @@ export const FADE_IN_ANIMATION_SETTINGS = { transition: { duration: 0.2 }, }; +export const MAX_FREE_COUNTS = 10; + export const STAGGER_CHILD_VARIANTS = { hidden: { opacity: 0, y: 20 }, show: { opacity: 1, y: 0, transition: { duration: 0.4, type: "spring" } }, diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 9ccc8ca..5f5fd90 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -5,6 +5,10 @@ export const AddDocumentUrlSchema = z.object({ url: z.string(), }); +export const DeleteFormSchema = z.object({ + text: z.string(), +}); + export const LoginSchema = z.object({ email: z.string().email({ message: "Email is required",