migrated from auth.js to clerk; some ui remake; added expense-card

This commit is contained in:
mr-shortman 2025-04-13 19:52:06 +02:00
parent cd8a8d746d
commit a2492fefbd
37 changed files with 786 additions and 1324 deletions

View File

@ -21,8 +21,8 @@
},
"dependencies": {
"@auth/drizzle-adapter": "^1.7.2",
"@capacitor/cli": "^7.2.0",
"@capacitor/core": "^7.2.0",
"@clerk/nextjs": "^6.14.3",
"@clerk/themes": "^2.2.31",
"@hookform/resolvers": "^5.0.1",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-avatar": "^1.1.3",
@ -46,7 +46,6 @@
"drizzle-orm": "^0.41.0",
"lucide-react": "^0.487.0",
"next": "^15.2.3",
"next-auth": "5.0.0-beta.25",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"postgres": "^3.4.4",
@ -56,6 +55,7 @@
"server-only": "^0.0.1",
"sonner": "^2.0.3",
"superjson": "^2.2.1",
"svix": "^1.64.0",
"tailwind-merge": "^3.1.0",
"tw-animate-css": "^1.2.5",
"use-debounce": "^10.0.4",

1088
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,22 +1,24 @@
import ExpenseForm from "@/app/_components/expense/expense-form";
import Header from "@/components/header";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
import { auth } from "@/server/auth";
import { api, HydrateClient } from "@/trpc/server";
import { currentUser } from "@clerk/nextjs/server";
import React from "react";
export default async function Page() {
const session = await auth();
if (session?.user) void api.friend.getAll.prefetch();
const user = await currentUser();
if (user) void api.friend.getAll.prefetch();
const sessionUser = await api.user.getSessionUser();
return (
<HydrateClient>
<Header text="Add Expense">
<Button type="submit" form="expense-form">
<Button type="submit" form="expense-form" size={"sm"}>
Create Expense
</Button>
</Header>
<ExpenseForm hideSubmit session={session!} />
<ExpenseForm hideSubmit sessionUser={sessionUser!} />
</HydrateClient>
);
}

View File

@ -1,3 +1,4 @@
import ExpenseCard from "@/app/_components/expense/expense-card";
import Header from "@/components/header";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
@ -7,20 +8,26 @@ import React from "react";
export default async function Page() {
const splits = await api.expense.getAll();
return (
<>
<Header text="Expenses">
<Button asChild>
<Button asChild size={"sm"}>
<Link href="/add">Add Expense</Link>
</Button>
</Header>
<Section>
{splits.map(({ expense }) => (
<div key={expense.id}>
<h2>{expense.description}</h2>
<p>{expense.amount}</p>
</div>
))}
<div className="p-4 rounded-md flex items-center justify-center text-muted-foreground bg-card/25">
Expense Stats
</div>
<ul className="space-y-4">
{splits.map((expense) => (
<li key={expense.id}>
<ExpenseCard expense={expense} />
</li>
))}
</ul>
</Section>
</>
);

View File

@ -1,13 +1,13 @@
import AddFriendDrawer from "@/app/_components/friend/add-friend-drawer";
import FriendList from "@/app/_components/friend/friend-list";
import Header from "@/components/header";
import { auth } from "@/server/auth";
import { api, HydrateClient } from "@/trpc/server";
import { currentUser } from "@clerk/nextjs/server";
import React from "react";
export default async function Page() {
const session = await auth();
if (session?.user) void api.friend.getAll.prefetch();
const user = await currentUser();
if (user) void api.friend.getAll.prefetch();
return (
<HydrateClient>

View File

@ -1,6 +1,4 @@
import Navbar from "@/components/navbar";
import { auth } from "@/server/auth";
import { redirect } from "next/navigation";
import React from "react";
export default async function Layout({
@ -8,11 +6,9 @@ export default async function Layout({
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) return redirect("/api/auth/signin");
return (
<div className="space-y-2 w-full mx-auto max-w h-screen flex flex-col justify-between bg-card/25 overflow-hidden">
<main className="space-y-4 rounded-b-[3rem] bg-background h-full border border-card z-20 relative overflow-y-auto pb-8">
<main className="space-y-4 rounded-b-[3rem] bg-background/75 h-full border border-card z-20 relative overflow-y-auto pb-8">
{children}
</main>
<Navbar />

View File

@ -1,17 +1,18 @@
import { appConfig } from "@/app.config";
import Header from "@/components/header";
import Section from "@/components/section";
import { auth } from "@/server/auth";
import UserDropdown from "../_components/user-dropdown";
import { ModeToggle } from "@/components/mode-toggle";
import { UserButton } from "@clerk/nextjs";
export default async function Home() {
const session = await auth();
// const session = await auth();
return (
<>
<Header text={appConfig.name}>
<UserDropdown user={session?.user!} />
<UserButton />
{/* <UserDropdown user={session?.user!} /> */}
</Header>
<Section>
<ModeToggle />

View File

@ -0,0 +1,7 @@
import React from "react";
function Page() {
return <div>SignIn page</div>;
}
export default Page;

View File

@ -0,0 +1,87 @@
import Avatar from "@/components/avatar";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Separator } from "@/components/ui/separator";
import { getAmount } from "@/lib/utils";
import type { Expense } from "@/server/db/schema";
import React from "react";
import { UserBadge } from "./expense-participants";
import { Icons } from "@/components/icons";
import ExpenseDetails from "./expense-details";
import { Button } from "@/components/ui/button";
function ExpenseCard({ expense }: { expense: Expense }) {
return (
<Drawer>
<DrawerTrigger className="w-full">
<Card className="bg-card/25 border-0 shadow-none gap-2 pb-0">
<CardHeader className="px-4">
<CardTitle className="flex items-center w-full">
<p className="w-full max-w-64 truncate ">{expense.description}</p>
<span className="ml-auto">
{getAmount(Number(expense.amount))}
</span>
</CardTitle>
</CardHeader>
<CardContent className="px-4">
{expense?.splits?.map((split, idx) => (
<div key={idx} className="text-sm flex items-center gap-1">
<UserBadge user={split.owedTo!} />
<Icons.brokenWallet className="size-4 " />
<UserBadge user={split.owedFrom!} />
<span className="font-semibold ml-auto ">
{getAmount(Number(split.amount))}
</span>
</div>
))}
</CardContent>
<Separator className="my-2" />
<CardFooter className="px-4 pb-4 flex items-center">
<span className="text-sm">You Owe/Get</span>
<span className="ml-auto font-semibold">
{getAmount(Number(expense.amount))}
</span>
</CardFooter>
</Card>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Are you absolutely sure?</DrawerTitle>
<DrawerDescription>This action cannot be undone.</DrawerDescription>
</DrawerHeader>
<div className="p-4">
<ExpenseDetails expense={expense} />
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
export default ExpenseCard;

View File

@ -0,0 +1,8 @@
import type { Expense } from "@/server/db/schema";
import React from "react";
function ExpenseDetails({ expense }: { expense: Expense }) {
return <div>ExpenseDetails for expense: {expense.id}</div>;
}
export default ExpenseDetails;

View File

@ -1,6 +1,6 @@
"use client";
import React from "react";
import type { Expense } from "@/server/db/schema";
import type { Expense, User } from "@/server/db/schema";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@ -19,7 +19,6 @@ import { Textarea } from "@/components/ui/textarea";
import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react";
import ExpenseSplit from "./expense-split";
import type { Session } from "next-auth";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { useExpenseStore } from "@/lib/store/expense-store";
@ -30,13 +29,13 @@ import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
function ExpenseForm({
session,
initialExpense,
hideSubmit,
sessionUser,
}: {
session: Session;
initialExpense?: Expense;
hideSubmit?: boolean;
sessionUser: User;
}) {
const form = useForm<z.infer<typeof expenseSchema>>({
resolver: zodResolver(expenseSchema),
@ -61,8 +60,8 @@ function ExpenseForm({
onSuccess(expense) {
form.reset();
resetExpenseStore();
addParticipant(session.user);
setPayments([{ amount: amount, userId: session.user.id }]);
addParticipant(sessionUser);
setPayments([{ amount: amount, userId: sessionUser.id }]);
toast.message("Expense Created", {
position: "bottom-center",
action: {
@ -95,13 +94,13 @@ function ExpenseForm({
}
React.useEffect(() => {
addParticipant(session.user);
setPayments([{ amount, userId: session.user.id }]);
addParticipant(sessionUser);
setPayments([{ amount, userId: sessionUser.id }]);
}, []);
const handleAmountChange = (value: number) => {
setAmount(value);
const firstUserId = payments[0]?.userId ?? session.user.id;
const firstUserId = payments[0]?.userId ?? sessionUser.id;
setPayments([{ amount: value, userId: firstUserId }]);
recalculateSplits();
};
@ -119,7 +118,7 @@ function ExpenseForm({
</Button>
)}
<ExpenseParticipants session={session} />
<ExpenseParticipants sessionUserId={sessionUser.id} />
<Separator />
<FormField
@ -169,7 +168,7 @@ function ExpenseForm({
</FormItem>
)}
/>
<ExpenseSplit session={session} />
<ExpenseSplit sessionUser={sessionUser} />
{/* <Tabs defaultValue="expense" className="size-full space-y-4">
<TabsList className="w-full bg-transparent p-0">

View File

@ -6,9 +6,30 @@ import FriendSelect from "../friend/friend-select";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
import type { Session } from "next-auth";
import type { User } from "@/server/db/schema";
export default function ExpenseParticipants({ session }: { session: Session }) {
export const UserBadge = ({
user,
children,
}: {
user: User;
children?: React.ReactNode;
}) => {
return (
<div className="border rounded-full gap-1 flex items-center w-max pr-1">
<Avatar src={user.image} fb={user.name} className="size-6" />
<span className="text-sm">{user.name}</span>
{children}
</div>
);
};
export default function ExpenseParticipants({
sessionUserId,
}: {
sessionUserId: string;
}) {
const [friends] = api.friend.getAll.useSuspenseQuery();
const participants = useExpenseStore((state) => state.participants);
const addParticipant = useExpenseStore((state) => state.addParticipant);
@ -30,25 +51,21 @@ export default function ExpenseParticipants({ session }: { session: Session }) {
/>
{participants.map((user) => (
<ul
key={user.id}
className="border rounded-full gap-1 flex items-center w-max "
>
<Avatar src={user.image} fb={user.name} className="size-6" />
<span className="pr-4 text-sm">{user.name}</span>
{session.user.id !== user.id && (
<Button
className=" rounded-full aspect-square size-6"
variant="destructive"
onClick={(e) => {
e.preventDefault();
removeParticipant(user.id!);
}}
>
<XIcon className="size-4" />
</Button>
)}
<ul key={user.id}>
<UserBadge user={user}>
{sessionUserId !== user.id && (
<Button
className=" rounded-full aspect-square size-6"
variant="destructive"
onClick={(e) => {
e.preventDefault();
removeParticipant(user.id!);
}}
>
<XIcon className="size-4" />
</Button>
)}
</UserBadge>
</ul>
))}
</div>

View File

@ -3,15 +3,14 @@ import React from "react";
import FriendSelect from "../friend/friend-select";
import { Button } from "@/components/ui/button";
import type { Session } from "next-auth";
import { Icons } from "@/components/icons";
import { useExpenseStore } from "@/lib/store/expense-store";
import PaidByInput from "./paid-by";
import { api } from "@/trpc/react";
import SplitTo from "./split-to";
import type { User } from "@/server/db/schema";
export default function ExpenseSplit({ session }: { session: Session }) {
export default function ExpenseSplit({ sessionUser }: { sessionUser: User }) {
const [friends] = api.friend.getAll.useSuspenseQuery();
const friendTarget = useExpenseStore((state) => state.friendTarget);
@ -22,7 +21,7 @@ export default function ExpenseSplit({ session }: { session: Session }) {
<div className="space-y-4 ">
<div className="flex items-center justify-center gap-2">
<span>Paid by</span>
<PaidByInput sessionUser={session.user} />
<PaidByInput sessionUser={sessionUser} />
</div>
<div className="flex items-center justify-center gap-2">
<span>and spliited</span>

View File

@ -8,20 +8,16 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { DialogClose } from "@radix-ui/react-dialog";
import type { PublicUser } from "next-auth";
import Avatar from "@/components/avatar";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import PaidByCustomSplit from "./paid-by-custom-split";
import { useExpenseStore } from "@/lib/store/expense-store";
import type { User } from "@/server/db/schema";
/* saved result is an array of objects with user and amount */
export default function PaidByInput({
sessionUser,
}: {
sessionUser: PublicUser;
}) {
export default function PaidByInput({ sessionUser }: { sessionUser: User }) {
const amount = useExpenseStore((state) => state.amount);
const payments = useExpenseStore((state) => state.payments);
const setPayments = useExpenseStore((state) => state.setPayments);

View File

@ -1,86 +0,0 @@
// "use client";
// import React, { useEffect, useState } from "react";
// export function usePwaInstallStatus() {
// const [isStandalone, setIsStandalone] = useState(false);
// const [isIOS, setIsIOS] = useState(false);
// const [isAndroid, setIsAndroid] = useState(false);
// useEffect(() => {
// const userAgent =
// navigator.userAgent || navigator.vendor || (window as any).opera;
// // iOS detection
// const iOS = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
// setIsIOS(iOS);
// // Android detection
// const android = /android/i.test(userAgent);
// setIsAndroid(android);
// // PWA "standalone" mode detection (works for both iOS & Android)
// const isInStandaloneMode =
// window.matchMedia("(display-mode: standalone)").matches ||
// (navigator as any).standalone === true; // for iOS Safari
// setIsStandalone(isInStandaloneMode);
// }, []);
// return { isIOS, isAndroid, isStandalone };
// }
// function InstallPrompt() {
// const { isIOS, isAndroid, isStandalone } = usePwaInstallStatus();
// const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
// useEffect(() => {
// const handler = (e: any) => {
// e.preventDefault();
// setDeferredPrompt(e);
// };
// window.addEventListener("beforeinstallprompt", handler);
// return () => {
// window.removeEventListener("beforeinstallprompt", handler);
// };
// }, []);
// const handleInstallClick = () => {
// console.log("install clicked");
// if (deferredPrompt) {
// deferredPrompt.prompt();
// deferredPrompt.userChoice.then((choiceResult: any) => {
// if (choiceResult.outcome === "accepted") {
// console.log("User accepted the A2HS prompt");
// } else {
// console.log("User dismissed the A2HS prompt");
// }
// setDeferredPrompt(null);
// });
// }
// };
// if (isStandalone) {
// return null; // Already running as a PWA, no need to show install prompt
// }
// return (
// <div className="absolute w-full h-20 z-50 bg-red-500 left-0 flex items-center justify-center right-0 bottom-0 text-white">
// {isIOS && <p>Tap "Share" and "Add to Home Screen" to install the app.</p>}
// {isAndroid && <button onClick={handleInstallClick}>Install App</button>}
// </div>
// );
// }
// export default function PWAWrapper({
// children,
// }: {
// children: React.ReactNode;
// }) {
// return (
// <>
// <InstallPrompt />
// {children}
// </>
// );
// }

View File

@ -1,50 +0,0 @@
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { User } from "next-auth";
import Avatar from "@/components/avatar";
import { LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Icons } from "@/components/icons";
import Link from "next/link";
export default function UserDropdown({ user }: { user: User }) {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar fb={user.name} src={user.image} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="justify-between " asChild>
<Link href={"/me"}>
Profile
<Icons.user className="size-4 text-foreground" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="justify-between " asChild>
<Link href={"/billing"}>
Billing
<Icons.wallet className="size-4 text-foreground" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="justify-between text-destructive" asChild>
<Link href={"/api/auth/signout"}>
Logout
<Icons.logout className="size-4 text-destructive" />
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,3 +0,0 @@
import { handlers } from "@/server/auth";
export const { GET, POST } = handlers;

View File

@ -0,0 +1,43 @@
import { env } from "@/env";
import { db } from "@/server/db";
import { users } from "@/server/db/schema";
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import { eq } from "drizzle-orm";
export async function POST(req: Request) {
try {
const evt = await verifyWebhook(req, {
signingSecret: env.CLERK_WEBHOOK_SIGNING_SECRET,
});
const eventType = evt.type;
switch (eventType) {
case "user.created":
await db.insert(users).values({
id: evt.data.id,
name: evt.data.first_name ?? evt.data.last_name,
image: evt.data.image_url,
});
break;
case "user.updated":
await db
.update(users)
.set({
name: evt.data.first_name ?? evt.data.last_name,
image: evt.data.image_url,
})
.where(eq(users.id, evt.data.id));
break;
case "user.deleted":
await db.delete(users).where(eq(users.id, evt.data.id!));
break;
default:
console.log("Unknown event type:", eventType);
break;
}
return new Response("Webhook received", { status: 200 });
} catch (err) {
console.error("Error verifying webhook:", err);
return new Response("Error verifying webhook", { status: 400 });
}
}

View File

@ -3,8 +3,7 @@ import "@/styles/globals.css";
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { TRPCReactProvider } from "@/trpc/react";
import { ThemeProvider } from "@/components/theme-provider";
import Providers from "./providers";
// import PWAWrapper from "./_components/install-prompt";
export const metadata: Metadata = {
@ -24,19 +23,10 @@ export default function RootLayout({
return (
<html lang="en" className={`${geist.variable}`}>
<body>
<TRPCReactProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{/* <PWAWrapper>
</PWAWrapper> */}
{children}
<Toaster position="top-center" />
</ThemeProvider>
</TRPCReactProvider>
<Providers>
{children}
<Toaster position="top-center" />
</Providers>
</body>
</html>
);

32
src/app/providers.tsx Normal file
View File

@ -0,0 +1,32 @@
import React from "react";
import { TRPCReactProvider } from "@/trpc/react";
import { ThemeProvider } from "@/components/theme-provider";
import { ClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes";
function Providers({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider
appearance={{
baseTheme: dark,
elements: {
userButtonAvatarBox: { height: "2rem", width: "2rem" },
},
}}
>
<TRPCReactProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</TRPCReactProvider>
</ClerkProvider>
);
}
export default Providers;

View File

@ -12,9 +12,12 @@ export default function Header({
return (
<div className="w-full p-4 border-b flex justify-between items-center relative">
<h2 className="font-black text-2xl uppercase">{text}</h2>
<div className="glow absolute right-0 top-0 w-20 h-8 bg-primary blur-2xl -z-10" />
{children}
{/* Glow effect */}
{glow && (
<div className="glow absolute right-0 top-0 w-20 h-8 blur-2xl -z-10 bg-gradient-to-bl from-primary to-transparent" />
)}
</div>
);
}

View File

@ -4,6 +4,7 @@ type IconName =
| "logo"
| "home"
| "wallet"
| "brokenWallet"
| "add"
| "group"
| "friends"
@ -81,6 +82,23 @@ export const Icons: Record<IconName, IconComponent> = {
</svg>
);
},
brokenWallet(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M6.18465 3.97712L9.16135 13.118C9.16869 13.1406 9.177 13.1626 9.18622 13.1839V15.2C9.18622 15.6142 9.52201 15.95 9.93622 15.95C10.3504 15.95 10.6862 15.6142 10.6862 15.2V3.5817C10.9817 3.45057 11.3028 3.37548 11.6418 3.32991C12.2366 3.24995 12.9858 3.24997 13.8842 3.25L18.4918 3.25C21.3606 3.25 23.6863 5.57564 23.6863 8.44445C23.6863 9.27618 23.2093 10.0342 22.4595 10.3941L21.9721 10.6281C20.8211 11.1806 20.8211 12.8194 21.9721 13.3719L22.4595 13.6059C23.2093 13.9658 23.6863 14.7238 23.6863 15.5556C23.6863 18.4244 21.3606 20.75 18.4918 20.75H9.93793C10.3514 20.7491 10.6862 20.4136 10.6862 20V18.8C10.6862 18.3858 10.3504 18.05 9.93622 18.05C9.52201 18.05 9.18622 18.3858 9.18622 18.8V20C9.18622 20.4134 9.5207 20.7487 9.9338 20.75L6.81991 20.75C5.07675 20.75 3.46745 19.8153 2.60378 18.3011L2.41643 17.9726C2.33486 17.8296 2.272 17.6767 2.22939 17.5177C2.03805 16.8036 2.27319 16.043 2.83417 15.5615L3.35914 15.1109C4.32795 14.2793 3.90377 12.6963 2.64897 12.4605L2.11766 12.3607C1.30023 12.2071 0.643309 11.5984 0.428041 10.795C-0.314462 8.02393 1.33001 5.17562 4.10107 4.43312L4.13954 4.42281C4.81066 4.24297 5.37063 4.09291 5.8359 4.01936C5.9532 4.00082 6.06938 3.98623 6.18465 3.97712Z"
fill="currentColor"
/>
</svg>
);
},
add(props) {
return (
<svg

View File

@ -38,8 +38,10 @@ function NavLink({ href, name, icon }: NavLink) {
</span>
</Link>
</Button>
{/* Glow effect */}
{active && (
<div className="h-16 w-16 left-1/2 -z-10 -translate-x-1/2 transform blur-3xl absolute -bottom-8 bg-primary" />
<div className="size-16 left-1/2 -z-10 -translate-x-1/2 transform blur-3xl absolute -bottom-8 bg-gradient-to-t from-primary to-transparent" />
)}
</div>
);

View File

@ -2,51 +2,59 @@ import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
AUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string()
: z.string().optional(),
AUTH_DISCORD_ID: z.string(),
AUTH_DISCORD_SECRET: z.string(),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
},
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
// AUTH_SECRET:
// process.env.NODE_ENV === "production"
// ? z.string()
// : z.string().optional(),
// AUTH_DISCORD_ID: z.string(),
// AUTH_DISCORD_SECRET: z.string(),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
CLERK_SECRET_KEY: z.string(),
CLERK_WEBHOOK_SIGNING_SECRET: z.string(),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_DISCORD_ID: process.env.AUTH_DISCORD_ID,
AUTH_DISCORD_SECRET: process.env.AUTH_DISCORD_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
// AUTH_SECRET: process.env.AUTH_SECRET,
// AUTH_DISCORD_ID: process.env.AUTH_DISCORD_ID,
// AUTH_DISCORD_SECRET: process.env.AUTH_DISCORD_SECRET,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
CLERK_WEBHOOK_SIGNING_SECRET: process.env.CLERK_WEBHOOK_SIGNING_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

View File

@ -16,3 +16,5 @@ export function debounce<T extends (...args: any[]) => void>(
timeoutId = setTimeout(() => func(...args), delay);
};
}
export const getAmount = (amount: number) => `${amount.toFixed(2)}`;

21
src/middleware.ts Normal file
View File

@ -0,0 +1,21 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtectedRoute = createRouteMatcher([
"/",
"/friend",
"/expense",
"/group",
"/add",
]);
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) await auth.protect();
});
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
// Always run for API routes
"/(api|trpc)(.*)",
],
};

View File

@ -1,6 +1,5 @@
import { expenseRouter } from "@/server/api/routers/expense";
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
import { friendRouter } from "./routers/friend";
import { expenseRouter, friendRouter, userRouter } from "./routers";
/**
* This is the primary router for your server.
@ -10,6 +9,7 @@ import { friendRouter } from "./routers/friend";
export const appRouter = createTRPCRouter({
expense: expenseRouter,
friend: friendRouter,
user: userRouter,
});
// export type definition of API

View File

@ -5,17 +5,41 @@ import { expenses, expenseSplits, type ExpenseSplit } from "@/server/db/schema";
import { expenseSchema, expenseSplitSchema } from "@/lib/validations/expense";
import { eq, or } from "drizzle-orm";
function restructureExpenseSplits(expenseSplits: Array<ExpenseSplit>) {
const expensesMap = new Map();
expenseSplits.forEach((split) => {
const expenseId = split.expenseId;
const expense = split.expense;
if (!expensesMap.has(expenseId)) {
expensesMap.set(expenseId, {
...expense,
splits: [],
});
}
expensesMap.get(expenseId).splits.push(split);
});
return Array.from(expensesMap.values());
}
export const expenseRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.expenseSplits.findMany({
const splits = await ctx.db.query.expenseSplits.findMany({
where: or(
eq(expenseSplits.owedFromId, ctx.session.user.id),
eq(expenseSplits.owedToId, ctx.session.user.id)
eq(expenseSplits.owedFromId, ctx.auth.userId),
eq(expenseSplits.owedToId, ctx.auth.userId)
),
with: {
owedFrom: true,
owedTo: true,
expense: true,
},
});
const expenses = restructureExpenseSplits(splits);
return expenses;
}),
create: protectedProcedure
.input(
@ -34,7 +58,7 @@ export const expenseRouter = createTRPCRouter({
const [expense] = await ctx.db
.insert(expenses)
.values({
createdById: ctx.session.user.id,
createdById: ctx.auth.userId,
...input.expense,
amount: input.expense.amount.toString(),
})

View File

@ -10,8 +10,8 @@ export const friendRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
const friends = await ctx.db.query.friendships.findMany({
where: or(
eq(friendships.userOneId, ctx.session.user.id),
eq(friendships.userTwoId, ctx.session.user.id)
eq(friendships.userOneId, ctx.auth.userId),
eq(friendships.userTwoId, ctx.auth.userId)
),
with: {
userOne: true,
@ -21,8 +21,8 @@ export const friendRouter = createTRPCRouter({
return friends.map((f) => ({
id: f.id,
status: f.status,
requestedBy: ctx.session.user.id === f.userOneId ? "me" : "them",
user: ctx.session.user.id === f.userOneId ? f.userTwo : f.userOne,
requestedBy: ctx.auth.userId === f.userOneId ? "me" : "them",
user: ctx.auth.userId === f.userOneId ? f.userTwo : f.userOne,
}));
}),
getPendingFriendRequests: protectedProcedure.query(
@ -30,8 +30,8 @@ export const friendRouter = createTRPCRouter({
await ctx.db.query.friendships.findMany({
where: and(
or(
eq(friendships.userOneId, ctx.session.user.id),
eq(friendships.userTwoId, ctx.session.user.id)
eq(friendships.userOneId, ctx.auth.userId),
eq(friendships.userTwoId, ctx.auth.userId)
),
eq(friendships.status, "pending")
),
@ -40,7 +40,7 @@ export const friendRouter = createTRPCRouter({
search: protectedProcedure
.input(z.object({ search: z.string() }))
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
const userId = ctx.auth.userId;
const friendIds = await ctx.db.query.friendships.findMany({
where: and(
or(
@ -91,7 +91,7 @@ export const friendRouter = createTRPCRouter({
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(friendships).values({
userOneId: ctx.session.user.id,
userOneId: ctx.auth.userId,
userTwoId: input.userId,
status: "pending",
});

View File

@ -0,0 +1,3 @@
export { friendRouter } from "./friend";
export { expenseRouter } from "./expense";
export { userRouter } from "./user";

View File

@ -1,14 +1,12 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { users } from "@/server/db/schema";
export const userRouter = createTRPCRouter({
getAll: protectedProcedure.query(
async ({ ctx }) =>
await ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
image: true,
},
})
),
getSessionUser: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.auth.userId),
});
return user;
}),
});

View File

@ -11,8 +11,8 @@ import { TRPCError, initTRPC } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
import { auth } from "@clerk/nextjs/server";
/**
* 1. CONTEXT
@ -27,13 +27,13 @@ import { db } from "@/server/db";
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth();
const clerkAuth = await auth();
return {
db,
session,
...opts,
};
return {
db,
auth: clerkAuth,
...opts,
};
};
/**
@ -44,17 +44,17 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
* errors on the backend.
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
/**
@ -85,20 +85,20 @@ export const createTRPCRouter = t.router;
* network latency that would occur in production but not in local development.
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
const start = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
return result;
});
/**
@ -119,15 +119,15 @@ export const publicProcedure = t.procedure.use(timingMiddleware);
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure
.use(timingMiddleware)
.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});
.use(timingMiddleware)
.use(({ ctx, next }) => {
if (!ctx.auth?.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
auth: { ...ctx.auth, userId: ctx.auth.userId },
},
});
});

View File

@ -1,70 +0,0 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import type { DefaultSession, NextAuthConfig, User } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import { db } from "@/server/db";
import {
accounts,
sessions,
users,
verificationTokens,
} from "@/server/db/schema";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
type PublicUser = Pick<User, "id" | "name" | "image">;
}
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authConfig = {
trustHost: true,
providers: [
DiscordProvider,
/**
* ...add more providers here.
*
* Most other providers require a bit more work than the Discord provider. For example, the
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
*
* @see https://next-auth.js.org/providers/github
*/
],
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
},
}),
},
} satisfies NextAuthConfig;

View File

@ -1,10 +0,0 @@
import NextAuth from "next-auth";
import { cache } from "react";
import { authConfig } from "./config";
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
const auth = cache(uncachedAuth);
export { auth, handlers, signIn, signOut };

View File

@ -9,7 +9,7 @@ import * as schema from "./schema";
* update.
*/
const globalForDb = globalThis as unknown as {
conn: postgres.Sql | undefined;
conn: postgres.Sql | undefined;
};
const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);

View File

@ -1,8 +1,6 @@
import { relations, sql } from "drizzle-orm";
import { index, pgTableCreator, primaryKey } from "drizzle-orm/pg-core";
import type { User } from "next-auth";
import type { AdapterAccount } from "next-auth/adapters";
import { index, pgTableCreator } from "drizzle-orm/pg-core";
import { createId as createCuid2 } from "@paralleldrive/cuid2";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
@ -11,17 +9,16 @@ import type { AdapterAccount } from "next-auth/adapters";
*/
export const createTable = pgTableCreator((name) => `betterwise_${name}`);
const createId = () => createCuid2();
export const expenses = createTable(
"expense",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
groupId: d.uuid(),
friendId: d.uuid(),
id: d.varchar().primaryKey().$defaultFn(createId),
groupId: d.varchar(),
friendId: d.varchar(),
createdById: d
.uuid()
.varchar()
.notNull()
.references(() => users.id),
amount: d.numeric({ scale: 2 }),
@ -61,20 +58,17 @@ export type Expense = typeof expenses.$inferSelect & {
export const expenseSplits = createTable(
"expense_split",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().primaryKey().$defaultFn(createId),
expenseId: d
.uuid()
.varchar()
.notNull()
.references(() => expenses.id, { onDelete: "cascade" }),
owedToId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id),
owedFromId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id),
amount: d.numeric({ scale: 2 }),
@ -105,22 +99,20 @@ export const expenseSplitRelations = relations(expenseSplits, ({ one }) => ({
export type ExpenseSplit = typeof expenseSplits.$inferSelect & {
expense?: Expense;
paidBy?: User;
owedBy?: User;
owedTo?: User;
owedFrom?: User;
};
export const settlements = createTable(
"settlement",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().primaryKey().$defaultFn(createId),
payerId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id),
receiverId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id),
amount: d.numeric().notNull(),
@ -153,18 +145,16 @@ export type Settlement = typeof settlements.$inferSelect & {
};
// Groups Table
export const groups = createTable(
"group",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().primaryKey().$defaultFn(createId),
name: d.varchar({ length: 255 }).notNull(),
createdById: d
.uuid()
.varchar()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
.references(() => users.id),
createdAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
@ -190,16 +180,13 @@ export type Group = typeof groups.$inferSelect & {
export const groupMembers = createTable(
"group_member",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().primaryKey().$defaultFn(createId),
groupId: d
.uuid()
.varchar()
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
joinedAt: d
@ -233,19 +220,17 @@ export type GroupMember = typeof groupMembers.$inferSelect & {
};
// Friendships Table (Tracks friend relationships)
export const friendships = createTable(
"friendship",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().primaryKey().$defaultFn(createId),
userOneId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
userTwoId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
status: d
@ -281,76 +266,11 @@ export type Friendship = typeof friendships.$inferSelect & {
};
export const users = createTable("user", (d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().notNull().primaryKey().unique(),
name: d.varchar({ length: 255 }),
email: d.varchar({ length: 255 }).notNull(),
emailVerified: d
.timestamp({
mode: "date",
withTimezone: true,
})
.default(sql`CURRENT_TIMESTAMP`),
image: d.varchar({ length: 255 }),
}));
export const usersRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
}));
export type User = typeof users.$inferSelect;
export const accounts = createTable(
"account",
(d) => ({
userId: d
.uuid()
.notNull()
.references(() => users.id),
type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
provider: d.varchar({ length: 255 }).notNull(),
providerAccountId: d.varchar({ length: 255 }).notNull(),
refresh_token: d.text(),
access_token: d.text(),
expires_at: d.integer(),
token_type: d.varchar({ length: 255 }),
scope: d.varchar({ length: 255 }),
id_token: d.text(),
session_state: d.varchar({ length: 255 }),
}),
(t) => [
primaryKey({ columns: [t.provider, t.providerAccountId] }),
index("account_user_id_idx").on(t.userId),
]
);
export const accountsRelations = relations(accounts, ({ one }) => ({
user: one(users, { fields: [accounts.userId], references: [users.id] }),
}));
export const sessions = createTable(
"session",
(d) => ({
sessionToken: d.varchar({ length: 255 }).notNull().primaryKey(),
userId: d
.uuid()
.notNull()
.references(() => users.id),
expires: d.timestamp({ mode: "date", withTimezone: true }).notNull(),
}),
(t) => [index("t_user_id_idx").on(t.userId)]
);
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const verificationTokens = createTable(
"verification_token",
(d) => ({
identifier: d.varchar({ length: 255 }).notNull(),
token: d.varchar({ length: 255 }).notNull(),
expires: d.timestamp({ mode: "date", withTimezone: true }).notNull(),
}),
(t) => [primaryKey({ columns: [t.identifier, t.token] })]
);
// export const usersRelations = relations(users, ({ many }) => ({}));