From f0027e6a0ec32f32bb22b583b4ca1f68af1ca7d1 Mon Sep 17 00:00:00 2001 From: oiov Date: Sun, 28 Jul 2024 21:31:51 +0800 Subject: [PATCH] feat: add short url crud --- actions/short-urls.ts | 0 app/(marketing)/s/page.tsx | 3 + app/(protected)/dashboard/info-card.tsx | 31 +- app/(protected)/dashboard/records/page.tsx | 7 +- .../dashboard/records/record-list.tsx | 9 +- app/(protected)/dashboard/urls/page.tsx | 7 +- app/(protected)/dashboard/urls/url-list.tsx | 146 +++++++--- app/api/record/add/route.ts | 24 +- app/api/record/delete/route.ts | 7 +- app/api/record/route.ts | 5 +- app/api/record/update/route.ts | 5 +- app/api/url/add/route.ts | 36 +++ app/api/url/delete/route.ts | 28 ++ app/api/url/route.ts | 21 ++ app/api/url/update/route.ts | 44 +++ auth.ts | 2 +- components/dashboard/header.tsx | 24 +- components/forms/record-form.tsx | 10 +- components/forms/url-form.tsx | 266 ++++++++++++++++++ {actions => lib/dto}/cloudflare-dns-record.ts | 0 lib/dto/short-urls.ts | 127 +++++++++ lib/{ => dto}/user.ts | 0 lib/validations/url.ts | 9 + .../20240705091917_init/migration.sql | 47 +++- prisma/schema.prisma | 36 +++ 25 files changed, 802 insertions(+), 92 deletions(-) delete mode 100644 actions/short-urls.ts create mode 100644 app/(marketing)/s/page.tsx create mode 100644 app/api/url/add/route.ts create mode 100644 app/api/url/delete/route.ts create mode 100644 app/api/url/route.ts create mode 100644 app/api/url/update/route.ts create mode 100644 components/forms/url-form.tsx rename {actions => lib/dto}/cloudflare-dns-record.ts (100%) create mode 100644 lib/dto/short-urls.ts rename lib/{ => dto}/user.ts (100%) create mode 100644 lib/validations/url.ts diff --git a/actions/short-urls.ts b/actions/short-urls.ts deleted file mode 100644 index e69de29..0000000 diff --git a/app/(marketing)/s/page.tsx b/app/(marketing)/s/page.tsx new file mode 100644 index 0000000..b2432e2 --- /dev/null +++ b/app/(marketing)/s/page.tsx @@ -0,0 +1,3 @@ +export default function IndexPage() { + return <>; +} diff --git a/app/(protected)/dashboard/info-card.tsx b/app/(protected)/dashboard/info-card.tsx index 1ffc4db..b87fa5b 100644 --- a/app/(protected)/dashboard/info-card.tsx +++ b/app/(protected)/dashboard/info-card.tsx @@ -1,6 +1,8 @@ -import { getUserRecordCount } from "@/actions/cloudflare-dns-record"; -import { GlobeLock, Link } from "lucide-react"; +import Link from "next/link"; +import { GlobeLock, Link as LinkIcon } from "lucide-react"; +import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record"; +import { getUserShortUrlCount } from "@/lib/dto/short-urls"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -10,11 +12,15 @@ export async function DNSInfoCard({ userId }: { userId: string }) { return ( - DNS Records + + + DNS Records + + - {[0, -1, undefined].includes(count) ? ( + {[-1, undefined].includes(count) ? ( ) : (
{count}
@@ -25,15 +31,24 @@ export async function DNSInfoCard({ userId }: { userId: string }) { ); } -export function UrlsInfoCard({ userId }: { userId: string }) { +export async function UrlsInfoCard({ userId }: { userId: string }) { + const count = await getUserShortUrlCount(userId); return ( - Short URLs - + + + Short URLs + + + -
20
+ {[-1, undefined].includes(count) ? ( + + ) : ( +
{count}
+ )}

total

diff --git a/app/(protected)/dashboard/records/page.tsx b/app/(protected)/dashboard/records/page.tsx index 730ee64..03cf961 100644 --- a/app/(protected)/dashboard/records/page.tsx +++ b/app/(protected)/dashboard/records/page.tsx @@ -18,7 +18,12 @@ export default async function DashboardPage() { return ( <> - + ); diff --git a/app/(protected)/dashboard/records/record-list.tsx b/app/(protected)/dashboard/records/record-list.tsx index 135e012..eb9a292 100644 --- a/app/(protected)/dashboard/records/record-list.tsx +++ b/app/(protected)/dashboard/records/record-list.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import Link from "next/link"; -import { UserRecordFormData } from "@/actions/cloudflare-dns-record"; import { User } from "@prisma/client"; import { ArrowUpRight, @@ -13,6 +12,7 @@ import { import useSWR, { useSWRConfig } from "swr"; import { TTL_ENUMS } from "@/lib/cloudflare"; +import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record"; import { fetcher } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -157,8 +157,9 @@ export default function UserRecordsList({ user }: RecordListProps) { {isLoading ? ( <> - - + + + ) : data && data.length > 0 ? ( data.map((record) => ( @@ -201,7 +202,7 @@ export default function UserRecordsList({ user }: RecordListProps) { )) ) : ( - + {/* */} No records You don't have any record yet. Start creating record. diff --git a/app/(protected)/dashboard/urls/page.tsx b/app/(protected)/dashboard/urls/page.tsx index efc3f2a..2bdb078 100644 --- a/app/(protected)/dashboard/urls/page.tsx +++ b/app/(protected)/dashboard/urls/page.tsx @@ -4,7 +4,7 @@ import { getCurrentUser } from "@/lib/session"; import { constructMetadata } from "@/lib/utils"; import { DashboardHeader } from "@/components/dashboard/header"; -import UserRecordsList from "./url-list"; +import UserUrlsList from "./url-list"; export const metadata = constructMetadata({ title: "Short URLs - WRDO", @@ -19,9 +19,12 @@ export default async function DashboardPage() { return ( <> + ); } diff --git a/app/(protected)/dashboard/urls/url-list.tsx b/app/(protected)/dashboard/urls/url-list.tsx index 0946a8f..8ae62c3 100644 --- a/app/(protected)/dashboard/urls/url-list.tsx +++ b/app/(protected)/dashboard/urls/url-list.tsx @@ -2,17 +2,12 @@ import { useState } from "react"; import Link from "next/link"; -import { UserRecordFormData } from "@/actions/cloudflare-dns-record"; import { User } from "@prisma/client"; -import { - ArrowUpRight, - DotSquareIcon, - PenLine, - RefreshCwIcon, -} from "lucide-react"; +import { PenLine, RefreshCwIcon } from "lucide-react"; import useSWR, { useSWRConfig } from "swr"; import { TTL_ENUMS } from "@/lib/cloudflare"; +import { ShortUrlFormData } from "@/lib/dto/short-urls"; import { fetcher } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -33,23 +28,24 @@ import { TableRow, } from "@/components/ui/table"; import StatusDot from "@/components/dashboard/status-dot"; -import { FormType, RecordForm } from "@/components/forms/record-form"; +import { FormType } from "@/components/forms/record-form"; +import { UrlForm } from "@/components/forms/url-form"; import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; -export interface RecordListProps { +export interface UrlListProps { user: Pick; } function TableColumnSekleton({ className }: { className?: string }) { return ( - - + + - + - + @@ -62,15 +58,16 @@ function TableColumnSekleton({ className }: { className?: string }) { ); } -export default function UserUrlsList({ user }: RecordListProps) { +export default function UserUrlsList({ user }: UrlListProps) { const [isShowForm, setShowForm] = useState(false); const [formType, setFormType] = useState("add"); - const [currentEditRecord, setCurrentEditRecord] = - useState(null); + const [currentEditUrl, setCurrentEditUrl] = useState( + null, + ); const { mutate } = useSWRConfig(); - const { data, error, isLoading } = useSWR( - "/api/record", + const { data, error, isLoading } = useSWR( + "/api/url", fetcher, { revalidateOnFocus: false, @@ -78,7 +75,7 @@ export default function UserUrlsList({ user }: RecordListProps) { ); const handleRefresh = () => { - // mutate("/api/record", undefined); + mutate("/api/url", undefined); }; return ( @@ -107,10 +104,10 @@ export default function UserUrlsList({ user }: RecordListProps) { className="w-[120px] shrink-0 gap-1" variant="default" onClick={() => { - // setCurrentEditRecord(null); - // setShowForm(false); - // setFormType("add"); - // setShowForm(!isShowForm); + setCurrentEditUrl(null); + setShowForm(false); + setFormType("add"); + setShowForm(!isShowForm); }} > Add url @@ -118,30 +115,30 @@ export default function UserUrlsList({ user }: RecordListProps) { - {/* {isShowForm && ( - - )} */} + )} - - + + Target - + Url - - Clicks + + Visible - Public + Active Actions @@ -149,17 +146,78 @@ export default function UserUrlsList({ user }: RecordListProps) { - - {/* - - No urls - - You don't have any url yet. Start creating url. - - - */} + {isLoading ? ( + <> + + + + + ) : data && data.length > 0 ? ( + data.map((short) => ( + + + + {short.target} + + + + + {short.url.slice(16)} + + + + {short.visible === 1 ? "Public" : "Private"} + + + + + + + + + )) + ) : ( + + {/* */} + No urls + + You don't have any url yet. Start creating url. + + + + )}
diff --git a/app/api/record/add/route.ts b/app/api/record/add/route.ts index 26224e4..333e805 100644 --- a/app/api/record/add/route.ts +++ b/app/api/record/add/route.ts @@ -1,13 +1,12 @@ +import { env } from "@/env.mjs"; +import { createDNSRecord } from "@/lib/cloudflare"; import { createUserRecord, getUserRecordByTypeNameContent, getUserRecordCount, -} from "@/actions/cloudflare-dns-record"; - -import { env } from "@/env.mjs"; -import { createDNSRecord } from "@/lib/cloudflare"; +} from "@/lib/dto/cloudflare-dns-record"; +import { checkUserStatus } from "@/lib/dto/user"; import { getCurrentUser } from "@/lib/session"; -import { checkUserStatus } from "@/lib/user"; import { generateSecret } from "@/lib/utils"; export async function POST(req: Request) { @@ -28,13 +27,6 @@ export async function POST(req: Request) { statusText: "API key、zone iD and email are required", }); } - const { records } = await req.json(); - const record = { - ...records[0], - id: generateSecret(16), - type: "CNAME", - proxied: false, - }; // check quota const user_records_count = await getUserRecordCount(user.id); @@ -48,6 +40,14 @@ export async function POST(req: Request) { }); } + const { records } = await req.json(); + const record = { + ...records[0], + id: generateSecret(16), + type: "CNAME", + proxied: false, + }; + const user_record = await getUserRecordByTypeNameContent( user.id, record.type, diff --git a/app/api/record/delete/route.ts b/app/api/record/delete/route.ts index 70e4701..b1bcf63 100644 --- a/app/api/record/delete/route.ts +++ b/app/api/record/delete/route.ts @@ -1,9 +1,8 @@ -import { deleteUserRecord } from "@/actions/cloudflare-dns-record"; - import { env } from "@/env.mjs"; import { deleteDNSRecord } from "@/lib/cloudflare"; +import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record"; +import { checkUserStatus } from "@/lib/dto/user"; import { getCurrentUser } from "@/lib/session"; -import { checkUserStatus } from "@/lib/user"; export async function POST(req: Request) { try { @@ -30,7 +29,7 @@ export async function POST(req: Request) { if (res && res.result?.id) { // Then delete user record. await deleteUserRecord(user.id, record_id, zone_id, active); - return Response.json({ + return Response.json("success", { status: 200, statusText: "success", }); diff --git a/app/api/record/route.ts b/app/api/record/route.ts index a074261..b18fd38 100644 --- a/app/api/record/route.ts +++ b/app/api/record/route.ts @@ -1,8 +1,7 @@ -import { getUserRecords } from "@/actions/cloudflare-dns-record"; - import { env } from "@/env.mjs"; +import { getUserRecords } from "@/lib/dto/cloudflare-dns-record"; +import { checkUserStatus } from "@/lib/dto/user"; import { getCurrentUser } from "@/lib/session"; -import { checkUserStatus } from "@/lib/user"; export async function GET(req: Request) { try { diff --git a/app/api/record/update/route.ts b/app/api/record/update/route.ts index 3c177d2..06b30b6 100644 --- a/app/api/record/update/route.ts +++ b/app/api/record/update/route.ts @@ -1,9 +1,8 @@ -import { updateUserRecord } from "@/actions/cloudflare-dns-record"; - import { env } from "@/env.mjs"; import { updateDNSRecord } from "@/lib/cloudflare"; +import { updateUserRecord } from "@/lib/dto/cloudflare-dns-record"; +import { checkUserStatus } from "@/lib/dto/user"; import { getCurrentUser } from "@/lib/session"; -import { checkUserStatus } from "@/lib/user"; export async function POST(req: Request) { try { diff --git a/app/api/url/add/route.ts b/app/api/url/add/route.ts new file mode 100644 index 0000000..acdf007 --- /dev/null +++ b/app/api/url/add/route.ts @@ -0,0 +1,36 @@ +import { env } from "@/env.mjs"; +import { createUserShortUrl } from "@/lib/dto/short-urls"; +import { checkUserStatus } from "@/lib/dto/user"; +import { getCurrentUser } from "@/lib/session"; +import { createUrlSchema } from "@/lib/validations/url"; + +export async function POST(req: Request) { + try { + const user = checkUserStatus(await getCurrentUser()); + if (user instanceof Response) return user; + + const { data } = await req.json(); + + const { target, url, visible, active } = createUrlSchema.parse(data); + const res = await createUserShortUrl({ + userId: user.id, + userName: user.name || "Anonymous", + target, + url, + visible, + active, + }); + if (res.status !== "success") { + return Response.json(res.status, { + status: 502, + statusText: `An error occurred. ${res.status}`, + }); + } + return Response.json(res.data); + } catch (error) { + return Response.json(error?.statusText || error, { + status: error.status || 500, + statusText: error.statusText || "Server error", + }); + } +} diff --git a/app/api/url/delete/route.ts b/app/api/url/delete/route.ts new file mode 100644 index 0000000..7de734f --- /dev/null +++ b/app/api/url/delete/route.ts @@ -0,0 +1,28 @@ +import { env } from "@/env.mjs"; +import { getUserRecords } from "@/lib/dto/cloudflare-dns-record"; +import { deleteUserShortUrl } from "@/lib/dto/short-urls"; +import { checkUserStatus } from "@/lib/dto/user"; +import { getCurrentUser } from "@/lib/session"; + +export async function POST(req: Request) { + try { + const user = checkUserStatus(await getCurrentUser()); + if (user instanceof Response) return user; + + const { url_id } = await req.json(); + if (!url_id) { + return Response.json("url id is required", { + status: 400, + statusText: "url id is required", + }); + } + + await deleteUserShortUrl(user.id, url_id); + return Response.json("success"); + } catch (error) { + return Response.json(error?.statusText || error, { + status: error.status || 500, + statusText: error.statusText || "Server error", + }); + } +} diff --git a/app/api/url/route.ts b/app/api/url/route.ts new file mode 100644 index 0000000..1568474 --- /dev/null +++ b/app/api/url/route.ts @@ -0,0 +1,21 @@ +import { env } from "@/env.mjs"; +import { getUserShortUrls } from "@/lib/dto/short-urls"; +import { checkUserStatus } from "@/lib/dto/user"; +import { getCurrentUser } from "@/lib/session"; + +export async function GET(req: Request) { + try { + const user = checkUserStatus(await getCurrentUser()); + if (user instanceof Response) return user; + + const user_urls = await getUserShortUrls(user.id, 1); + // TODO: get meta info + + return Response.json(user_urls); + } catch (error) { + return Response.json(error?.statusText || error, { + status: error.status || 500, + statusText: error.statusText || "Server error", + }); + } +} diff --git a/app/api/url/update/route.ts b/app/api/url/update/route.ts new file mode 100644 index 0000000..473c93f --- /dev/null +++ b/app/api/url/update/route.ts @@ -0,0 +1,44 @@ +import { env } from "@/env.mjs"; +import { getUserRecords } from "@/lib/dto/cloudflare-dns-record"; +import { updateUserShortUrl } from "@/lib/dto/short-urls"; +import { checkUserStatus } from "@/lib/dto/user"; +import { getCurrentUser } from "@/lib/session"; +import { createUrlSchema } from "@/lib/validations/url"; + +export async function POST(req: Request) { + try { + const user = checkUserStatus(await getCurrentUser()); + if (user instanceof Response) return user; + + const { data } = await req.json(); + if (!data?.id) { + return Response.json(`Url id is required`, { + status: 400, + statusText: `Url id is required`, + }); + } + + const { target, url, visible, active, id } = createUrlSchema.parse(data); + const res = await updateUserShortUrl({ + id, + userId: user.id, + userName: user.name || "Anonymous", + target, + url, + visible, + active, + }); + if (res.status !== "success") { + return Response.json(res.status, { + status: 400, + statusText: `An error occurred. ${res.status}`, + }); + } + return Response.json(res.data); + } catch (error) { + return Response.json(error?.statusText || error, { + status: error.status || 500, + statusText: error.statusText || "Server error", + }); + } +} diff --git a/auth.ts b/auth.ts index 386efe3..4865d58 100644 --- a/auth.ts +++ b/auth.ts @@ -4,7 +4,7 @@ import { UserRole } from "@prisma/client"; import NextAuth, { type DefaultSession } from "next-auth"; import { prisma } from "@/lib/db"; -import { getUserById } from "@/lib/user"; +import { getUserById } from "@/lib/dto/user"; // More info: https://authjs.dev/getting-started/typescript#module-augmentation declare module "next-auth" { diff --git a/components/dashboard/header.tsx b/components/dashboard/header.tsx index 060080f..1d8f968 100644 --- a/components/dashboard/header.tsx +++ b/components/dashboard/header.tsx @@ -1,19 +1,41 @@ +import Link from "next/link"; + interface DashboardHeaderProps { heading: string; text?: string; + link?: string; + linkText?: string; children?: React.ReactNode; } export function DashboardHeader({ heading, text, + link, + linkText, children, }: DashboardHeaderProps) { return (

{heading}

- {text &&

{text}

} + +

+ {text && {text}} + {link && ( + + {" "} + See documentation about{" "} + + {linkText} + + + )} +

{children}
diff --git a/components/forms/record-form.tsx b/components/forms/record-form.tsx index 2ccb352..e56894e 100644 --- a/components/forms/record-form.tsx +++ b/components/forms/record-form.tsx @@ -1,13 +1,6 @@ "use client"; -import { - Dispatch, - SetStateAction, - useEffect, - useState, - useTransition, -} from "react"; -import { UserRecordFormData } from "@/actions/cloudflare-dns-record"; +import { Dispatch, SetStateAction, useState, useTransition } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { User } from "@prisma/client"; import { useForm } from "react-hook-form"; @@ -19,6 +12,7 @@ import { RecordType, TTL_ENUMS, } from "@/lib/cloudflare"; +import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record"; import { createRecordSchema } from "@/lib/validations/record"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/components/forms/url-form.tsx b/components/forms/url-form.tsx new file mode 100644 index 0000000..2312009 --- /dev/null +++ b/components/forms/url-form.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { Dispatch, SetStateAction, useTransition } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { User } from "@prisma/client"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +import { ShortUrlFormData } from "@/lib/dto/short-urls"; +import { createUrlSchema } from "@/lib/validations/url"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Icons } from "@/components/shared/icons"; + +import { FormSectionColumns } from "../dashboard/form-section-columns"; +import { Switch } from "../ui/switch"; + +export type FormData = ShortUrlFormData; + +export type FormType = "add" | "edit"; + +export interface RecordFormProps { + user: Pick; + isShowForm: boolean; + setShowForm: Dispatch>; + type: FormType; + initData?: ShortUrlFormData | null; + onRefresh: () => void; +} + +export function UrlForm({ + setShowForm, + type, + initData, + onRefresh, +}: RecordFormProps) { + const [isPending, startTransition] = useTransition(); + const [isDeleting, startDeleteTransition] = useTransition(); + + const { + handleSubmit, + register, + formState: { errors }, + getValues, + setValue, + } = useForm({ + resolver: zodResolver(createUrlSchema), + defaultValues: { + id: initData?.id || "", + target: initData?.target || "", + url: initData?.url || "", + active: initData?.active || 1, + visible: initData?.visible || 0, + }, + }); + + const onSubmit = handleSubmit((data) => { + if (type === "add") { + handleCreateUrl(data); + } else if (type === "edit") { + handleUpdateUrl(data); + } + }); + + const handleCreateUrl = async (data: ShortUrlFormData) => { + startTransition(async () => { + const response = await fetch("/api/url/add", { + method: "POST", + body: JSON.stringify({ + data, + }), + }); + if (!response.ok || response.status !== 200) { + toast.error("Created Failed!", { + description: response.statusText, + }); + } else { + const res = await response.json(); + toast.success(`Created successfully!`); + setShowForm(false); + onRefresh(); + } + }); + }; + + const handleUpdateUrl = async (data: ShortUrlFormData) => { + startTransition(async () => { + if (type === "edit") { + const response = await fetch("/api/url/update", { + method: "POST", + body: JSON.stringify({ data }), + }); + if (!response.ok || response.status !== 200) { + toast.error("Update Failed", { + description: response.statusText, + }); + } else { + const res = await response.json(); + toast.success(`Update successfully!`); + setShowForm(false); + onRefresh(); + } + } + }); + }; + + const handleDeleteUrl = async () => { + if (type === "edit") { + startDeleteTransition(async () => { + const response = await fetch("/api/url/delete", { + method: "POST", + body: JSON.stringify({ url_id: initData?.id }), + }); + if (!response.ok || response.status !== 200) { + toast.error("Delete Failed", { + description: response.statusText, + }); + } else { + await response.json(); + toast.success(`Success`); + setShowForm(false); + onRefresh(); + } + }); + } + }; + + return ( +
+
+ +
+ + +
+
+ {errors?.target ? ( +

+ {errors.target.message} +

+ ) : ( +

+ Required. https://your-origin-url +

+ )} +
+
+ +
+ + +
+ + https://wr.do/s/ + + +
+
+
+ {errors?.url ? ( +

+ {errors.url.message} +

+ ) : ( +

+ A random url. +

+ )} +
+
+
+ +
+ +
+ + setValue("visible", value ? 1 : 0)} + /> +
+

+ Public or private short url. +

+
+ +
+ + setValue("active", value ? 1 : 0)} + /> +
+

+ Enable or disable short url. +

+
+
+ + {/* Action buttons */} +
+ {type === "edit" && ( + + )} + + +
+
+ ); +} diff --git a/actions/cloudflare-dns-record.ts b/lib/dto/cloudflare-dns-record.ts similarity index 100% rename from actions/cloudflare-dns-record.ts rename to lib/dto/cloudflare-dns-record.ts diff --git a/lib/dto/short-urls.ts b/lib/dto/short-urls.ts new file mode 100644 index 0000000..126b5c4 --- /dev/null +++ b/lib/dto/short-urls.ts @@ -0,0 +1,127 @@ +import { auth } from "@/auth"; +import { UrlMeta } from "@prisma/client"; + +import { prisma } from "@/lib/db"; + +export interface ShortUrlFormData { + id?: string; + userId: string; + userName: string; + target: string; + url: string; + visible: number; + active: number; + created_at?: string; + updated_at?: string; +} + +export interface UserShortUrlInfo extends ShortUrlFormData { + // meta: Omit; + meta?: UrlMeta; +} + +export async function getUserShortUrls(userId: string, active: number = 1) { + return await prisma.userUrl.findMany({ + where: { + userId, + active, + }, + }); +} + +export async function getUserShortUrlCount(userId: string, active: number = 1) { + try { + return await prisma.userUrl.count({ + where: { + userId, + active, + }, + }); + } catch (error) { + return -1; + } +} + +export async function createUserShortUrl(data: ShortUrlFormData) { + try { + const session = await auth(); + if (!session?.user) { + throw new Error("Unauthorized"); + } + + const res = await prisma.userUrl.create({ + data: { + userId: session.user?.id!, + userName: session.user?.name || "Anonymous", + target: data.target, + url: data.url, + visible: data.visible, + active: data.active, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + return { status: "success", data: res }; + } catch (error) { + return { status: error }; + } +} + +export async function updateUserShortUrl(data: ShortUrlFormData) { + try { + const res = await prisma.userUrl.update({ + where: { + id: data.id, + userId: data.userId, + }, + data: { + target: data.target, + url: data.url, + visible: data.visible, + active: data.active, + updatedAt: new Date().toISOString(), + }, + }); + return { status: "success", data: res }; + } catch (error) { + return { status: error }; + } +} + +export async function updateUserShortUrlVisibility( + id: string, + visible: number, +) { + try { + const res = await prisma.userUrl.update({ + where: { + id, + }, + data: { + visible, + updatedAt: new Date().toISOString(), + }, + }); + return { status: "success", data: res }; + } catch (error) { + return { status: error }; + } +} + +export async function deleteUserShortUrl(userId: string, urlId: string) { + return await prisma.userUrl.delete({ + where: { + id: urlId, + userId, + }, + }); +} + +export async function getUserUrlMetaInfo(userId: string, urlId: string) { + return await prisma.urlMeta.findMany({ + where: { + userId, + urlId, + }, + }); +} diff --git a/lib/user.ts b/lib/dto/user.ts similarity index 100% rename from lib/user.ts rename to lib/dto/user.ts diff --git a/lib/validations/url.ts b/lib/validations/url.ts new file mode 100644 index 0000000..a96d2af --- /dev/null +++ b/lib/validations/url.ts @@ -0,0 +1,9 @@ +import * as z from "zod"; + +export const createUrlSchema = z.object({ + id: z.string().optional(), + target: z.string().min(6), // TODO + url: z.string().min(2), // TODO + visible: z.number().default(1), + active: z.number().default(1), +}); diff --git a/prisma/migrations/20240705091917_init/migration.sql b/prisma/migrations/20240705091917_init/migration.sql index 39ddb12..18f8276 100644 --- a/prisma/migrations/20240705091917_init/migration.sql +++ b/prisma/migrations/20240705091917_init/migration.sql @@ -106,7 +106,7 @@ CREATE TABLE "user_records" "modified_on" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "active" INTEGER NOT NULL DEFAULT 1, - CONSTRAINT "user_records_pkey" PRIMARY KEY ("id"), + CONSTRAINT "user_records_pkey" PRIMARY KEY ("id") ); -- CreateIndex @@ -117,3 +117,48 @@ CREATE UNIQUE INDEX "user_records_record_id_key" ON "user_records"("record_id"); -- AddForeignKey ALTER TABLE "user_records" ADD CONSTRAINT "user_records_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "user_urls" +( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "userName" TEXT NOT NULL, + "target" TEXT NOT NULL, + "url" TEXT NOT NULL, + "visible" INTEGER NOT NULL DEFAULT 0, + "active" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_urls_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "user_urls_userId_idx" ON "user_urls" ("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_urls_url_key" ON "user_urls"("url"); + +-- AddForeignKey +ALTER TABLE "user_urls" ADD CONSTRAINT "user_urls_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "url_metas" +( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "urlId" TEXT NOT NULL, + "click" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "url_metas_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "url_metas_userId_urlId_idx" ON "url_metas" ("userId", "urlId"); + +-- AddForeignKey +ALTER TABLE "url_metas" ADD CONSTRAINT "url_metas_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "url_metas" ADD CONSTRAINT "url_metas_urlId_fkey" FOREIGN KEY ("urlId") REFERENCES "user_urls"("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4fef88d..ad18daf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,6 +64,8 @@ model User { accounts Account[] sessions Session[] UserRecord UserRecord[] + UserUrl UserUrl[] + UrlMeta UrlMeta[] @@map(name: "users") } @@ -100,3 +102,37 @@ model UserRecord { @@index([userId]) @@map(name: "user_records") } + +model UserUrl { + id String @id @default(cuid()) + userId String + userName String + target String + url String @unique + visible Int @default(0) + active Int @default(1) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @map(name: "updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + UrlMeta UrlMeta[] + + @@index([userId]) + @@map(name: "user_urls") +} + +model UrlMeta { + id String @id @default(cuid()) + userId String + urlId String + click Int + + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @map(name: "updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userUrl UserUrl @relation(fields: [urlId], references: [id], onDelete: Cascade) + + @@index([userId, urlId]) + @@map(name: "url_metas") +}