migrated from auth.js to clerk; some ui remake; added expense-card
This commit is contained in:
parent
cd8a8d746d
commit
a2492fefbd
@ -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
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
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 />
|
||||
|
||||
7
src/app/(router)/sign-in/page.tsx
Normal file
7
src/app/(router)/sign-in/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
function Page() {
|
||||
return <div>SignIn page</div>;
|
||||
}
|
||||
|
||||
export default Page;
|
||||
87
src/app/_components/expense/expense-card.tsx
Normal file
87
src/app/_components/expense/expense-card.tsx
Normal 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;
|
||||
8
src/app/_components/expense/expense-details.tsx
Normal file
8
src/app/_components/expense/expense-details.tsx
Normal 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;
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
import { handlers } from "@/server/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
43
src/app/api/webhooks/route.ts
Normal file
43
src/app/api/webhooks/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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
32
src/app/providers.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
98
src/env.js
98
src/env.js
@ -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,
|
||||
});
|
||||
|
||||
@ -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
21
src/middleware.ts
Normal 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)(.*)",
|
||||
],
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
3
src/server/api/routers/index.ts
Normal file
3
src/server/api/routers/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { friendRouter } from "./friend";
|
||||
export { expenseRouter } from "./expense";
|
||||
export { userRouter } from "./user";
|
||||
@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
@ -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 };
|
||||
@ -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);
|
||||
|
||||
@ -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 }) => ({}));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user