Merge pull request #35 from atridadl/dev
2.0.0 F-f-f-f-f-faster! No more Next-Auth holding me down! ✨ Auth moved from next-auth -> Clerk ✨ ORM moved from Prisma to Drizzle ✨ Major version number is one whole number higher! Wow!
This commit is contained in:
commit
0d2005a8e5
32 changed files with 1743 additions and 2102 deletions
|
@ -8,9 +8,9 @@ UPSTASH_REDIS_EXPIRY_SECONDS=""
|
||||||
UPSTASH_RATELIMIT_REQUESTS=""
|
UPSTASH_RATELIMIT_REQUESTS=""
|
||||||
UPSTASH_RATELIMIT_SECONDS=""
|
UPSTASH_RATELIMIT_SECONDS=""
|
||||||
|
|
||||||
#Next Auth Core
|
#Auth
|
||||||
NEXTAUTH_SECRET=""
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
|
||||||
NEXTAUTH_URL=""
|
CLERK_SECRET_KEY=""
|
||||||
|
|
||||||
# Next Auth Github Provider
|
# Next Auth Github Provider
|
||||||
GITHUB_CLIENT_ID=""
|
GITHUB_CLIENT_ID=""
|
||||||
|
|
12
drizzle.config.ts
Normal file
12
drizzle.config.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import type { Config } from "drizzle-kit";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: "./src/server/schema.ts",
|
||||||
|
out: "./drizzle/generated",
|
||||||
|
driver: "mysql2",
|
||||||
|
breakpoints: true,
|
||||||
|
dbCredentials: {
|
||||||
|
connectionString: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
|
@ -10,7 +10,11 @@ const config = {
|
||||||
defaultLocale: "en",
|
defaultLocale: "en",
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
domains: ["avatars.githubusercontent.com", "lh3.googleusercontent.com"],
|
domains: [
|
||||||
|
"avatars.githubusercontent.com",
|
||||||
|
"lh3.googleusercontent.com",
|
||||||
|
"img.clerk.com",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
25
package.json
25
package.json
|
@ -1,34 +1,35 @@
|
||||||
{
|
{
|
||||||
"name": "sprintpadawan",
|
"name": "sprintpadawan",
|
||||||
"version": "1.2.7",
|
"version": "2.0.0",
|
||||||
"description": "Plan. Sprint. Repeat.",
|
"description": "Plan. Sprint. Repeat.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"serv": "NEXTAUTH_URL=http://localhost:3000 && next dev",
|
"serv": "next dev",
|
||||||
"dev": "pnpm serv",
|
"dev": "pnpm serv",
|
||||||
"postinstall": "prisma generate",
|
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ably-labs/react-hooks": "^2.1.1",
|
"@ably-labs/react-hooks": "^2.1.1",
|
||||||
"@auth/prisma-adapter": "^1.0.1",
|
"@clerk/nextjs": "^4.23.2",
|
||||||
"@prisma/client": "5.1.1",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
|
"@planetscale/database": "^1.10.0",
|
||||||
"@react-email/components": "^0.0.7",
|
"@react-email/components": "^0.0.7",
|
||||||
"@tanstack/react-query": "^4.32.6",
|
"@tanstack/react-query": "^4.32.6",
|
||||||
"@trpc/client": "10.37.1",
|
"@trpc/client": "10.37.1",
|
||||||
"@trpc/next": "10.37.1",
|
"@trpc/next": "10.37.1",
|
||||||
"@trpc/react-query": "10.37.1",
|
"@trpc/react-query": "10.37.1",
|
||||||
"@trpc/server": "10.37.1",
|
"@trpc/server": "10.37.1",
|
||||||
"@unkey/api": "^0.5.0",
|
"@unkey/api": "^0.6.9",
|
||||||
"@upstash/ratelimit": "^0.4.3",
|
"@upstash/ratelimit": "^0.4.3",
|
||||||
"@upstash/redis": "^1.22.0",
|
"@upstash/redis": "^1.22.0",
|
||||||
"ably": "^1.2.42",
|
"ably": "^1.2.43",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"drizzle-orm": "^0.28.2",
|
||||||
"json2csv": "6.0.0-alpha.2",
|
"json2csv": "6.0.0-alpha.2",
|
||||||
"next": "^13.4.13",
|
"next": "^13.4.13",
|
||||||
"next-auth": "^4.22.5",
|
|
||||||
"nextjs-cors": "^2.1.2",
|
"nextjs-cors": "^2.1.2",
|
||||||
"postcss": "^8.4.27",
|
"postcss": "^8.4.27",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
@ -44,14 +45,14 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/eslint": "^8.44.2",
|
"@types/eslint": "^8.44.2",
|
||||||
"@types/json2csv": "^5.0.3",
|
"@types/json2csv": "^5.0.3",
|
||||||
"@types/node": "^20.4.9",
|
"@types/node": "^20.4.10",
|
||||||
"@types/react": "^18.2.19",
|
"@types/react": "^18.2.20",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
||||||
"@typescript-eslint/parser": "^6.3.0",
|
"@typescript-eslint/parser": "^6.3.0",
|
||||||
"daisyui": "^3.5.1",
|
"daisyui": "^3.5.1",
|
||||||
"eslint": "^8.46.0",
|
"drizzle-kit": "^0.19.12",
|
||||||
|
"eslint": "^8.47.0",
|
||||||
"eslint-config-next": "^13.4.13",
|
"eslint-config-next": "^13.4.13",
|
||||||
"prisma": "5.1.1",
|
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^5.1.6"
|
"typescript": "^5.1.6"
|
||||||
},
|
},
|
||||||
|
|
1682
pnpm-lock.yaml
generated
1682
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -1,109 +0,0 @@
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "mysql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
relationMode = "prisma"
|
|
||||||
}
|
|
||||||
|
|
||||||
model Account {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
userId String
|
|
||||||
type String
|
|
||||||
provider String
|
|
||||||
providerAccountId String
|
|
||||||
refresh_token String? @db.Text
|
|
||||||
access_token String? @db.Text
|
|
||||||
expires_at Int?
|
|
||||||
token_type String?
|
|
||||||
scope String?
|
|
||||||
id_token String? @db.Text
|
|
||||||
session_state String?
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Session {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
sessionToken String @unique
|
|
||||||
userId String
|
|
||||||
expires DateTime
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
name String?
|
|
||||||
email String? @unique
|
|
||||||
emailVerified DateTime?
|
|
||||||
image String?
|
|
||||||
accounts Account[]
|
|
||||||
sessions Session[]
|
|
||||||
rooms Room[]
|
|
||||||
votes Vote[]
|
|
||||||
logs Log[]
|
|
||||||
isAdmin Boolean @default(false)
|
|
||||||
isVIP Boolean @default(false)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
model VerificationToken {
|
|
||||||
identifier String
|
|
||||||
token String @unique
|
|
||||||
expires DateTime
|
|
||||||
|
|
||||||
@@unique([identifier, token])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Room {
|
|
||||||
id String @id @unique @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
userId String
|
|
||||||
roomName String
|
|
||||||
storyName String
|
|
||||||
visible Boolean
|
|
||||||
votes Vote[]
|
|
||||||
scale String
|
|
||||||
logs Log[]
|
|
||||||
owner User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Vote {
|
|
||||||
id String @id @unique @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
userId String
|
|
||||||
roomId String
|
|
||||||
value String
|
|
||||||
owner User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([userId, roomId])
|
|
||||||
@@index([roomId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Log {
|
|
||||||
id String @id @unique @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
userId String
|
|
||||||
roomId String
|
|
||||||
scale String
|
|
||||||
votes Json
|
|
||||||
roomName String
|
|
||||||
storyName String
|
|
||||||
owner User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@index([roomId])
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { signIn, signOut, useSession } from "next-auth/react";
|
import { UserButton, useUser } from "@clerk/nextjs";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
@ -9,19 +9,22 @@ interface NavbarProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Navbar = ({ title }: NavbarProps) => {
|
const Navbar = ({ title }: NavbarProps) => {
|
||||||
const { data: sessionData, status: sessionStatus } = useSession();
|
const { isLoaded, isSignedIn } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const navigationMenu = () => {
|
const navigationMenu = () => {
|
||||||
if (sessionStatus === "authenticated" && router.pathname !== "/dashboard") {
|
if (router.pathname !== "/dashboard" && isSignedIn) {
|
||||||
return (
|
return (
|
||||||
<Link className="btn btn-secondary btn-outline mx-2" href="/dashboard">
|
<Link className="btn btn-secondary btn-outline mx-2" href="/dashboard">
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
} else if (sessionStatus === "unauthenticated") {
|
} else if (!isSignedIn) {
|
||||||
return (
|
return (
|
||||||
<button className="btn btn-secondary" onClick={() => void signIn()}>
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => void router.push("/sign-in")}
|
||||||
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@ -51,7 +54,7 @@ const Navbar = ({ title }: NavbarProps) => {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sessionStatus === "loading" ? (
|
{!isLoaded ? (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<span className="loading loading-dots loading-lg"></span>
|
<span className="loading loading-dots loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -59,53 +62,7 @@ const Navbar = ({ title }: NavbarProps) => {
|
||||||
navigationMenu()
|
navigationMenu()
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sessionData?.user.image && (
|
<UserButton afterSignOutUrl="/" />
|
||||||
<div className="flex-none gap-2">
|
|
||||||
<div className="dropdown dropdown-end">
|
|
||||||
<label tabIndex={0} className="btn btn-ghost btn-circle avatar">
|
|
||||||
<div className="w-10 rounded-full">
|
|
||||||
<Image
|
|
||||||
src={sessionData.user.image}
|
|
||||||
alt="Profile picture."
|
|
||||||
height={32}
|
|
||||||
width={32}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<ul
|
|
||||||
tabIndex={0}
|
|
||||||
className="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 rounded-box z-50"
|
|
||||||
>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
about="Profile Page"
|
|
||||||
href="/profile"
|
|
||||||
className="justify-between"
|
|
||||||
>
|
|
||||||
Profile
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
{sessionData.user.isAdmin && (
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
about="Admin Page"
|
|
||||||
href="/admin"
|
|
||||||
className="justify-between"
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm text-center whitespace-nowrap"
|
|
||||||
onClick={() => void signOut({ callbackUrl: "/" })}
|
|
||||||
>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,25 +1,24 @@
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { configureAbly, useChannel } from "@ably-labs/react-hooks";
|
import { configureAbly, useChannel } from "@ably-labs/react-hooks";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { IoEnterOutline, IoTrashBinOutline } from "react-icons/io5";
|
import { IoEnterOutline, IoTrashBinOutline } from "react-icons/io5";
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env.mjs";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
import { useUser } from "@clerk/nextjs";
|
||||||
|
|
||||||
const RoomList = () => {
|
const RoomList = () => {
|
||||||
const { data: sessionData } = useSession();
|
const { isSignedIn, user } = useUser();
|
||||||
|
|
||||||
configureAbly({
|
configureAbly({
|
||||||
key: env.NEXT_PUBLIC_ABLY_PUBLIC_KEY,
|
key: env.NEXT_PUBLIC_ABLY_PUBLIC_KEY,
|
||||||
clientId: sessionData?.user.id,
|
clientId: user?.id,
|
||||||
recover: (_, cb) => {
|
recover: (_, cb) => {
|
||||||
cb(true);
|
cb(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [] = useChannel(
|
const [] = useChannel(
|
||||||
`${env.NEXT_PUBLIC_APP_ENV}-${sessionData ? sessionData.user.id : ""}`,
|
`${env.NEXT_PUBLIC_APP_ENV}-${user?.id}`,
|
||||||
() => void refetchRoomsFromDb()
|
() => void refetchRoomsFromDb()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -27,7 +26,7 @@ const RoomList = () => {
|
||||||
|
|
||||||
const { data: roomsFromDb, refetch: refetchRoomsFromDb } =
|
const { data: roomsFromDb, refetch: refetchRoomsFromDb } =
|
||||||
api.room.getAll.useQuery(undefined, {
|
api.room.getAll.useQuery(undefined, {
|
||||||
enabled: sessionData?.user !== undefined,
|
enabled: isSignedIn,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createRoom = api.room.create.useMutation({});
|
const createRoom = api.room.create.useMutation({});
|
||||||
|
@ -43,7 +42,7 @@ const RoomList = () => {
|
||||||
const deleteRoom = api.room.delete.useMutation({});
|
const deleteRoom = api.room.delete.useMutation({});
|
||||||
|
|
||||||
const deleteRoomHandler = (roomId: string) => {
|
const deleteRoomHandler = (roomId: string) => {
|
||||||
if (sessionData) {
|
if (isSignedIn) {
|
||||||
deleteRoom.mutate({ id: roomId });
|
deleteRoom.mutate({ id: roomId });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,83 +0,0 @@
|
||||||
import { configureAbly, useChannel } from "@ably-labs/react-hooks";
|
|
||||||
import { env } from "~/env.mjs";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
const Stats = () => {
|
|
||||||
configureAbly({
|
|
||||||
key: env.NEXT_PUBLIC_ABLY_PUBLIC_KEY,
|
|
||||||
recover: (_, cb) => {
|
|
||||||
cb(true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [] = useChannel(
|
|
||||||
`${env.NEXT_PUBLIC_APP_ENV}-stats`,
|
|
||||||
() => void refetchData()
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: usersCount,
|
|
||||||
isLoading: usersCountLoading,
|
|
||||||
isFetching: usersCountFetching,
|
|
||||||
refetch: refetchUsersCount,
|
|
||||||
} = api.rest.userCount.useQuery();
|
|
||||||
const {
|
|
||||||
data: roomsCount,
|
|
||||||
isLoading: roomsCountLoading,
|
|
||||||
isFetching: roomsCountFetching,
|
|
||||||
refetch: refetchRoomsCount,
|
|
||||||
} = api.rest.roomCount.useQuery();
|
|
||||||
const {
|
|
||||||
data: votesCount,
|
|
||||||
isLoading: votesCountLoading,
|
|
||||||
isFetching: votesCountFetching,
|
|
||||||
refetch: refetchVotesCount,
|
|
||||||
} = api.rest.voteCount.useQuery();
|
|
||||||
|
|
||||||
const refetchData = async () => {
|
|
||||||
await Promise.all([
|
|
||||||
refetchUsersCount(),
|
|
||||||
refetchRoomsCount(),
|
|
||||||
refetchVotesCount(),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="stats stats-horizontal shadow bg-neutral m-4">
|
|
||||||
<div className="stat">
|
|
||||||
<div className="stat-title">Users</div>
|
|
||||||
<div className="stat-value">
|
|
||||||
{usersCountLoading || usersCountFetching ? (
|
|
||||||
<span className="loading loading-infinity loading-lg"></span>
|
|
||||||
) : (
|
|
||||||
<>{usersCount ? usersCount : "0"}</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat">
|
|
||||||
<div className="stat-title">Rooms</div>
|
|
||||||
<div className="stat-value">
|
|
||||||
{roomsCountLoading || roomsCountFetching ? (
|
|
||||||
<span className="loading loading-infinity loading-lg"></span>
|
|
||||||
) : (
|
|
||||||
<>{roomsCount ? roomsCount : "0"}</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat">
|
|
||||||
<div className="stat-title">Votes</div>
|
|
||||||
<div className="stat-value">
|
|
||||||
{votesCountLoading || votesCountFetching ? (
|
|
||||||
<span className="loading loading-infinity loading-lg"></span>
|
|
||||||
) : (
|
|
||||||
<>{votesCount ? votesCount : "0"}</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Stats;
|
|
18
src/env.mjs
18
src/env.mjs
|
@ -12,17 +12,6 @@ const server = z.object({
|
||||||
UPSTASH_RATELIMIT_REQUESTS: z.string(),
|
UPSTASH_RATELIMIT_REQUESTS: z.string(),
|
||||||
UPSTASH_RATELIMIT_SECONDS: z.string(),
|
UPSTASH_RATELIMIT_SECONDS: z.string(),
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||||
NEXTAUTH_SECRET:
|
|
||||||
process.env.NODE_ENV === "production"
|
|
||||||
? z.string().min(1)
|
|
||||||
: z.string().min(1).optional(),
|
|
||||||
NEXTAUTH_URL: z.preprocess(
|
|
||||||
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
|
|
||||||
// Since NextAuth.js automatically uses the VERCEL_URL if present.
|
|
||||||
(str) => process.env.VERCEL_URL ?? str,
|
|
||||||
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
|
|
||||||
process.env.VERCEL ? z.string().min(1) : z.string().url()
|
|
||||||
),
|
|
||||||
GITHUB_CLIENT_ID: z.string(),
|
GITHUB_CLIENT_ID: z.string(),
|
||||||
GITHUB_CLIENT_SECRET: z.string(),
|
GITHUB_CLIENT_SECRET: z.string(),
|
||||||
GOOGLE_CLIENT_ID: z.string(),
|
GOOGLE_CLIENT_ID: z.string(),
|
||||||
|
@ -31,6 +20,7 @@ const server = z.object({
|
||||||
APP_ENV: z.string(),
|
APP_ENV: z.string(),
|
||||||
RESEND_API_KEY: z.string(),
|
RESEND_API_KEY: z.string(),
|
||||||
UNKEY_ROOT_KEY: z.string(),
|
UNKEY_ROOT_KEY: z.string(),
|
||||||
|
CLERK_SECRET_KEY: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -40,6 +30,7 @@ const server = z.object({
|
||||||
const client = z.object({
|
const client = z.object({
|
||||||
NEXT_PUBLIC_ABLY_PUBLIC_KEY: z.string(),
|
NEXT_PUBLIC_ABLY_PUBLIC_KEY: z.string(),
|
||||||
NEXT_PUBLIC_APP_ENV: z.string(),
|
NEXT_PUBLIC_APP_ENV: z.string(),
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -56,8 +47,6 @@ const processEnv = {
|
||||||
UPSTASH_RATELIMIT_REQUESTS: process.env.UPSTASH_RATELIMIT_REQUESTS,
|
UPSTASH_RATELIMIT_REQUESTS: process.env.UPSTASH_RATELIMIT_REQUESTS,
|
||||||
UPSTASH_RATELIMIT_SECONDS: process.env.UPSTASH_RATELIMIT_SECONDS,
|
UPSTASH_RATELIMIT_SECONDS: process.env.UPSTASH_RATELIMIT_SECONDS,
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
|
||||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
|
||||||
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
|
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
|
||||||
GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
|
GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
|
||||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
@ -68,6 +57,9 @@ const processEnv = {
|
||||||
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
||||||
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
||||||
UNKEY_ROOT_KEY: process.env.UNKEY_ROOT_KEY,
|
UNKEY_ROOT_KEY: process.env.UNKEY_ROOT_KEY,
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
|
||||||
|
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||||
|
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't touch the part below
|
// Don't touch the part below
|
||||||
|
|
9
src/middleware.ts
Normal file
9
src/middleware.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { authMiddleware } from "@clerk/nextjs";
|
||||||
|
|
||||||
|
export default authMiddleware({
|
||||||
|
publicRoutes: ["/", "/api/(.*)"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
|
||||||
|
};
|
|
@ -1,56 +1,23 @@
|
||||||
import { type Session } from "next-auth";
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
|
||||||
import { type AppType } from "next/app";
|
import { type AppType } from "next/app";
|
||||||
|
import { ClerkProvider } from "@clerk/nextjs";
|
||||||
|
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Footer from "~/components/Footer";
|
import Footer from "~/components/Footer";
|
||||||
import Navbar from "~/components/Navbar";
|
import Navbar from "~/components/Navbar";
|
||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
const MyApp: AppType<{ session: Session | null }> = ({
|
const MyApp: AppType = ({ Component, pageProps }) => {
|
||||||
Component,
|
|
||||||
pageProps: { session, ...pageProps },
|
|
||||||
}) => {
|
|
||||||
const [pageLoading, setPageLoading] = useState<boolean>(false);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
router.events.on("routeChangeStart", () => {
|
|
||||||
setPageLoading(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.events.on("routeChangeComplete", () => {
|
|
||||||
setPageLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
router.events.off("routeChangeStart", () => {
|
|
||||||
setPageLoading(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.events.off("routeChangeComplete", () => {
|
|
||||||
setPageLoading(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, [router.events]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionProvider session={ session }>
|
<ClerkProvider {...pageProps}>
|
||||||
<div className="block h-[100%]">
|
<div className="block h-[100%]">
|
||||||
<Navbar title="Sprint Padawan" />
|
<Navbar title="Sprint Padawan" />
|
||||||
<div className="flex flex-row items-center justify-center min-h-[calc(100%-114px)]">
|
<div className="flex flex-row items-center justify-center min-h-[calc(100%-114px)]">
|
||||||
{ pageLoading ? (
|
<Component {...pageProps} />
|
||||||
<span className="loading loading-dots loading-lg"></span>
|
|
||||||
) : (
|
|
||||||
<Component { ...pageProps } />
|
|
||||||
) }
|
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</SessionProvider>
|
</ClerkProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,270 +0,0 @@
|
||||||
import { type GetServerSideProps, type NextPage } from "next";
|
|
||||||
import Head from "next/head";
|
|
||||||
|
|
||||||
import { AiOutlineClear } from "react-icons/ai";
|
|
||||||
import { FaShieldAlt } from "react-icons/fa";
|
|
||||||
import { IoTrashBinOutline } from "react-icons/io5";
|
|
||||||
import { SiGithub, SiGoogle } from "react-icons/si";
|
|
||||||
import { GiStarFormation } from "react-icons/gi";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
import { getServerAuthSession } from "../../server/auth";
|
|
||||||
import Stats from "~/components/Stats";
|
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
|
||||||
const session = await getServerAuthSession(ctx);
|
|
||||||
|
|
||||||
// Redirect to login if not signed in
|
|
||||||
if (!session) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: `/api/auth/signin?callbackUrl=${ctx.resolvedUrl}`,
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.user.isAdmin) {
|
|
||||||
ctx.res.statusCode = 403;
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Return session if logged in
|
|
||||||
return {
|
|
||||||
props: { session },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const Admin: NextPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>Sprint Padawan - Admin</title>
|
|
||||||
<meta name="description" content="Plan. Sprint. Repeat." />
|
|
||||||
</Head>
|
|
||||||
<div className="flex flex-col items-center justify-center text-center px-4 py-16 ">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<AdminBody />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Admin;
|
|
||||||
|
|
||||||
const AdminBody = () => {
|
|
||||||
const {
|
|
||||||
data: users,
|
|
||||||
isLoading: usersLoading,
|
|
||||||
isFetching: usersFetching,
|
|
||||||
refetch: refetchUsers,
|
|
||||||
} = api.user.getAll.useQuery();
|
|
||||||
|
|
||||||
const getProviders = (user: {
|
|
||||||
createdAt: Date;
|
|
||||||
accounts: {
|
|
||||||
provider: string;
|
|
||||||
}[];
|
|
||||||
sessions: {
|
|
||||||
id: string;
|
|
||||||
}[];
|
|
||||||
id: string;
|
|
||||||
isAdmin: boolean;
|
|
||||||
isVIP: boolean;
|
|
||||||
name: string | null;
|
|
||||||
email: string | null;
|
|
||||||
}) => {
|
|
||||||
return user.accounts.map((account) => {
|
|
||||||
return account.provider;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteUserMutation = api.user.delete.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
await refetchData();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const clearSessionsByUserMutation = api.session.deleteAllByUserId.useMutation(
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
await refetchData();
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearSessionsMutation = api.session.deleteAll.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
await refetchData();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const setAdminMutation = api.user.setAdmin.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
await refetchData();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const setVIPMutation = api.user.setVIP.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
await refetchData();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteUserHandler = async (userId: string) => {
|
|
||||||
await deleteUserMutation.mutateAsync({ userId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearSessionsByUserHandler = async (userId: string) => {
|
|
||||||
await clearSessionsByUserMutation.mutateAsync({ userId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearSessionsHandler = async () => {
|
|
||||||
await clearSessionsMutation.mutateAsync();
|
|
||||||
};
|
|
||||||
|
|
||||||
const setAdmin = async (userId: string, value: boolean) => {
|
|
||||||
await setAdminMutation.mutateAsync({ userId, value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const setVIP = async (userId: string, value: boolean) => {
|
|
||||||
await setVIPMutation.mutateAsync({ userId, value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const refetchData = async () => {
|
|
||||||
await Promise.all([refetchUsers()]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1 className="text-4xl font-bold">Admin Panel</h1>
|
|
||||||
|
|
||||||
<Stats />
|
|
||||||
|
|
||||||
{usersFetching ? (
|
|
||||||
<span className="loading loading-dots loading-lg"></span>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-row flex-wrap text-center items-center justify-center gap-2">
|
|
||||||
<button
|
|
||||||
className="btn btn-primary m-2"
|
|
||||||
onClick={() => void clearSessionsHandler()}
|
|
||||||
>
|
|
||||||
Delete All Sessions
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => void refetchData()}
|
|
||||||
>
|
|
||||||
Re-fetch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card max-w-[80vw] bg-neutral shadow-xl m-4">
|
|
||||||
<div className="card-body">
|
|
||||||
<h2 className="card-title">Users:</h2>
|
|
||||||
|
|
||||||
{usersLoading || usersFetching ? (
|
|
||||||
<span className="loading loading-dots loading-lg"></span>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-scroll">
|
|
||||||
<table className="table text-center">
|
|
||||||
{/* head */}
|
|
||||||
<thead>
|
|
||||||
<tr className="border-white">
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Created At</th>
|
|
||||||
<th># Sessions</th>
|
|
||||||
<th>Providers</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="">
|
|
||||||
{users
|
|
||||||
?.sort((user1, user2) =>
|
|
||||||
user2.createdAt > user1.createdAt ? 1 : -1
|
|
||||||
)
|
|
||||||
.map((user) => {
|
|
||||||
return (
|
|
||||||
<tr key={user.id} className="hover">
|
|
||||||
<td className="max-w-[100px] break-words">
|
|
||||||
{user.id}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="max-w-[100px] break-normal">
|
|
||||||
{user.name}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-[100px] break-normal">
|
|
||||||
{user.createdAt.toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-[100px] break-normal">
|
|
||||||
{user.sessions.length}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-[100px] break-normal">
|
|
||||||
{getProviders(user).includes("google") && (
|
|
||||||
<SiGoogle className="text-xl m-1 inline-block hover:text-secondary" />
|
|
||||||
)}
|
|
||||||
{getProviders(user).includes("github") && (
|
|
||||||
<SiGithub className="text-xl m-1 inline-block hover:text-secondary" />
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button className="m-2">
|
|
||||||
{user.isAdmin ? (
|
|
||||||
<FaShieldAlt
|
|
||||||
className="text-xl inline-block text-primary"
|
|
||||||
onClick={() => void setAdmin(user.id, false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FaShieldAlt
|
|
||||||
className="text-xl inline-block"
|
|
||||||
onClick={() => void setAdmin(user.id, true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button className="m-2">
|
|
||||||
{user.isVIP ? (
|
|
||||||
<GiStarFormation
|
|
||||||
className="text-xl inline-block text-secondary"
|
|
||||||
onClick={() => void setVIP(user.id, false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<GiStarFormation
|
|
||||||
className="text-xl inline-block"
|
|
||||||
onClick={() => void setVIP(user.id, true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="m-2"
|
|
||||||
onClick={() =>
|
|
||||||
void clearSessionsByUserHandler(user.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AiOutlineClear className="text-xl inline-block hover:text-warning" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="m-2"
|
|
||||||
onClick={() => void deleteUserHandler(user.id)}
|
|
||||||
>
|
|
||||||
<IoTrashBinOutline className="text-xl inline-block hover:text-error" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,4 +0,0 @@
|
||||||
import NextAuth from "next-auth";
|
|
||||||
import { authOptions } from "~/server/auth";
|
|
||||||
|
|
||||||
export default NextAuth(authOptions);
|
|
|
@ -10,9 +10,9 @@ export default createNextApiHandler({
|
||||||
onError:
|
onError:
|
||||||
env.NODE_ENV === "development"
|
env.NODE_ENV === "development"
|
||||||
? ({ path, error }) => {
|
? ({ path, error }) => {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import type { GetServerSideProps, NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
|
||||||
import RoomList from "~/components/RoomList";
|
import RoomList from "~/components/RoomList";
|
||||||
|
@ -7,27 +6,8 @@ import RoomList from "~/components/RoomList";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FaShieldAlt } from "react-icons/fa";
|
import { FaShieldAlt } from "react-icons/fa";
|
||||||
import { getServerAuthSession } from "~/server/auth";
|
|
||||||
import { GiStarFormation } from "react-icons/gi";
|
import { GiStarFormation } from "react-icons/gi";
|
||||||
|
import { useUser } from "@clerk/nextjs";
|
||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
|
||||||
const session = await getServerAuthSession(ctx);
|
|
||||||
|
|
||||||
// Redirect to login if not signed in
|
|
||||||
if (!session) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: `/api/auth/signin?callbackUrl=${ctx.resolvedUrl}`,
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return session if logged in
|
|
||||||
return {
|
|
||||||
props: { session },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const Home: NextPage = () => {
|
const Home: NextPage = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -46,23 +26,27 @@ const Home: NextPage = () => {
|
||||||
export default Home;
|
export default Home;
|
||||||
|
|
||||||
const HomePageBody = () => {
|
const HomePageBody = () => {
|
||||||
const { data: sessionData } = useSession();
|
const { isLoaded, user } = useUser();
|
||||||
const [joinRoomTextBox, setJoinRoomTextBox] = useState<string>("");
|
const [joinRoomTextBox, setJoinRoomTextBox] = useState<string>("");
|
||||||
const [tabIndex, setTabIndex] = useState<number>();
|
const [tabIndex, setTabIndex] = useState<number>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tabIndexLocal = localStorage.getItem(`dashboardTabIndex`);
|
const tabIndexLocal = localStorage.getItem(`dashboardTabIndex`);
|
||||||
setTabIndex(tabIndexLocal !== null ? Number(tabIndexLocal) : 0);
|
setTabIndex(tabIndexLocal !== null ? Number(tabIndexLocal) : 0);
|
||||||
}, [tabIndex, sessionData]);
|
}, [tabIndex, user]);
|
||||||
|
|
||||||
return (
|
return !isLoaded ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span className="loading loading-dots loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<h1 className="flex flex-row flex-wrap text-center justify-center items-center gap-1 text-4xl font-bold mx-auto">
|
<h1 className="flex flex-row flex-wrap text-center justify-center items-center gap-1 text-4xl font-bold mx-auto">
|
||||||
Hi, {sessionData?.user.name}!{" "}
|
Hi, {user?.fullName}!{" "}
|
||||||
{sessionData?.user.isAdmin && (
|
{(user?.publicMetadata.isAdmin as boolean | undefined) && (
|
||||||
<FaShieldAlt className="inline-block text-primary" />
|
<FaShieldAlt className="inline-block text-primary" />
|
||||||
)}
|
)}
|
||||||
{sessionData?.user.isVIP && (
|
{(user?.publicMetadata.isVIP as boolean | undefined) && (
|
||||||
<GiStarFormation className="inline-block text-secondary" />
|
<GiStarFormation className="inline-block text-secondary" />
|
||||||
)}
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { type NextPage } from "next";
|
import { type NextPage } from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Stats from "~/components/Stats";
|
|
||||||
|
|
||||||
const Home: NextPage = () => {
|
const Home: NextPage = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -55,13 +54,6 @@ const HomePageBody = () => {
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card card-compact bg-secondary text-black font-bold text-left">
|
|
||||||
<div className="card-body">
|
|
||||||
<h2 className="card-title">Stats:</h2>
|
|
||||||
<Stats />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,205 +0,0 @@
|
||||||
import { type GetServerSideProps, type NextPage } from "next";
|
|
||||||
import Head from "next/head";
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
import { signIn, useSession } from "next-auth/react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { FaShieldAlt } from "react-icons/fa";
|
|
||||||
import { SiGithub, SiGoogle } from "react-icons/si";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
import { getServerAuthSession } from "../../server/auth";
|
|
||||||
import { GiStarFormation } from "react-icons/gi";
|
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
|
||||||
const session = await getServerAuthSession(ctx);
|
|
||||||
|
|
||||||
// Redirect to login if not signed in
|
|
||||||
if (!session) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: `/api/auth/signin?callbackUrl=${ctx.resolvedUrl}`,
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return session if logged in
|
|
||||||
return {
|
|
||||||
props: { session },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const Profile: NextPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>Sprint Padawan - Profile</title>
|
|
||||||
<meta name="description" content="Plan. Sprint. Repeat." />
|
|
||||||
</Head>
|
|
||||||
<div className="flex flex-col items-center justify-center text-center gap-12 px-4 py-16 ">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<ProfileBody />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Profile;
|
|
||||||
|
|
||||||
const ProfileBody = () => {
|
|
||||||
const { data: sessionData } = useSession();
|
|
||||||
const [nameText, setNameText] = useState<string>("");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { data: providers, isLoading: providersLoading } =
|
|
||||||
api.user.getProviders.useQuery();
|
|
||||||
|
|
||||||
const deleteUserMutation = api.user.delete.useMutation({});
|
|
||||||
const saveUserMutation = api.user.save.useMutation({});
|
|
||||||
|
|
||||||
const deleteCurrentUser = async () => {
|
|
||||||
await deleteUserMutation.mutateAsync();
|
|
||||||
(document.querySelector("#delete-user-modal") as HTMLInputElement).checked =
|
|
||||||
false;
|
|
||||||
router.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveUser = async () => {
|
|
||||||
await saveUserMutation.mutateAsync({
|
|
||||||
name: nameText,
|
|
||||||
});
|
|
||||||
router.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setNameText(sessionData?.user.name || "");
|
|
||||||
}, [sessionData]);
|
|
||||||
|
|
||||||
if (sessionData) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="delete-user-modal"
|
|
||||||
className="modal-toggle"
|
|
||||||
/>
|
|
||||||
<div className="modal modal-bottom sm:modal-middle">
|
|
||||||
<div className="modal-box flex-col flex text-center justify-center items-center">
|
|
||||||
<label
|
|
||||||
htmlFor="delete-user-modal"
|
|
||||||
className="btn btn-sm btn-circle absolute right-2 top-2"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<h3 className="font-bold text-lg text-error">
|
|
||||||
This action will delete ALL data associated with your account. The
|
|
||||||
same GitHub Account can be used, but none of your existing data
|
|
||||||
will be available. If you are sure, please confirm below:
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="modal-action">
|
|
||||||
<label
|
|
||||||
htmlFor="delete-user-modal"
|
|
||||||
className="btn btn-error"
|
|
||||||
onClick={() => void deleteCurrentUser()}
|
|
||||||
>
|
|
||||||
I am sure!
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card w-90 bg-neutral shadow-xl">
|
|
||||||
<div className="card-body">
|
|
||||||
<h2 className="card-title">Profile:</h2>
|
|
||||||
{sessionData.user.image && (
|
|
||||||
<Image
|
|
||||||
className="mx-auto"
|
|
||||||
src={sessionData.user.image}
|
|
||||||
alt="Profile picture."
|
|
||||||
height={100}
|
|
||||||
width={100}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-row flex-wrap items-center text-center justify-center gap-4">
|
|
||||||
{sessionData.user.isAdmin && (
|
|
||||||
<div className="tooltip tooltip-primary" data-tip="Admin">
|
|
||||||
<FaShieldAlt className="text-xl text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sessionData.user.isVIP && (
|
|
||||||
<div className="tooltip tooltip-secondary" data-tip="VIP">
|
|
||||||
<GiStarFormation className="inline-block text-xl text-secondary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{providersLoading ? (
|
|
||||||
<div className="mx-auto">
|
|
||||||
<span className="loading loading-dots loading-lg"></span>{" "}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mx-auto">
|
|
||||||
<button
|
|
||||||
className={`btn btn-square btn-outline mx-2`}
|
|
||||||
disabled={providers?.includes("github")}
|
|
||||||
onClick={() => void signIn("github")}
|
|
||||||
>
|
|
||||||
<SiGithub />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={`btn btn-square btn-outline mx-2`}
|
|
||||||
disabled={providers?.includes("google")}
|
|
||||||
onClick={() => void signIn("google")}
|
|
||||||
>
|
|
||||||
<SiGoogle />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sessionData.user.name && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Name"
|
|
||||||
className="input input-bordered"
|
|
||||||
value={nameText}
|
|
||||||
onChange={(event) => setNameText(event.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sessionData.user.email && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Email"
|
|
||||||
className="input input-bordered"
|
|
||||||
value={sessionData.user.email}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => void saveUser()}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
Save Account
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* <button className="btn btn-error">Delete Account</button> */}
|
|
||||||
|
|
||||||
<label htmlFor="delete-user-modal" className="btn btn-error">
|
|
||||||
Delete Account
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <h1>Error getting login session!</h1>;
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { type GetServerSideProps, type NextPage } from "next";
|
import { type NextPage } from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { EventTypes } from "~/utils/types";
|
import { EventTypes } from "~/utils/types";
|
||||||
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {
|
import {
|
||||||
IoCheckmarkCircleOutline,
|
IoCheckmarkCircleOutline,
|
||||||
|
@ -17,10 +16,7 @@ import {
|
||||||
IoSaveOutline,
|
IoSaveOutline,
|
||||||
} from "react-icons/io5";
|
} from "react-icons/io5";
|
||||||
import { GiStarFormation } from "react-icons/gi";
|
import { GiStarFormation } from "react-icons/gi";
|
||||||
import { z } from "zod";
|
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { getServerAuthSession } from "../../server/auth";
|
|
||||||
|
|
||||||
import { configureAbly, useChannel, usePresence } from "@ably-labs/react-hooks";
|
import { configureAbly, useChannel, usePresence } from "@ably-labs/react-hooks";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FaShieldAlt } from "react-icons/fa";
|
import { FaShieldAlt } from "react-icons/fa";
|
||||||
|
@ -28,27 +24,10 @@ import { RiVipCrownFill } from "react-icons/ri";
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env.mjs";
|
||||||
import { downloadCSV } from "~/utils/helpers";
|
import { downloadCSV } from "~/utils/helpers";
|
||||||
import type { PresenceItem } from "~/utils/types";
|
import type { PresenceItem } from "~/utils/types";
|
||||||
|
import { useUser } from "@clerk/nextjs";
|
||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
|
||||||
const session = await getServerAuthSession(ctx);
|
|
||||||
|
|
||||||
// Redirect to login if not signed in
|
|
||||||
if (!session) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: `/api/auth/signin?callbackUrl=${ctx.resolvedUrl}`,
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return session if logged in
|
|
||||||
return {
|
|
||||||
props: { session },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const Room: NextPage = () => {
|
const Room: NextPage = () => {
|
||||||
|
const { isSignedIn } = useUser();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
|
@ -57,7 +36,13 @@ const Room: NextPage = () => {
|
||||||
<meta http-equiv="Cache-control" content="no-cache" />
|
<meta http-equiv="Cache-control" content="no-cache" />
|
||||||
</Head>
|
</Head>
|
||||||
<div className="flex flex-col items-center justify-center text-center gap-2">
|
<div className="flex flex-col items-center justify-center text-center gap-2">
|
||||||
<RoomBody />
|
{!isSignedIn ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span className="loading loading-dots loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<RoomBody />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -66,9 +51,9 @@ const Room: NextPage = () => {
|
||||||
export default Room;
|
export default Room;
|
||||||
|
|
||||||
const RoomBody = ({}) => {
|
const RoomBody = ({}) => {
|
||||||
const { data: sessionData } = useSession();
|
const { isSignedIn, user } = useUser();
|
||||||
const { query } = useRouter();
|
const { query } = useRouter();
|
||||||
const roomId = z.string().parse(query.id);
|
const roomId = query.id as string;
|
||||||
|
|
||||||
const [storyNameText, setStoryNameText] = useState<string>("");
|
const [storyNameText, setStoryNameText] = useState<string>("");
|
||||||
const [roomScale, setRoomScale] = useState<string>("");
|
const [roomScale, setRoomScale] = useState<string>("");
|
||||||
|
@ -85,7 +70,7 @@ const RoomBody = ({}) => {
|
||||||
|
|
||||||
configureAbly({
|
configureAbly({
|
||||||
key: env.NEXT_PUBLIC_ABLY_PUBLIC_KEY,
|
key: env.NEXT_PUBLIC_ABLY_PUBLIC_KEY,
|
||||||
clientId: sessionData?.user.id,
|
clientId: user?.id,
|
||||||
recover: (_, cb) => {
|
recover: (_, cb) => {
|
||||||
cb(true);
|
cb(true);
|
||||||
},
|
},
|
||||||
|
@ -108,11 +93,11 @@ const RoomBody = ({}) => {
|
||||||
const [presenceData] = usePresence<PresenceItem>(
|
const [presenceData] = usePresence<PresenceItem>(
|
||||||
`${env.NEXT_PUBLIC_APP_ENV}-${roomId}`,
|
`${env.NEXT_PUBLIC_APP_ENV}-${roomId}`,
|
||||||
{
|
{
|
||||||
name: sessionData?.user.name || "",
|
name: user?.fullName || "",
|
||||||
image: sessionData?.user.image || "",
|
image: user?.imageUrl || "",
|
||||||
client_id: sessionData?.user.id || "",
|
client_id: user?.id || "",
|
||||||
isAdmin: sessionData?.user.isAdmin || false,
|
isAdmin: (user?.publicMetadata.isAdmin as boolean | undefined) || false,
|
||||||
isVIP: sessionData?.user.isVIP || false,
|
isVIP: (user?.publicMetadata.isVIP as boolean | undefined) || false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -129,19 +114,16 @@ const RoomBody = ({}) => {
|
||||||
|
|
||||||
// Init story name
|
// Init story name
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionData && roomFromDb) {
|
if (isSignedIn && roomFromDb) {
|
||||||
setStoryNameText(roomFromDb.storyName || "");
|
setStoryNameText(roomFromDb.storyName || "");
|
||||||
setRoomScale(roomFromDb.scale || "ERROR");
|
setRoomScale(roomFromDb.scale || "ERROR");
|
||||||
}
|
}
|
||||||
}, [roomFromDb, roomId, sessionData]);
|
}, [roomFromDb, roomId, isSignedIn, user]);
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const getVoteForCurrentUser = () => {
|
const getVoteForCurrentUser = () => {
|
||||||
if (roomFromDb && sessionData) {
|
if (roomFromDb && isSignedIn) {
|
||||||
return (
|
return votesFromDb && votesFromDb.find((vote) => vote.userId === user.id);
|
||||||
votesFromDb &&
|
|
||||||
votesFromDb.find((vote) => vote.userId === sessionData.user.id)
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -183,16 +165,16 @@ const RoomBody = ({}) => {
|
||||||
})
|
})
|
||||||
.concat({
|
.concat({
|
||||||
id: "LATEST",
|
id: "LATEST",
|
||||||
createdAt: new Date(),
|
created_at: new Date(),
|
||||||
userId: roomFromDb.owner.id,
|
userId: roomFromDb.userId,
|
||||||
roomId: roomFromDb.id,
|
roomId: roomFromDb.id,
|
||||||
scale: roomScale,
|
scale: roomScale,
|
||||||
votes: votesFromDb.map((vote) => {
|
votes: votesFromDb.map((vote) => {
|
||||||
return {
|
return {
|
||||||
name: vote.owner.name,
|
|
||||||
value: vote.value,
|
value: vote.value,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
room: roomFromDb,
|
||||||
roomName: roomFromDb.roomName,
|
roomName: roomFromDb.roomName,
|
||||||
storyName: storyNameText,
|
storyName: storyNameText,
|
||||||
});
|
});
|
||||||
|
@ -365,111 +347,108 @@ const RoomBody = ({}) => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sessionData &&
|
{isSignedIn && !!roomFromDb && roomFromDb.userId === user.id && (
|
||||||
!!roomFromDb &&
|
<>
|
||||||
roomFromDb.userId === sessionData.user.id && (
|
<div className="card card-compact bg-neutral shadow-xl mx-auto m-4">
|
||||||
<>
|
<div className="card-body flex flex-col flex-wrap">
|
||||||
<div className="card card-compact bg-neutral shadow-xl mx-auto m-4">
|
<h2 className="card-title mx-auto">Room Settings</h2>
|
||||||
<div className="card-body flex flex-col flex-wrap">
|
|
||||||
<h2 className="card-title mx-auto">Room Settings</h2>
|
|
||||||
|
|
||||||
<label className="label mx-auto">
|
<label className="label mx-auto">
|
||||||
{"Vote Scale (Comma Separated):"}{" "}
|
{"Vote Scale (Comma Separated):"}{" "}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Scale (Comma Separated)"
|
placeholder="Scale (Comma Separated)"
|
||||||
className="input input-bordered m-auto"
|
className="input input-bordered m-auto"
|
||||||
value={roomScale}
|
value={roomScale}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setRoomScale(event.target.value);
|
setRoomScale(event.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label className="label mx-auto">{"Story Name:"} </label>
|
<label className="label mx-auto">{"Story Name:"} </label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Story Name"
|
placeholder="Story Name"
|
||||||
className="input input-bordered m-auto"
|
className="input input-bordered m-auto"
|
||||||
value={storyNameText}
|
value={storyNameText}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setStoryNameText(event.target.value);
|
setStoryNameText(event.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row flex-wrap text-center items-center justify-center gap-2">
|
<div className="flex flex-row flex-wrap text-center items-center justify-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => saveRoom(!roomFromDb.visible, false)}
|
onClick={() => saveRoom(!roomFromDb.visible, false)}
|
||||||
className="btn btn-primary inline-flex"
|
className="btn btn-primary inline-flex"
|
||||||
>
|
>
|
||||||
{roomFromDb.visible ? (
|
{roomFromDb.visible ? (
|
||||||
<>
|
<>
|
||||||
<IoEyeOffOutline className="text-xl mr-1" />
|
<IoEyeOffOutline className="text-xl mr-1" />
|
||||||
Hide
|
Hide
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<IoEyeOutline className="text-xl mr-1" />
|
<IoEyeOutline className="text-xl mr-1" />
|
||||||
Show
|
Show
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
saveRoom(
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
roomFromDb.storyName === storyNameText ||
|
|
||||||
votesFromDb?.length === 0
|
|
||||||
? false
|
|
||||||
: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="btn btn-primary inline-flex"
|
|
||||||
disabled={
|
|
||||||
[...new Set(roomScale.split(","))].filter(
|
|
||||||
(item) => item !== ""
|
|
||||||
).length <= 1
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{roomFromDb.storyName === storyNameText ||
|
|
||||||
votesFromDb?.length === 0 ? (
|
|
||||||
<>
|
|
||||||
<IoReloadOutline className="text-xl mr-1" /> Reset
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<IoSaveOutline className="text-xl mr-1" /> Save
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{votesFromDb &&
|
|
||||||
(roomFromDb.logs.length > 0 ||
|
|
||||||
votesFromDb.length > 0) && (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => downloadLogs()}
|
|
||||||
className="btn btn-primary inline-flex hover:animate-pulse"
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<IoDownloadOutline className="text-xl" />
|
|
||||||
</>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
saveRoom(
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
roomFromDb.storyName === storyNameText ||
|
||||||
|
votesFromDb?.length === 0
|
||||||
|
? false
|
||||||
|
: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="btn btn-primary inline-flex"
|
||||||
|
disabled={
|
||||||
|
[...new Set(roomScale.split(","))].filter(
|
||||||
|
(item) => item !== ""
|
||||||
|
).length <= 1
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{roomFromDb.storyName === storyNameText ||
|
||||||
|
votesFromDb?.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<IoReloadOutline className="text-xl mr-1" /> Reset
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IoSaveOutline className="text-xl mr-1" /> Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{votesFromDb &&
|
||||||
|
(roomFromDb.logs.length > 0 || votesFromDb.length > 0) && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => downloadLogs()}
|
||||||
|
className="btn btn-primary inline-flex hover:animate-pulse"
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<IoDownloadOutline className="text-xl" />
|
||||||
|
</>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
// Room does not exist
|
// Room does not exist
|
||||||
|
|
17
src/pages/sign-in/[[...index]].tsx
Normal file
17
src/pages/sign-in/[[...index]].tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { SignIn } from "@clerk/nextjs";
|
||||||
|
|
||||||
|
const SignInPage = () => (
|
||||||
|
<div style={styles}>
|
||||||
|
<SignIn path="/sign-in" routing="path" signUpUrl="/sign-up" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SignInPage;
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
};
|
17
src/pages/sign-up/[[...index]].tsx
Normal file
17
src/pages/sign-up/[[...index]].tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { SignUp } from "@clerk/nextjs";
|
||||||
|
|
||||||
|
const SignUpPage = () => (
|
||||||
|
<div style={styles}>
|
||||||
|
<SignUp path="/sign-up" routing="path" signInUrl="/sign-in" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SignUpPage;
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
};
|
|
@ -1,7 +1,5 @@
|
||||||
import { roomRouter } from "~/server/api/routers/room";
|
import { roomRouter } from "~/server/api/routers/room";
|
||||||
import { createTRPCRouter } from "~/server/api/trpc";
|
import { createTRPCRouter } from "~/server/api/trpc";
|
||||||
import { sessionRouter } from "./routers/session";
|
|
||||||
import { userRouter } from "./routers/user";
|
|
||||||
import { voteRouter } from "./routers/vote";
|
import { voteRouter } from "./routers/vote";
|
||||||
import { restRouter } from "./routers/rest";
|
import { restRouter } from "./routers/rest";
|
||||||
|
|
||||||
|
@ -13,8 +11,6 @@ import { restRouter } from "./routers/rest";
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
room: roomRouter,
|
room: roomRouter,
|
||||||
vote: voteRouter,
|
vote: voteRouter,
|
||||||
user: userRouter,
|
|
||||||
session: sessionRouter,
|
|
||||||
rest: restRouter,
|
rest: restRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,8 @@ import { z } from "zod";
|
||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { fetchCache, setCache } from "~/server/redis";
|
import { fetchCache, setCache } from "~/server/redis";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { rooms, votes } from "~/server/schema";
|
||||||
|
|
||||||
export const restRouter = createTRPCRouter({
|
export const restRouter = createTRPCRouter({
|
||||||
dbWarmer: publicProcedure
|
dbWarmer: publicProcedure
|
||||||
|
@ -13,7 +15,7 @@ export const restRouter = createTRPCRouter({
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const isValidKey = await validateApiKey(input.key);
|
const isValidKey = await validateApiKey(input.key);
|
||||||
if (isValidKey) {
|
if (isValidKey) {
|
||||||
await ctx.prisma.verificationToken.findMany();
|
await ctx.db.query.votes.findMany();
|
||||||
return "Toasted the DB";
|
return "Toasted the DB";
|
||||||
} else {
|
} else {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
@ -30,7 +32,11 @@ export const restRouter = createTRPCRouter({
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
} else {
|
} else {
|
||||||
const votesCount = await ctx.prisma.vote.count();
|
const votesResult = (
|
||||||
|
await ctx.db.select({ count: sql<number>`count(*)` }).from(votes)
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const votesCount = votesResult ? Number(votesResult.count) : 0;
|
||||||
|
|
||||||
await setCache(`kv_votecount`, votesCount);
|
await setCache(`kv_votecount`, votesCount);
|
||||||
|
|
||||||
|
@ -38,24 +44,6 @@ export const restRouter = createTRPCRouter({
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
userCount: publicProcedure
|
|
||||||
.meta({ openapi: { method: "GET", path: "/rest/users/count" } })
|
|
||||||
.input(z.void())
|
|
||||||
.output(z.number())
|
|
||||||
.query(async ({ ctx }) => {
|
|
||||||
const cachedResult = await fetchCache<number>(`kv_usercount`);
|
|
||||||
|
|
||||||
if (cachedResult) {
|
|
||||||
return cachedResult;
|
|
||||||
} else {
|
|
||||||
const usersCount = await ctx.prisma.user.count();
|
|
||||||
|
|
||||||
await setCache(`kv_usercount`, usersCount);
|
|
||||||
|
|
||||||
return usersCount;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
roomCount: publicProcedure
|
roomCount: publicProcedure
|
||||||
.meta({ openapi: { method: "GET", path: "/rest/rooms/count" } })
|
.meta({ openapi: { method: "GET", path: "/rest/rooms/count" } })
|
||||||
.input(z.void())
|
.input(z.void())
|
||||||
|
@ -66,7 +54,11 @@ export const restRouter = createTRPCRouter({
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
} else {
|
} else {
|
||||||
const roomsCount = await ctx.prisma.room.count();
|
const roomsResult = (
|
||||||
|
await ctx.db.select({ count: sql<number>`count(*)` }).from(rooms)
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const roomsCount = roomsResult ? Number(roomsResult.count) : 0;
|
||||||
|
|
||||||
await setCache(`kv_roomcount`, roomsCount);
|
await setCache(`kv_roomcount`, roomsCount);
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,10 @@ import { publishToChannel } from "~/server/ably";
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
|
|
||||||
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
||||||
|
import { logs, rooms, votes } from "~/server/schema";
|
||||||
import { EventTypes } from "~/utils/types";
|
import { EventTypes } from "~/utils/types";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const roomRouter = createTRPCRouter({
|
export const roomRouter = createTRPCRouter({
|
||||||
// Create
|
// Create
|
||||||
|
@ -14,57 +17,52 @@ export const roomRouter = createTRPCRouter({
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (ctx.session) {
|
const room = await ctx.db.insert(rooms).values({
|
||||||
const room = await ctx.prisma.room.create({
|
id: createId(),
|
||||||
data: {
|
userId: ctx.auth.userId,
|
||||||
userId: ctx.session.user.id,
|
roomName: input.name,
|
||||||
roomName: input.name,
|
storyName: "First Story!",
|
||||||
storyName: "First Story!",
|
scale: "0.5,1,2,3,5,8",
|
||||||
scale: "0.5,1,2,3,5,8",
|
visible: false,
|
||||||
visible: false,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
if (room) {
|
|
||||||
await invalidateCache(`kv_roomcount`);
|
|
||||||
await invalidateCache(`kv_roomlist_${ctx.session.user.id}`);
|
|
||||||
|
|
||||||
await publishToChannel(
|
const success = room.rowsAffected > 0;
|
||||||
`${ctx.session.user.id}`,
|
if (room) {
|
||||||
EventTypes.ROOM_LIST_UPDATE,
|
await invalidateCache(`kv_roomcount`);
|
||||||
JSON.stringify(room)
|
await invalidateCache(`kv_roomlist_${ctx.auth.userId}`);
|
||||||
);
|
|
||||||
|
|
||||||
await publishToChannel(
|
await publishToChannel(
|
||||||
`stats`,
|
`${ctx.auth.userId}`,
|
||||||
EventTypes.STATS_UPDATE,
|
EventTypes.ROOM_LIST_UPDATE,
|
||||||
JSON.stringify(room)
|
JSON.stringify(room)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
// happy path
|
await publishToChannel(
|
||||||
return !!room;
|
`stats`,
|
||||||
|
EventTypes.STATS_UPDATE,
|
||||||
|
JSON.stringify(room)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return success;
|
||||||
// clinically depressed path
|
|
||||||
return false;
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Get One
|
// Get One
|
||||||
get: protectedProcedure
|
get: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(({ ctx, input }) => {
|
.query(({ ctx, input }) => {
|
||||||
return ctx.prisma.room.findUnique({
|
return ctx.db.query.rooms.findFirst({
|
||||||
where: {
|
where: eq(rooms.id, input.id),
|
||||||
id: input.id,
|
with: {
|
||||||
},
|
logs: {
|
||||||
select: {
|
with: {
|
||||||
id: true,
|
room: true,
|
||||||
userId: true,
|
},
|
||||||
logs: true,
|
},
|
||||||
roomName: true,
|
votes: {
|
||||||
storyName: true,
|
with: {
|
||||||
visible: true,
|
room: true,
|
||||||
scale: true,
|
},
|
||||||
owner: true,
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
@ -77,23 +75,16 @@ export const roomRouter = createTRPCRouter({
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
}[]
|
}[]
|
||||||
>(`kv_roomlist_${ctx.session.user.id}`);
|
>(`kv_roomlist_${ctx.auth.userId}`);
|
||||||
|
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
} else {
|
} else {
|
||||||
const roomList = await ctx.prisma.room.findMany({
|
const roomList = await ctx.db.query.rooms.findMany({
|
||||||
where: {
|
where: eq(rooms.userId, ctx.auth.userId),
|
||||||
userId: ctx.session.user.id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
createdAt: true,
|
|
||||||
roomName: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await setCache(`kv_roomlist_${ctx.session.user.id}`, roomList);
|
await setCache(`kv_roomlist_${ctx.auth.userId}`, roomList);
|
||||||
|
|
||||||
return roomList;
|
return roomList;
|
||||||
}
|
}
|
||||||
|
@ -114,119 +105,88 @@ export const roomRouter = createTRPCRouter({
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (input.reset) {
|
if (input.reset) {
|
||||||
if (input.log) {
|
if (input.log) {
|
||||||
const oldRoom = await ctx.prisma.room.findUnique({
|
const oldRoom = await ctx.db.query.rooms.findFirst({
|
||||||
where: {
|
where: eq(rooms.id, input.roomId),
|
||||||
id: input.roomId,
|
with: {
|
||||||
},
|
votes: true,
|
||||||
select: {
|
logs: true,
|
||||||
roomName: true,
|
|
||||||
storyName: true,
|
|
||||||
scale: true,
|
|
||||||
votes: {
|
|
||||||
select: {
|
|
||||||
owner: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
oldRoom &&
|
oldRoom &&
|
||||||
(await ctx.prisma.log.create({
|
(await ctx.db.insert(logs).values({
|
||||||
data: {
|
id: createId(),
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.auth.userId,
|
||||||
roomId: input.roomId,
|
roomId: input.roomId,
|
||||||
scale: oldRoom.scale,
|
scale: oldRoom.scale,
|
||||||
votes: oldRoom.votes.map((vote) => {
|
votes: oldRoom.votes.map((vote) => {
|
||||||
return {
|
return {
|
||||||
name: vote.owner.name,
|
name: vote.userId,
|
||||||
value: vote.value,
|
value: vote.value,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
roomName: oldRoom.roomName,
|
roomName: oldRoom.roomName,
|
||||||
storyName: oldRoom.storyName,
|
storyName: oldRoom.storyName,
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.prisma.vote.deleteMany({
|
await ctx.db.delete(votes).where(eq(votes.roomId, input.roomId));
|
||||||
where: {
|
|
||||||
roomId: input.roomId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await invalidateCache(`kv_votes_${input.roomId}`);
|
await invalidateCache(`kv_votes_${input.roomId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRoom = await ctx.prisma.room.update({
|
const newRoom = await ctx.db
|
||||||
where: {
|
.update(rooms)
|
||||||
id: input.roomId,
|
.set({
|
||||||
},
|
|
||||||
data: {
|
|
||||||
storyName: input.name,
|
storyName: input.name,
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.auth.userId,
|
||||||
visible: input.visible,
|
visible: input.visible,
|
||||||
scale: [...new Set(input.scale.split(","))]
|
scale: [...new Set(input.scale.split(","))]
|
||||||
.filter((item) => item !== "")
|
.filter((item) => item !== "")
|
||||||
.toString(),
|
.toString(),
|
||||||
},
|
})
|
||||||
select: {
|
.where(eq(rooms.id, input.roomId));
|
||||||
id: true,
|
|
||||||
roomName: true,
|
|
||||||
storyName: true,
|
|
||||||
visible: true,
|
|
||||||
scale: true,
|
|
||||||
votes: {
|
|
||||||
select: {
|
|
||||||
owner: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newRoom) {
|
const success = newRoom.rowsAffected > 0;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
await publishToChannel(
|
await publishToChannel(
|
||||||
`${newRoom.id}`,
|
`${input.roomId}`,
|
||||||
EventTypes.ROOM_UPDATE,
|
EventTypes.ROOM_UPDATE,
|
||||||
JSON.stringify(newRoom)
|
JSON.stringify(newRoom)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return !!newRoom;
|
return success;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Delete One
|
// Delete One
|
||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const deletedRoom = await ctx.prisma.room.delete({
|
const deletedRoom = await ctx.db
|
||||||
where: {
|
.delete(rooms)
|
||||||
id: input.id,
|
.where(eq(rooms.id, input.id));
|
||||||
},
|
|
||||||
});
|
const success = deletedRoom.rowsAffected > 0;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await ctx.db.delete(votes).where(eq(votes.roomId, input.id));
|
||||||
|
|
||||||
|
await ctx.db.delete(logs).where(eq(logs.roomId, input.id));
|
||||||
|
|
||||||
if (deletedRoom) {
|
|
||||||
await invalidateCache(`kv_roomcount`);
|
await invalidateCache(`kv_roomcount`);
|
||||||
await invalidateCache(`kv_votecount`);
|
await invalidateCache(`kv_votecount`);
|
||||||
await invalidateCache(`kv_roomlist_${ctx.session.user.id}`);
|
await invalidateCache(`kv_roomlist_${ctx.auth.userId}`);
|
||||||
|
|
||||||
await publishToChannel(
|
await publishToChannel(
|
||||||
`${ctx.session.user.id}`,
|
`${ctx.auth.userId}`,
|
||||||
EventTypes.ROOM_LIST_UPDATE,
|
EventTypes.ROOM_LIST_UPDATE,
|
||||||
JSON.stringify(deletedRoom)
|
JSON.stringify(deletedRoom)
|
||||||
);
|
);
|
||||||
|
|
||||||
await publishToChannel(
|
await publishToChannel(
|
||||||
`${deletedRoom.id}`,
|
`${input.id}`,
|
||||||
EventTypes.ROOM_UPDATE,
|
EventTypes.ROOM_UPDATE,
|
||||||
JSON.stringify(deletedRoom)
|
JSON.stringify(deletedRoom)
|
||||||
);
|
);
|
||||||
|
@ -238,6 +198,6 @@ export const roomRouter = createTRPCRouter({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return !!deletedRoom;
|
return success;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
import { adminProcedure, createTRPCRouter } from "~/server/api/trpc";
|
|
||||||
import { invalidateCache } from "~/server/redis";
|
|
||||||
|
|
||||||
export const sessionRouter = createTRPCRouter({
|
|
||||||
deleteAllByUserId: adminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
userId: z.string(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const sessions = await ctx.prisma.session.deleteMany({
|
|
||||||
where: {
|
|
||||||
userId: input.userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!!sessions) {
|
|
||||||
await invalidateCache(`kv_userlist_admin`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return !!sessions;
|
|
||||||
}),
|
|
||||||
deleteAll: adminProcedure.mutation(async ({ ctx }) => {
|
|
||||||
const sessions = await ctx.prisma.session.deleteMany();
|
|
||||||
|
|
||||||
if (!!sessions) {
|
|
||||||
await invalidateCache(`kv_userlist_admin`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return !!sessions;
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -1,194 +0,0 @@
|
||||||
import type { User } from "@prisma/client";
|
|
||||||
import { Resend } from "resend";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { Goodbye } from "~/components/templates/Goodbye";
|
|
||||||
import { env } from "~/env.mjs";
|
|
||||||
import { publishToChannel } from "~/server/ably";
|
|
||||||
import {
|
|
||||||
adminProcedure,
|
|
||||||
createTRPCRouter,
|
|
||||||
protectedProcedure,
|
|
||||||
} from "~/server/api/trpc";
|
|
||||||
|
|
||||||
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
|
||||||
import { EventTypes } from "~/utils/types";
|
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
|
||||||
getProviders: protectedProcedure.query(async ({ ctx }) => {
|
|
||||||
const providers = await ctx.prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
id: ctx.session.user.id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
accounts: {
|
|
||||||
select: {
|
|
||||||
provider: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return providers?.accounts.map((account) => {
|
|
||||||
return account.provider;
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
|
||||||
const cachedResult = await fetchCache<
|
|
||||||
{
|
|
||||||
accounts: {
|
|
||||||
provider: string;
|
|
||||||
}[];
|
|
||||||
sessions: {
|
|
||||||
id: string;
|
|
||||||
}[];
|
|
||||||
id: string;
|
|
||||||
createdAt: Date;
|
|
||||||
isAdmin: boolean;
|
|
||||||
isVIP: boolean;
|
|
||||||
name: string | null;
|
|
||||||
email: string | null;
|
|
||||||
}[]
|
|
||||||
>(`kv_userlist_admin`);
|
|
||||||
|
|
||||||
if (cachedResult) {
|
|
||||||
return cachedResult.map((user) => {
|
|
||||||
return {
|
|
||||||
...user,
|
|
||||||
createdAt: new Date(user.createdAt),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const users = await ctx.prisma.user.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
isAdmin: true,
|
|
||||||
isVIP: true,
|
|
||||||
createdAt: true,
|
|
||||||
email: true,
|
|
||||||
sessions: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
accounts: {
|
|
||||||
select: {
|
|
||||||
provider: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await setCache(`${env.APP_ENV}_kv_userlist_admin`, users);
|
|
||||||
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
delete: protectedProcedure
|
|
||||||
.input(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
userId: z.string(),
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
let user: User;
|
|
||||||
if (input?.userId && ctx.session.user.isAdmin) {
|
|
||||||
user = await ctx.prisma.user.delete({
|
|
||||||
where: {
|
|
||||||
id: input.userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
user = await ctx.prisma.user.delete({
|
|
||||||
where: {
|
|
||||||
id: ctx.session.user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!!user && user.name && user.email) {
|
|
||||||
await resend.emails.send({
|
|
||||||
from: "Sprint Padawan <no-reply@sprintpadawan.dev>",
|
|
||||||
to: user.email,
|
|
||||||
subject: "Sorry to see you go... 😭",
|
|
||||||
react: Goodbye({ name: user.name }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await invalidateCache(`kv_usercount`);
|
|
||||||
await invalidateCache(`kv_userlist_admin`);
|
|
||||||
|
|
||||||
await publishToChannel(
|
|
||||||
`stats`,
|
|
||||||
EventTypes.STATS_UPDATE,
|
|
||||||
JSON.stringify(user)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return !!user;
|
|
||||||
}),
|
|
||||||
save: protectedProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
name: z.string(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const user = await ctx.prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: ctx.session.user.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
name: input.name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return !!user;
|
|
||||||
}),
|
|
||||||
setAdmin: adminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
userId: z.string(),
|
|
||||||
value: z.boolean(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const user = await ctx.prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: input.userId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
isAdmin: input.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await invalidateCache(`kv_userlist_admin`);
|
|
||||||
|
|
||||||
return !!user;
|
|
||||||
}),
|
|
||||||
|
|
||||||
setVIP: adminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
userId: z.string(),
|
|
||||||
value: z.boolean(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const user = await ctx.prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: input.userId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
isVIP: input.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await invalidateCache(`kv_userlist_admin`);
|
|
||||||
|
|
||||||
return !!user;
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { publishToChannel } from "~/server/ably";
|
import { publishToChannel } from "~/server/ably";
|
||||||
|
|
||||||
import type { Room } from "@prisma/client";
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
||||||
import { EventTypes } from "~/utils/types";
|
import { EventTypes } from "~/utils/types";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { votes } from "~/server/schema";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
|
||||||
export const voteRouter = createTRPCRouter({
|
export const voteRouter = createTRPCRouter({
|
||||||
getAllByRoomId: protectedProcedure
|
getAllByRoomId: protectedProcedure
|
||||||
|
@ -12,14 +14,10 @@ export const voteRouter = createTRPCRouter({
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const cachedResult = await fetchCache<
|
const cachedResult = await fetchCache<
|
||||||
{
|
{
|
||||||
value: string;
|
|
||||||
room: Room;
|
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
value: string;
|
||||||
|
created_at: Date;
|
||||||
userId: string;
|
userId: string;
|
||||||
owner: {
|
|
||||||
name: string | null;
|
|
||||||
};
|
|
||||||
roomId: string;
|
roomId: string;
|
||||||
}[]
|
}[]
|
||||||
>(`kv_votes_${input.roomId}`);
|
>(`kv_votes_${input.roomId}`);
|
||||||
|
@ -27,23 +25,8 @@ export const voteRouter = createTRPCRouter({
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
} else {
|
} else {
|
||||||
const votesByRoomId = await ctx.prisma.vote.findMany({
|
const votesByRoomId = await ctx.db.query.votes.findMany({
|
||||||
where: {
|
where: eq(votes.roomId, input.roomId),
|
||||||
roomId: input.roomId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
createdAt: true,
|
|
||||||
owner: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
room: true,
|
|
||||||
roomId: true,
|
|
||||||
userId: true,
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await setCache(`kv_votes_${input.roomId}`, votesByRoomId);
|
await setCache(`kv_votes_${input.roomId}`, votesByRoomId);
|
||||||
|
@ -54,42 +37,34 @@ export const voteRouter = createTRPCRouter({
|
||||||
set: protectedProcedure
|
set: protectedProcedure
|
||||||
.input(z.object({ value: z.string(), roomId: z.string() }))
|
.input(z.object({ value: z.string(), roomId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const vote = await ctx.prisma.vote.upsert({
|
const updateResult = await ctx.db
|
||||||
where: {
|
.update(votes)
|
||||||
userId_roomId: {
|
.set({
|
||||||
roomId: input.roomId,
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
value: input.value,
|
value: input.value,
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.auth.userId,
|
||||||
roomId: input.roomId,
|
roomId: input.roomId,
|
||||||
},
|
})
|
||||||
update: {
|
.where(eq(votes.userId, ctx.auth.userId));
|
||||||
value: input.value,
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
roomId: input.roomId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
value: true,
|
|
||||||
userId: true,
|
|
||||||
roomId: true,
|
|
||||||
id: true,
|
|
||||||
owner: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (vote) {
|
let success = updateResult.rowsAffected > 0;
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
const vote = await ctx.db.insert(votes).ignore().values({
|
||||||
|
id: createId(),
|
||||||
|
value: input.value,
|
||||||
|
userId: ctx.auth.userId,
|
||||||
|
roomId: input.roomId,
|
||||||
|
});
|
||||||
|
|
||||||
|
success = vote.rowsAffected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
await invalidateCache(`kv_votecount`);
|
await invalidateCache(`kv_votecount`);
|
||||||
await invalidateCache(`kv_votes_${input.roomId}`);
|
await invalidateCache(`kv_votes_${input.roomId}`);
|
||||||
|
|
||||||
await publishToChannel(
|
await publishToChannel(
|
||||||
`${vote.roomId}`,
|
`${input.roomId}`,
|
||||||
EventTypes.VOTE_UPDATE,
|
EventTypes.VOTE_UPDATE,
|
||||||
input.value
|
input.value
|
||||||
);
|
);
|
||||||
|
@ -97,10 +72,10 @@ export const voteRouter = createTRPCRouter({
|
||||||
await publishToChannel(
|
await publishToChannel(
|
||||||
`stats`,
|
`stats`,
|
||||||
EventTypes.STATS_UPDATE,
|
EventTypes.STATS_UPDATE,
|
||||||
JSON.stringify(vote)
|
JSON.stringify(success)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return !!vote;
|
return success;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,81 +1,71 @@
|
||||||
/**
|
/**
|
||||||
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
|
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
|
||||||
* 1. You want to modify request context (see Part 1).
|
* 1. You want to modify request context (see Part 1)
|
||||||
* 2. You want to create a new middleware or type of procedure (see Part 3).
|
* 2. You want to create a new middleware or type of procedure (see Part 3)
|
||||||
*
|
*
|
||||||
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
|
* tl;dr - this is where all the tRPC server stuff is created and plugged in.
|
||||||
* need to use are documented accordingly near the end.
|
* The pieces you will need to use are documented accordingly near the end
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. CONTEXT
|
* 1. CONTEXT
|
||||||
*
|
*
|
||||||
* This section defines the "contexts" that are available in the backend API.
|
* This section defines the "contexts" that are available in the backend API
|
||||||
|
*
|
||||||
|
* These allow you to access things like the database, the session, etc, when
|
||||||
|
* processing a request
|
||||||
*
|
*
|
||||||
* These allow you to access things when processing a request, like the database, the session, etc.
|
|
||||||
*/
|
*/
|
||||||
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
|
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||||
import { type Session } from "next-auth";
|
import { getAuth } from "@clerk/nextjs/server";
|
||||||
|
import type {
|
||||||
|
SignedInAuthObject,
|
||||||
|
SignedOutAuthObject,
|
||||||
|
} from "@clerk/nextjs/api";
|
||||||
|
|
||||||
import { getServerAuthSession } from "~/server/auth";
|
import { db } from "../db";
|
||||||
import { prisma } from "~/server/db";
|
|
||||||
|
|
||||||
type CreateContextOptions = {
|
|
||||||
session: Session | null;
|
|
||||||
ip: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
interface AuthContext {
|
||||||
|
auth: SignedInAuthObject | SignedOutAuthObject;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This helper generates the "internals" for a tRPC context. If you need to use it, you can export
|
* This helper generates the "internals" for a tRPC context. If you need to use
|
||||||
* it from here.
|
* it, you can export it from here
|
||||||
*
|
*
|
||||||
* Examples of things you may need it for:
|
* Examples of things you may need it for:
|
||||||
* - testing, so we don't have to mock Next.js' req/res
|
* - testing, so we dont have to mock Next.js' req/res
|
||||||
* - tRPC's `createSSGHelpers`, where we don't have req/res
|
* - trpc's `createSSGHelpers` where we don't have req/res
|
||||||
*
|
|
||||||
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
|
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
|
||||||
*/
|
*/
|
||||||
const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
const createInnerTRPCContext = ({ auth }: AuthContext) => {
|
||||||
return {
|
return {
|
||||||
session: opts.session,
|
auth,
|
||||||
ip: opts.ip,
|
db,
|
||||||
prisma,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the actual context you will use in your router. It will be used to process every request
|
* This is the actual context you'll use in your router. It will be used to
|
||||||
* that goes through your tRPC endpoint.
|
* process every request that goes through your tRPC endpoint
|
||||||
*
|
* @link https://trpc.io/docs/context
|
||||||
* @see https://trpc.io/docs/context
|
|
||||||
*/
|
*/
|
||||||
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
export const createTRPCContext = (opts: CreateNextContextOptions) => {
|
||||||
const { req, res } = opts;
|
return createInnerTRPCContext({ auth: getAuth(opts.req) });
|
||||||
|
|
||||||
// Get the session from the server using the getServerSession wrapper function
|
|
||||||
const session = await getServerAuthSession({ req, res });
|
|
||||||
|
|
||||||
return createInnerTRPCContext({
|
|
||||||
ip: req.socket.remoteAddress,
|
|
||||||
session,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2. INITIALIZATION
|
* 2. INITIALIZATION
|
||||||
*
|
*
|
||||||
* This is where the tRPC API is initialized, connecting the context and transformer.
|
* This is where the trpc api is initialized, connecting the context and
|
||||||
|
* transformer
|
||||||
*/
|
*/
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import type { OpenApiMeta } from "trpc-openapi";
|
|
||||||
import { Ratelimit } from "@upstash/ratelimit";
|
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { env } from "~/env.mjs";
|
import type { OpenApiMeta } from "trpc-openapi";
|
||||||
import { Redis } from "@upstash/redis";
|
|
||||||
|
|
||||||
const t = initTRPC
|
const t = initTRPC
|
||||||
.meta<OpenApiMeta>()
|
|
||||||
.context<typeof createTRPCContext>()
|
.context<typeof createTRPCContext>()
|
||||||
|
.meta<OpenApiMeta>()
|
||||||
.create({
|
.create({
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
errorFormatter({ shape }) {
|
errorFormatter({ shape }) {
|
||||||
|
@ -83,91 +73,37 @@ const t = initTRPC
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// check if the user is signed in, otherwise through a UNAUTHORIZED CODE
|
||||||
|
const isAuthed = t.middleware(({ next, ctx }) => {
|
||||||
|
if (!ctx.auth.userId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
auth: ctx.auth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
/**
|
/**
|
||||||
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
|
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
|
||||||
*
|
*
|
||||||
* These are the pieces you use to build your tRPC API. You should import these a lot in the
|
* These are the pieces you use to build your tRPC API. You should import these
|
||||||
* "/src/server/api/routers" directory.
|
* a lot in the /src/server/api/routers folder
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is how you create new routers and sub-routers in your tRPC API.
|
* This is how you create new routers and subrouters in your tRPC API
|
||||||
*
|
|
||||||
* @see https://trpc.io/docs/router
|
* @see https://trpc.io/docs/router
|
||||||
*/
|
*/
|
||||||
export const createTRPCRouter = t.router;
|
export const createTRPCRouter = t.router;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public (unauthenticated) procedure
|
* Public (unauthed) procedure
|
||||||
*
|
*
|
||||||
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
|
* This is the base piece you use to build new queries and mutations on your
|
||||||
* guarantee that a user querying is authorized, but you can still access user session data if they
|
* tRPC API. It does not guarantee that a user querying is authorized, but you
|
||||||
* are logged in.
|
* can still access user session data if they are logged in
|
||||||
*/
|
*/
|
||||||
export const publicProcedure = t.procedure;
|
export const publicProcedure = t.procedure;
|
||||||
|
export const protectedProcedure = t.procedure.use(isAuthed);
|
||||||
/** Reusable middleware that enforces users are logged in before running the procedure. */
|
|
||||||
const enforceAuthSession = t.middleware(async ({ ctx, next }) => {
|
|
||||||
// Auth
|
|
||||||
if (!ctx.session || !ctx.session.user) {
|
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const rateLimit = new Ratelimit({
|
|
||||||
redis: Redis.fromEnv(),
|
|
||||||
limiter: Ratelimit.slidingWindow(
|
|
||||||
Number(env.UPSTASH_RATELIMIT_REQUESTS),
|
|
||||||
`${Number(env.UPSTASH_RATELIMIT_SECONDS)}s`
|
|
||||||
),
|
|
||||||
analytics: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { success } = await rateLimit.limit(
|
|
||||||
`${env.APP_ENV}_${ctx.session?.user.id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!success) throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
|
|
||||||
|
|
||||||
return next({
|
|
||||||
ctx: {
|
|
||||||
session: { ...ctx.session, user: ctx.session.user },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const enforceAdminRole = t.middleware(async ({ ctx, next }) => {
|
|
||||||
if (!ctx.session || !ctx.session.user || !ctx.session?.user.isAdmin)
|
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
||||||
|
|
||||||
return next({
|
|
||||||
ctx: {
|
|
||||||
session: { ...ctx.session, user: ctx.session.user },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// const enforceApiToken = t.middleware(async ({ ctx, next, path }) => {
|
|
||||||
// const res = await unkey.keys.verify({
|
|
||||||
// key: ""
|
|
||||||
// })
|
|
||||||
// if (!ctx.session || !ctx.session.user || !ctx.session?.user.isAdmin)
|
|
||||||
// throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
||||||
|
|
||||||
// return next({
|
|
||||||
// ctx: {
|
|
||||||
// session: { ...ctx.session, user: ctx.session.user },
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Protected (authenticated) procedure
|
|
||||||
*
|
|
||||||
* If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
|
|
||||||
* the session is valid and guarantees `ctx.session.user` is not null.
|
|
||||||
*
|
|
||||||
* @see https://trpc.io/docs/procedures
|
|
||||||
*/
|
|
||||||
export const protectedProcedure = t.procedure.use(enforceAuthSession);
|
|
||||||
|
|
||||||
export const adminProcedure = t.procedure.use(enforceAdminRole);
|
|
||||||
|
|
|
@ -1,118 +0,0 @@
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
|
||||||
import { type GetServerSidePropsContext } from "next";
|
|
||||||
import {
|
|
||||||
getServerSession,
|
|
||||||
type DefaultSession,
|
|
||||||
type NextAuthOptions,
|
|
||||||
} from "next-auth";
|
|
||||||
import GithubProvider from "next-auth/providers/github";
|
|
||||||
import GoogleProvider from "next-auth/providers/google";
|
|
||||||
import { Resend } from "resend";
|
|
||||||
import { env } from "~/env.mjs";
|
|
||||||
import { prisma } from "~/server/db";
|
|
||||||
import { Welcome } from "../components/templates/Welcome";
|
|
||||||
import { invalidateCache } from "./redis";
|
|
||||||
import { publishToChannel } from "./ably";
|
|
||||||
import { EventTypes } from "~/utils/types";
|
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
isAdmin: boolean;
|
|
||||||
isVIP: boolean;
|
|
||||||
} & DefaultSession["user"];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
isAdmin: boolean;
|
|
||||||
isVIP: boolean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
|
|
||||||
*
|
|
||||||
* @see https://next-auth.js.org/configuration/options
|
|
||||||
*/
|
|
||||||
export const authOptions: NextAuthOptions = {
|
|
||||||
callbacks: {
|
|
||||||
session({ session, user }) {
|
|
||||||
if (session.user) {
|
|
||||||
session.user.id = user.id;
|
|
||||||
session.user.isAdmin = user.isAdmin;
|
|
||||||
session.user.isVIP = user.isVIP;
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
async createUser({ user }) {
|
|
||||||
if (user && user.name && user.email) {
|
|
||||||
await resend.sendEmail({
|
|
||||||
from: "no-reply@sprintpadawan.dev",
|
|
||||||
to: user.email,
|
|
||||||
subject: "🎉 Welcome to Sprint Padawan! 🎉",
|
|
||||||
//@ts-ignore: IDK why this doesn't work...
|
|
||||||
|
|
||||||
react: Welcome({ name: user.name }),
|
|
||||||
});
|
|
||||||
await invalidateCache(`kv_userlist_admin`);
|
|
||||||
await invalidateCache(`kv_usercount`);
|
|
||||||
|
|
||||||
await publishToChannel(
|
|
||||||
`stats`,
|
|
||||||
EventTypes.STATS_UPDATE,
|
|
||||||
JSON.stringify(user)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async signIn({}) {
|
|
||||||
await invalidateCache(`kv_userlist_admin`);
|
|
||||||
},
|
|
||||||
async signOut() {
|
|
||||||
await invalidateCache(`kv_userlist_admin`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// @ts-ignore This adapter should work...
|
|
||||||
adapter: PrismaAdapter(prisma),
|
|
||||||
providers: [
|
|
||||||
GithubProvider({
|
|
||||||
clientId: env.GITHUB_CLIENT_ID,
|
|
||||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
|
||||||
}),
|
|
||||||
GoogleProvider({
|
|
||||||
clientId: env.GOOGLE_CLIENT_ID,
|
|
||||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
||||||
}),
|
|
||||||
/**
|
|
||||||
* ...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
|
|
||||||
*/
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file.
|
|
||||||
*
|
|
||||||
* @see https://next-auth.js.org/configuration/nextjs
|
|
||||||
*/
|
|
||||||
export const getServerAuthSession = (ctx: {
|
|
||||||
req: GetServerSidePropsContext["req"];
|
|
||||||
res: GetServerSidePropsContext["res"];
|
|
||||||
}) => {
|
|
||||||
return getServerSession(ctx.req, ctx.res, authOptions);
|
|
||||||
};
|
|
|
@ -1,14 +1,11 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { drizzle } from "drizzle-orm/planetscale-serverless";
|
||||||
|
import { connect } from "@planetscale/database";
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env.mjs";
|
||||||
|
import * as schema from "~/server/schema";
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
// create the connection
|
||||||
|
const connection = connect({
|
||||||
|
url: env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
export const prisma =
|
export const db = drizzle(connection, { schema });
|
||||||
globalForPrisma.prisma ||
|
|
||||||
new PrismaClient({
|
|
||||||
log:
|
|
||||||
env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import { Redis } from "@upstash/redis";
|
import { Redis } from "@upstash/redis";
|
||||||
import https from "https";
|
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env.mjs";
|
||||||
|
|
||||||
export const redis = Redis.fromEnv({
|
export const redis = Redis.fromEnv();
|
||||||
agent: new https.Agent({ keepAlive: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setCache = async <T>(key: string, value: T) => {
|
export const setCache = async <T>(key: string, value: T) => {
|
||||||
try {
|
try {
|
||||||
|
@ -20,14 +17,8 @@ export const setCache = async <T>(key: string, value: T) => {
|
||||||
export const fetchCache = async <T>(key: string) => {
|
export const fetchCache = async <T>(key: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await redis.get(`${env.APP_ENV}_${key}`);
|
const result = await redis.get(`${env.APP_ENV}_${key}`);
|
||||||
if (result) {
|
|
||||||
console.log("CACHE HIT");
|
|
||||||
} else {
|
|
||||||
console.log("CACHE MISS");
|
|
||||||
}
|
|
||||||
return result as T;
|
return result as T;
|
||||||
} catch {
|
} catch {
|
||||||
console.log("CACHE ERROR");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
65
src/server/schema.ts
Normal file
65
src/server/schema.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import {
|
||||||
|
timestamp,
|
||||||
|
mysqlTable,
|
||||||
|
varchar,
|
||||||
|
boolean,
|
||||||
|
json,
|
||||||
|
} from "drizzle-orm/mysql-core";
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const rooms = mysqlTable("Room", {
|
||||||
|
id: varchar("id", { length: 255 }).notNull().primaryKey(),
|
||||||
|
created_at: timestamp("created_at", {
|
||||||
|
mode: "date",
|
||||||
|
fsp: 3,
|
||||||
|
}).defaultNow(),
|
||||||
|
userId: varchar("userId", { length: 255 }).notNull(),
|
||||||
|
roomName: varchar("roomName", { length: 255 }),
|
||||||
|
storyName: varchar("storyName", { length: 255 }),
|
||||||
|
visible: boolean("visible").default(false).notNull(),
|
||||||
|
scale: varchar("scale", { length: 255 }).default("0.5,1,2,3,5").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const roomsRelations = relations(rooms, ({ many }) => ({
|
||||||
|
votes: many(votes),
|
||||||
|
logs: many(logs),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const votes = mysqlTable("Vote", {
|
||||||
|
id: varchar("id", { length: 255 }).notNull().primaryKey(),
|
||||||
|
created_at: timestamp("created_at", {
|
||||||
|
mode: "date",
|
||||||
|
fsp: 3,
|
||||||
|
}).defaultNow(),
|
||||||
|
userId: varchar("userId", { length: 255 }).notNull(),
|
||||||
|
roomId: varchar("roomId", { length: 255 }).notNull(),
|
||||||
|
value: varchar("value", { length: 255 }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const votesRelations = relations(votes, ({ one }) => ({
|
||||||
|
room: one(rooms, {
|
||||||
|
fields: [votes.roomId],
|
||||||
|
references: [rooms.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const logs = mysqlTable("Log", {
|
||||||
|
id: varchar("id", { length: 255 }).notNull().primaryKey(),
|
||||||
|
created_at: timestamp("created_at", {
|
||||||
|
mode: "date",
|
||||||
|
fsp: 3,
|
||||||
|
}).defaultNow(),
|
||||||
|
userId: varchar("userId", { length: 255 }).notNull(),
|
||||||
|
roomId: varchar("roomId", { length: 255 }).notNull(),
|
||||||
|
scale: varchar("scale", { length: 255 }),
|
||||||
|
votes: json("votes"),
|
||||||
|
roomName: varchar("roomName", { length: 255 }),
|
||||||
|
storyName: varchar("storyName", { length: 255 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logsRelations = relations(logs, ({ one }) => ({
|
||||||
|
room: one(rooms, {
|
||||||
|
fields: [logs.roomId],
|
||||||
|
references: [rooms.id],
|
||||||
|
}),
|
||||||
|
}));
|
Loading…
Add table
Reference in a new issue