3.0.0
This commit is contained in:
parent
8a0a02528e
commit
e9b652f89b
50 changed files with 984 additions and 861 deletions
|
@ -16,11 +16,7 @@ CLERK_SECRET_KEY=""
|
||||||
CLERK_WEBHOOK_SIGNING_SECRET=""
|
CLERK_WEBHOOK_SIGNING_SECRET=""
|
||||||
|
|
||||||
# Ably
|
# Ably
|
||||||
ABLY_PRIVATE_KEY=""
|
ABLY_API_KEY=""
|
||||||
NEXT_PUBLIC_ABLY_PUBLIC_KEY=""
|
|
||||||
|
|
||||||
# Email
|
|
||||||
RESEND_API_KEY=""
|
|
||||||
|
|
||||||
# Unkey
|
# Unkey
|
||||||
UNKEY_ROOT_KEY=""
|
UNKEY_ROOT_KEY=""
|
||||||
|
|
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Desktop (please complete the following information):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Smartphone (please complete the following information):**
|
||||||
|
- Device: [e.g. iPhone6]
|
||||||
|
- OS: [e.g. iOS8.1]
|
||||||
|
- Browser [e.g. stock browser, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -40,3 +40,6 @@ yarn-error.log*
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.vercel
|
||||||
|
.env*.local
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
import { SignIn } from "@clerk/nextjs";
|
import { SignIn } from "@clerk/nextjs";
|
||||||
|
|
||||||
export const dynamic = "force-static";
|
|
||||||
|
|
||||||
const SignInPage = () => (
|
const SignInPage = () => (
|
||||||
<SignIn path="/sign-in" routing="path" signUpUrl="/sign-up" />
|
<SignIn path="/sign-in" routing="path" signUpUrl="/sign-up" />
|
||||||
);
|
);
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
import { SignUp } from "@clerk/nextjs";
|
import { SignUp } from "@clerk/nextjs";
|
||||||
|
|
||||||
export const dynamic = "force-static";
|
|
||||||
|
|
||||||
const SignUpPage = () => (
|
const SignUpPage = () => (
|
||||||
<SignUp path="/sign-up" routing="path" signInUrl="/sign-in" />
|
<SignUp path="/sign-up" routing="path" signInUrl="/sign-in" />
|
||||||
);
|
);
|
|
@ -1,56 +1,32 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { configureAbly, useChannel } from "@ably-labs/react-hooks";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, 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 LoadingIndicator from "./LoadingIndicator";
|
import LoadingIndicator from "@/_components/LoadingIndicator";
|
||||||
import { useUser } from "@clerk/nextjs";
|
import { useUser } from "@clerk/nextjs";
|
||||||
import { createRoom, deleteRoom, getRooms } from "@/server/actions/room";
|
import { useChannel } from "ably/react";
|
||||||
|
import type { RoomsResponse } from "@/_utils/types";
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const revalidate = 0;
|
|
||||||
export const fetchCache = "force-no-store";
|
|
||||||
|
|
||||||
const RoomList = () => {
|
const RoomList = () => {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
configureAbly({
|
|
||||||
key: env.NEXT_PUBLIC_ABLY_PUBLIC_KEY,
|
|
||||||
clientId: user?.id,
|
|
||||||
recover: (_, cb) => {
|
|
||||||
cb(true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useChannel(
|
useChannel(
|
||||||
`${env.NEXT_PUBLIC_APP_ENV}-${user?.id}`,
|
`${env.NEXT_PUBLIC_APP_ENV}-${user?.id}`,
|
||||||
() => void getRoomsHandler()
|
() => void getRoomsHandler()
|
||||||
);
|
);
|
||||||
|
|
||||||
const [roomName, setRoomName] = useState<string>("");
|
const [roomName, setRoomName] = useState<string>("");
|
||||||
const [roomsFromDb, setRoomsFromDb] = useState<
|
const [roomsFromDb, setRoomsFromDb] = useState<RoomsResponse>(undefined);
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
createdAt: Date;
|
|
||||||
roomName: string;
|
|
||||||
}[]
|
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
created_at: Date | null;
|
|
||||||
userId: string;
|
|
||||||
roomName: string | null;
|
|
||||||
storyName: string | null;
|
|
||||||
visible: boolean;
|
|
||||||
scale: string;
|
|
||||||
}[]
|
|
||||||
| undefined
|
|
||||||
| null
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const createRoomHandler = async () => {
|
const createRoomHandler = async () => {
|
||||||
await createRoom(roomName);
|
await fetch("/api/internal/room", {
|
||||||
|
cache: "no-cache",
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name: roomName }),
|
||||||
|
});
|
||||||
|
|
||||||
setRoomName("");
|
setRoomName("");
|
||||||
(document.querySelector("#roomNameInput") as HTMLInputElement).value = "";
|
(document.querySelector("#roomNameInput") as HTMLInputElement).value = "";
|
||||||
(document.querySelector("#new-room-modal") as HTMLInputElement).checked =
|
(document.querySelector("#new-room-modal") as HTMLInputElement).checked =
|
||||||
|
@ -58,18 +34,21 @@ const RoomList = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRoomsHandler = async () => {
|
const getRoomsHandler = async () => {
|
||||||
const dbRooms = await getRooms();
|
const dbRoomsResponse = await fetch("/api/internal/room", {
|
||||||
|
cache: "no-cache",
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
const dbRooms = (await dbRoomsResponse.json()) as RoomsResponse;
|
||||||
setRoomsFromDb(dbRooms);
|
setRoomsFromDb(dbRooms);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteRoomHandler = async (roomId: string) => {
|
const deleteRoomHandler = async (roomId: string) => {
|
||||||
await deleteRoom(roomId);
|
await fetch(`/api/internal/room/${roomId}`, {
|
||||||
|
cache: "no-cache",
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void getRoomsHandler();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-8">
|
<div className="flex flex-col items-center justify-center gap-8">
|
||||||
{/* Modal for Adding Rooms */}
|
{/* Modal for Adding Rooms */}
|
|
@ -1,4 +1,4 @@
|
||||||
import LoadingIndicator from "@/app/_components/LoadingIndicator";
|
import LoadingIndicator from "@/_components/LoadingIndicator";
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return <LoadingIndicator />;
|
return <LoadingIndicator />;
|
|
@ -1,15 +1,18 @@
|
||||||
import RoomList from "@/app/_components/RoomList";
|
import RoomList from "@/(client)/dashboard/RoomList";
|
||||||
import { FaShieldAlt } from "react-icons/fa";
|
import { FaShieldAlt } from "react-icons/fa";
|
||||||
import { GiStarFormation } from "react-icons/gi";
|
import { GiStarFormation } from "react-icons/gi";
|
||||||
import { isAdmin, isVIP } from "@/utils/helpers";
|
import { isAdmin, isVIP } from "@/_utils/helpers";
|
||||||
import { currentUser } from "@clerk/nextjs";
|
import { currentUser } from "@clerk/nextjs";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = ["pdx1"];
|
||||||
|
|
||||||
export default async function Dashboard() {
|
export default async function Dashboard() {
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col text-center items-center justify-center px-4 py-16 gap-4">
|
<div className="flex flex-col text-center items-center justify-center px-4 py-16 gap-4">
|
||||||
<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">
|
||||||
Hi, {user?.firstName ?? user?.username}!{" "}
|
Hi, {user?.firstName ?? user?.username}!{" "}
|
||||||
{isAdmin(user?.publicMetadata) && (
|
{isAdmin(user?.publicMetadata) && (
|
||||||
<FaShieldAlt className="inline-block text-primary" />
|
<FaShieldAlt className="inline-block text-primary" />
|
16
app/(client)/layout.tsx
Normal file
16
app/(client)/layout.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AblyProvider } from "ably/react";
|
||||||
|
import * as Ably from "ably";
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const client = new Ably.Realtime.Promise({
|
||||||
|
authUrl: "/api/internal/ably",
|
||||||
|
});
|
||||||
|
|
||||||
|
return <AblyProvider client={client}>{children}</AblyProvider>;
|
||||||
|
}
|
23
app/(client)/room/[id]/NoRoomUI.tsx
Normal file
23
app/(client)/room/[id]/NoRoomUI.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const VoteUI = () => {
|
||||||
|
return (
|
||||||
|
<span className="text-center">
|
||||||
|
<h1 className="text-5xl font-bold m-2">4️⃣0️⃣4️⃣</h1>
|
||||||
|
<h1 className="text-5xl font-bold m-2">
|
||||||
|
Oops! This room does not appear to exist, or may have been deleted! 😢
|
||||||
|
</h1>
|
||||||
|
<Link
|
||||||
|
about="Back to home."
|
||||||
|
href="/"
|
||||||
|
className="btn btn-secondary normal-case text-xl m-2"
|
||||||
|
>
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VoteUI;
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
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 { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
|
@ -16,21 +16,15 @@ import {
|
||||||
IoSaveOutline,
|
IoSaveOutline,
|
||||||
} from "react-icons/io5";
|
} from "react-icons/io5";
|
||||||
import { GiStarFormation } from "react-icons/gi";
|
import { GiStarFormation } from "react-icons/gi";
|
||||||
import { configureAbly, useChannel, usePresence } from "@ably-labs/react-hooks";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { FaShieldAlt } from "react-icons/fa";
|
import { FaShieldAlt } from "react-icons/fa";
|
||||||
import { RiVipCrownFill } from "react-icons/ri";
|
import { RiVipCrownFill } from "react-icons/ri";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "env.mjs";
|
||||||
import { isAdmin, isVIP, jsonToCsv } from "@/utils/helpers";
|
import { isAdmin, isVIP, jsonToCsv } from "app/_utils/helpers";
|
||||||
import type { PresenceItem } from "@/utils/types";
|
import type { PresenceItem, RoomResponse, VoteResponse } from "@/_utils/types";
|
||||||
import LoadingIndicator from "@/app/_components/LoadingIndicator";
|
import LoadingIndicator from "@/_components/LoadingIndicator";
|
||||||
import { useUser } from "@clerk/nextjs";
|
import { useUser } from "@clerk/nextjs";
|
||||||
import { getRoom, setRoom } from "@/server/actions/room";
|
import { useChannel, usePresence } from "ably/react";
|
||||||
import { getVotes, setVote } from "@/server/actions/vote";
|
import NoRoomUI from "./NoRoomUI";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const revalidate = 0;
|
|
||||||
export const fetchCache = "force-no-store";
|
|
||||||
|
|
||||||
const VoteUI = () => {
|
const VoteUI = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
@ -41,65 +35,33 @@ const VoteUI = () => {
|
||||||
const [roomScale, setRoomScale] = useState<string>("");
|
const [roomScale, setRoomScale] = useState<string>("");
|
||||||
const [copied, setCopied] = useState<boolean>(false);
|
const [copied, setCopied] = useState<boolean>(false);
|
||||||
|
|
||||||
const [roomFromDb, setRoomFromDb] = useState<
|
const [roomFromDb, setRoomFromDb] = useState<RoomResponse>();
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
created_at: Date | null;
|
|
||||||
userId: string;
|
|
||||||
roomName: string | null;
|
|
||||||
storyName: string | null;
|
|
||||||
visible: boolean;
|
|
||||||
scale: string | null;
|
|
||||||
logs: {
|
|
||||||
id: string;
|
|
||||||
created_at: Date | null;
|
|
||||||
userId: string;
|
|
||||||
roomId: string;
|
|
||||||
roomName: string | null;
|
|
||||||
storyName: string | null;
|
|
||||||
scale: string | null;
|
|
||||||
votes: unknown;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
| null
|
|
||||||
>();
|
|
||||||
|
|
||||||
const [votesFromDb, setVotesFromDb] = useState<
|
const [votesFromDb, setVotesFromDb] = useState<VoteResponse>(undefined);
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
created_at: Date | null;
|
|
||||||
userId: string;
|
|
||||||
roomId: string;
|
|
||||||
value: string;
|
|
||||||
}[]
|
|
||||||
| undefined
|
|
||||||
| null
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const getRoomHandler = async () => {
|
const getRoomHandler = async () => {
|
||||||
const dbRoom = await getRoom(roomId);
|
const dbRoomResponse = await fetch(`/api/internal/room/${roomId}`, {
|
||||||
|
cache: "no-cache",
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
const dbRoom = (await dbRoomResponse.json()) as RoomResponse;
|
||||||
setRoomFromDb(dbRoom);
|
setRoomFromDb(dbRoom);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVotesHandler = async () => {
|
const getVotesHandler = async () => {
|
||||||
const dbVotes = await getVotes(roomId);
|
const dbVotesResponse = await fetch(`/api/internal/room/${roomId}/votes`, {
|
||||||
|
cache: "no-cache",
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
const dbVotes = (await dbVotesResponse.json()) as VoteResponse;
|
||||||
setVotesFromDb(dbVotes);
|
setVotesFromDb(dbVotes);
|
||||||
};
|
};
|
||||||
|
|
||||||
configureAbly({
|
useChannel(
|
||||||
key: env.NEXT_PUBLIC_ABLY_PUBLIC_KEY,
|
|
||||||
clientId: user ? user.id : "unknown",
|
|
||||||
recover: (_, cb) => {
|
|
||||||
cb(true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [channel] = useChannel(
|
|
||||||
{
|
{
|
||||||
channelName: `${env.NEXT_PUBLIC_APP_ENV}-${roomId}`,
|
channelName: `${env.NEXT_PUBLIC_APP_ENV}-${roomId}`,
|
||||||
},
|
},
|
||||||
({ name }) => {
|
({ name }: { name: string }) => {
|
||||||
if (name === EventTypes.ROOM_UPDATE) {
|
if (name === EventTypes.ROOM_UPDATE) {
|
||||||
void getVotesHandler();
|
void getVotesHandler();
|
||||||
void getRoomHandler();
|
void getRoomHandler();
|
||||||
|
@ -109,7 +71,7 @@ const VoteUI = () => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const [presenceData] = usePresence<PresenceItem>(
|
const { presenceData } = usePresence<PresenceItem>(
|
||||||
`${env.NEXT_PUBLIC_APP_ENV}-${roomId}`,
|
`${env.NEXT_PUBLIC_APP_ENV}-${roomId}`,
|
||||||
{
|
{
|
||||||
name: (user?.fullName ?? user?.username) || "",
|
name: (user?.fullName ?? user?.username) || "",
|
||||||
|
@ -120,18 +82,7 @@ const VoteUI = () => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe on mount and unsubscribe on unmount
|
// Init Story name
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener("beforeunload", () => channel.presence.leave());
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("beforeunload", () =>
|
|
||||||
channel.presence.leave()
|
|
||||||
);
|
|
||||||
channel.presence.leave();
|
|
||||||
};
|
|
||||||
}, [channel.presence, roomId]);
|
|
||||||
|
|
||||||
// Init story name
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (roomFromDb) {
|
if (roomFromDb) {
|
||||||
setStoryNameText(roomFromDb.storyName || "");
|
setStoryNameText(roomFromDb.storyName || "");
|
||||||
|
@ -155,7 +106,13 @@ const VoteUI = () => {
|
||||||
|
|
||||||
const setVoteHandler = async (value: string) => {
|
const setVoteHandler = async (value: string) => {
|
||||||
if (roomFromDb) {
|
if (roomFromDb) {
|
||||||
await setVote(value, roomFromDb.id);
|
await fetch(`/api/internal/room/${roomId}/vote`, {
|
||||||
|
cache: "no-cache",
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -165,14 +122,17 @@ const VoteUI = () => {
|
||||||
log = false
|
log = false
|
||||||
) => {
|
) => {
|
||||||
if (roomFromDb) {
|
if (roomFromDb) {
|
||||||
await setRoom(
|
await fetch(`/api/internal/room/${roomId}`, {
|
||||||
storyNameText,
|
cache: "no-cache",
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: storyNameText,
|
||||||
visible,
|
visible,
|
||||||
roomScale,
|
scale: roomScale,
|
||||||
roomFromDb.id,
|
|
||||||
reset,
|
reset,
|
||||||
log
|
log,
|
||||||
);
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -238,15 +198,13 @@ const VoteUI = () => {
|
||||||
if (!!matchedVote) {
|
if (!!matchedVote) {
|
||||||
return <div>{matchedVote.value}</div>;
|
return <div>{matchedVote.value}</div>;
|
||||||
} else {
|
} else {
|
||||||
return <IoHourglassOutline className="text-xl mx-auto text-error" />;
|
return <IoHourglassOutline className="text-xl text-error" />;
|
||||||
}
|
}
|
||||||
} else if (!!matchedVote) {
|
} else if (!!matchedVote) {
|
||||||
return (
|
return <IoCheckmarkCircleOutline className="text-xl text-success" />;
|
||||||
<IoCheckmarkCircleOutline className="text-xl mx-auto text-success" />
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<IoHourglassOutline className="text-xl animate-spin mx-auto text-warning" />
|
<IoHourglassOutline className="text-xl animate-spin text-warning" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -257,9 +215,9 @@ const VoteUI = () => {
|
||||||
// Room has been loaded
|
// Room has been loaded
|
||||||
} else if (roomFromDb) {
|
} else if (roomFromDb) {
|
||||||
return (
|
return (
|
||||||
<span className="text-center">
|
<div className="flex flex-col gap-4 text-center justify-center items-center">
|
||||||
<div className="text-2xl">{roomFromDb.roomName}</div>
|
<div className="text-2xl">{roomFromDb.roomName}</div>
|
||||||
<div className="flex flex-row flex-wrap text-center justify-center items-center gap-1 text-md mx-auto">
|
<div className="flex flex-row flex-wrap text-center justify-center items-center gap-1 text-md">
|
||||||
<div>ID:</div>
|
<div>ID:</div>
|
||||||
<div>{roomFromDb.id}</div>
|
<div>{roomFromDb.id}</div>
|
||||||
|
|
||||||
|
@ -276,13 +234,11 @@ const VoteUI = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{roomFromDb && (
|
{roomFromDb && (
|
||||||
<div className="card card-compact bg-base-100 shadow-xl mx-auto m-4">
|
<div className="card card-compact bg-base-100 shadow-xl">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="card-title mx-auto">
|
<h2 className="card-title">Story: {roomFromDb.storyName}</h2>
|
||||||
Story: {roomFromDb.storyName}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<ul className="p-0 mx-auto flex flex-row flex-wrap justify-center items-center text-ceter gap-4">
|
<ul className="p-0 flex flex-row flex-wrap justify-center items-center text-ceter gap-4">
|
||||||
{presenceData &&
|
{presenceData &&
|
||||||
presenceData
|
presenceData
|
||||||
.filter(
|
.filter(
|
||||||
|
@ -299,7 +255,7 @@ const VoteUI = () => {
|
||||||
key={presenceItem.clientId}
|
key={presenceItem.clientId}
|
||||||
className="flex flex-row items-center justify-center gap-2"
|
className="flex flex-row items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<div className="w-10 rounded-full avatar mx-auto">
|
<div className="w-10 rounded-full avatar">
|
||||||
<Image
|
<Image
|
||||||
src={presenceItem.data.image}
|
src={presenceItem.data.image}
|
||||||
alt={`${presenceItem.data.name}'s Profile Picture`}
|
alt={`${presenceItem.data.name}'s Profile Picture`}
|
||||||
|
@ -308,7 +264,7 @@ const VoteUI = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="flex flex-row flex-wrap text-center justify-center items-center gap-1 text-md mx-auto">
|
<p className="flex flex-row flex-wrap text-center justify-center items-center gap-1 text-md">
|
||||||
{presenceItem.data.name}{" "}
|
{presenceItem.data.name}{" "}
|
||||||
{presenceItem.data.isAdmin && (
|
{presenceItem.data.isAdmin && (
|
||||||
<span
|
<span
|
||||||
|
@ -349,7 +305,7 @@ const VoteUI = () => {
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="join md:btn-group-horizontal mx-auto">
|
<div className="join md:btn-group-horizontal">
|
||||||
{roomFromDb.scale?.split(",").map((scaleItem, index) => {
|
{roomFromDb.scale?.split(",").map((scaleItem, index) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
@ -373,30 +329,30 @@ const VoteUI = () => {
|
||||||
{!!roomFromDb &&
|
{!!roomFromDb &&
|
||||||
(roomFromDb.userId === user?.id || isAdmin(user?.publicMetadata)) && (
|
(roomFromDb.userId === user?.id || isAdmin(user?.publicMetadata)) && (
|
||||||
<>
|
<>
|
||||||
<div className="card card-compact bg-base-100 shadow-xl mx-auto m-4">
|
<div className="card card-compact bg-base-100 shadow-xl">
|
||||||
<div className="card-body flex flex-col flex-wrap">
|
<div className="card-body flex flex-col flex-wrap">
|
||||||
<h2 className="card-title mx-auto">Room Settings</h2>
|
<h2 className="card-title">Room Settings</h2>
|
||||||
|
|
||||||
<label className="label mx-auto">
|
<label className="label">
|
||||||
{"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"
|
||||||
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">{"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"
|
||||||
value={storyNameText}
|
value={storyNameText}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setStoryNameText(event.target.value);
|
setStoryNameText(event.target.value);
|
||||||
|
@ -476,25 +432,11 @@ const VoteUI = () => {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
);
|
);
|
||||||
// Room does not exist
|
// Room does not exist
|
||||||
} else {
|
} else {
|
||||||
return (
|
return <NoRoomUI />;
|
||||||
<span className="text-center">
|
|
||||||
<h1 className="text-5xl font-bold m-2">4️⃣0️⃣4️⃣</h1>
|
|
||||||
<h1 className="text-5xl font-bold m-2">
|
|
||||||
Oops! This room does not appear to exist, or may have been deleted! 😢
|
|
||||||
</h1>
|
|
||||||
<Link
|
|
||||||
about="Back to home."
|
|
||||||
href="/"
|
|
||||||
className="btn btn-secondary normal-case text-xl m-2"
|
|
||||||
>
|
|
||||||
Back to Home
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import LoadingIndicator from "@/app/_components/LoadingIndicator";
|
import LoadingIndicator from "@/_components/LoadingIndicator";
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return <LoadingIndicator />;
|
return <LoadingIndicator />;
|
|
@ -1,4 +1,7 @@
|
||||||
import VoteUI from "@/app/_components/VoteUI";
|
import VoteUI from "@/(client)/room/[id]/VoteUI";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = ["pdx1"];
|
||||||
|
|
||||||
export default function Room() {
|
export default function Room() {
|
||||||
return (
|
return (
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { GiTechnoHeart } from "react-icons/gi";
|
import { GiTechnoHeart } from "react-icons/gi";
|
||||||
import packagejson from "../../../package.json";
|
import packagejson from "../../package.json";
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
return (
|
return (
|
|
@ -4,14 +4,12 @@ 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, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "env.mjs";
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const Navbar = ({ title }: NavbarProps) => {
|
const Navbar = ({ title }: NavbarProps) => {
|
||||||
const { isSignedIn } = useUser();
|
const { isSignedIn } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -21,7 +19,7 @@ const Navbar = ({ title }: NavbarProps) => {
|
||||||
if (pathname !== "/dashboard" && isSignedIn) {
|
if (pathname !== "/dashboard" && isSignedIn) {
|
||||||
return (
|
return (
|
||||||
<Link className="btn btn-primary btn-outline mx-2" href="/dashboard">
|
<Link className="btn btn-primary btn-outline mx-2" href="/dashboard">
|
||||||
Dashboard
|
Demo Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
} else if (!isSignedIn) {
|
} else if (!isSignedIn) {
|
||||||
|
@ -30,7 +28,7 @@ const Navbar = ({ title }: NavbarProps) => {
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => void router.push("/sign-in")}
|
onClick={() => void router.push("/sign-in")}
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In to Demo
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "env.mjs";
|
||||||
import type { EventType } from "../utils/types";
|
import type { EventType } from "@/_utils/types";
|
||||||
|
|
||||||
export const publishToChannel = async (
|
export const publishToChannel = async (
|
||||||
channel: string,
|
channel: string,
|
||||||
|
@ -12,7 +12,7 @@ export const publishToChannel = async (
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Basic ${btoa(env.ABLY_PRIVATE_KEY)}`,
|
Authorization: `Basic ${btoa(env.ABLY_API_KEY)}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: event,
|
name: event,
|
|
@ -1,7 +1,7 @@
|
||||||
import { neon, neonConfig } from "@neondatabase/serverless";
|
import { neon, neonConfig } from "@neondatabase/serverless";
|
||||||
import { drizzle } from "drizzle-orm/neon-http";
|
import { drizzle } from "drizzle-orm/neon-http";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "env.mjs";
|
||||||
import * as schema from "@/server/schema";
|
import * as schema from "app/_lib/schema";
|
||||||
|
|
||||||
neonConfig.fetchConnectionCache = true;
|
neonConfig.fetchConnectionCache = true;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Redis } from "@upstash/redis";
|
import { Redis } from "@upstash/redis";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "env.mjs";
|
||||||
|
|
||||||
export const redis = Redis.fromEnv();
|
export const redis = Redis.fromEnv();
|
||||||
|
|
|
@ -1,13 +1,8 @@
|
||||||
// import { Unkey, verifyKey } from "@unkey/api";
|
|
||||||
import { verifyKey } from "@unkey/api";
|
import { verifyKey } from "@unkey/api";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
// import { env } from "@/env.mjs";
|
|
||||||
|
|
||||||
// const unkey = new Unkey({token: env.UNKEY_ROOT_KEY})
|
|
||||||
|
|
||||||
export const validateRequest = async (req: NextRequest) => {
|
export const validateRequest = async (req: NextRequest) => {
|
||||||
const authorization = req.headers.get("authorization");
|
const authorization = req.headers.get("authorization");
|
||||||
// Get the auth bearer token if it exists
|
|
||||||
if (authorization) {
|
if (authorization) {
|
||||||
const key = authorization.split("Bearer ").at(1);
|
const key = authorization.split("Bearer ").at(1);
|
||||||
if (key) {
|
if (key) {
|
69
app/_utils/types.ts
Normal file
69
app/_utils/types.ts
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
type BetterEnum<T> = T[keyof T];
|
||||||
|
|
||||||
|
export const EventTypes = {
|
||||||
|
ROOM_LIST_UPDATE: "room.list.update",
|
||||||
|
ROOM_UPDATE: "room.update",
|
||||||
|
VOTE_UPDATE: "vote.update",
|
||||||
|
STATS_UPDATE: "stats.update",
|
||||||
|
} as const;
|
||||||
|
export type EventType = BetterEnum<typeof EventTypes>;
|
||||||
|
|
||||||
|
export interface PresenceItem {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
client_id: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isVIP: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomsResponse =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
roomName: string;
|
||||||
|
}[]
|
||||||
|
| {
|
||||||
|
roomName: string | null;
|
||||||
|
id: string;
|
||||||
|
created_at: Date | null;
|
||||||
|
userId: string;
|
||||||
|
storyName: string | null;
|
||||||
|
visible: boolean;
|
||||||
|
scale: string;
|
||||||
|
}[]
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export type RoomResponse =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
created_at: Date | null;
|
||||||
|
userId: string;
|
||||||
|
roomName: string | null;
|
||||||
|
storyName: string | null;
|
||||||
|
visible: boolean;
|
||||||
|
scale: string | null;
|
||||||
|
logs: {
|
||||||
|
id: string;
|
||||||
|
created_at: Date | null;
|
||||||
|
userId: string;
|
||||||
|
roomId: string;
|
||||||
|
roomName: string | null;
|
||||||
|
storyName: string | null;
|
||||||
|
scale: string | null;
|
||||||
|
votes: unknown;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export type VoteResponse =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
created_at: Date | null;
|
||||||
|
userId: string;
|
||||||
|
roomId: string;
|
||||||
|
}[]
|
||||||
|
| null
|
||||||
|
| undefined;
|
|
@ -1,7 +1,7 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "../server/db";
|
import { db } from "../_lib/db";
|
||||||
import { rooms } from "../server/schema";
|
import { rooms } from "../_lib/schema";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "env.mjs";
|
||||||
|
|
||||||
export const onUserDeletedHandler = async (userId: string | undefined) => {
|
export const onUserDeletedHandler = async (userId: string | undefined) => {
|
||||||
if (!userId) {
|
if (!userId) {
|
9
app/api/README.md
Normal file
9
app/api/README.md
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# API
|
||||||
|
|
||||||
|
## Categories:
|
||||||
|
|
||||||
|
1. Internal - Only to be used within the application by signed in users.
|
||||||
|
2. External - Only to be used outside of the application.
|
||||||
|
- Public - Can be used by anyone.
|
||||||
|
- Private - Can only be used by a user who uses a valid API Key from Unkey
|
||||||
|
3. Webhooks - Only to be used by external services to send data to the application. Sub-routes are for different handlers.
|
37
app/api/internal/ably/route.ts
Normal file
37
app/api/internal/ably/route.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import * as Ably from "ably/promises";
|
||||||
|
import { env } from "env.mjs";
|
||||||
|
import { currentUser } from "@clerk/nextjs";
|
||||||
|
|
||||||
|
async function handler() {
|
||||||
|
const user = await currentUser();
|
||||||
|
|
||||||
|
if (!env.ABLY_API_KEY) {
|
||||||
|
return new Response(
|
||||||
|
`Missing ABLY_API_KEY environment variable.
|
||||||
|
If you're running locally, please ensure you have a ./.env file with a value for ABLY_API_KEY=your-key.
|
||||||
|
If you're running in Netlify, make sure you've configured env variable ABLY_API_KEY.
|
||||||
|
Please see README.md for more details on configuring your Ably API Key.`,
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
statusText: `Missing ABLY_API_KEY environment variable.
|
||||||
|
If you're running locally, please ensure you have a ./.env file with a value for ABLY_API_KEY=your-key.
|
||||||
|
If you're running in Netlify, make sure you've configured env variable ABLY_API_KEY.
|
||||||
|
Please see README.md for more details on configuring your Ably API Key.`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Ably.Rest(env.ABLY_API_KEY);
|
||||||
|
const tokenRequestData = await client.auth.createTokenRequest({
|
||||||
|
clientId: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(tokenRequestData, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "SUCCESS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler as POST, handler as GET };
|
184
app/api/internal/room/[roomId]/route.ts
Normal file
184
app/api/internal/room/[roomId]/route.ts
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { db } from "@/_lib/db";
|
||||||
|
import { logs, rooms, votes } from "@/_lib/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { publishToChannel } from "@/_lib/ably";
|
||||||
|
import { EventTypes } from "@/_utils/types";
|
||||||
|
import { invalidateCache } from "@/_lib/redis";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { getAuth } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = ["pdx1"];
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { roomId: string } }
|
||||||
|
) {
|
||||||
|
if (!params.roomId) {
|
||||||
|
return new NextResponse("RoomId Missing!", {
|
||||||
|
status: 400,
|
||||||
|
statusText: "BAD REQUEST!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomFromDb = await db.query.rooms.findFirst({
|
||||||
|
where: eq(rooms.id, params.roomId),
|
||||||
|
with: {
|
||||||
|
logs: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(roomFromDb, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "SUCCESS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { roomId: string } }
|
||||||
|
) {
|
||||||
|
const { userId } = getAuth(request as NextRequest);
|
||||||
|
|
||||||
|
if (!params.roomId) {
|
||||||
|
return new NextResponse("RoomId Missing!", {
|
||||||
|
status: 400,
|
||||||
|
statusText: "BAD REQUEST!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedRoom = await db
|
||||||
|
.delete(rooms)
|
||||||
|
.where(eq(rooms.id, params.roomId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const success = deletedRoom.length > 0;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await publishToChannel(
|
||||||
|
`${userId}`,
|
||||||
|
EventTypes.ROOM_LIST_UPDATE,
|
||||||
|
JSON.stringify(deletedRoom)
|
||||||
|
);
|
||||||
|
|
||||||
|
await publishToChannel(
|
||||||
|
`${params.roomId}`,
|
||||||
|
EventTypes.ROOM_UPDATE,
|
||||||
|
JSON.stringify(deletedRoom)
|
||||||
|
);
|
||||||
|
|
||||||
|
await invalidateCache(`kv_roomlist_${userId}`);
|
||||||
|
|
||||||
|
await publishToChannel(
|
||||||
|
`${userId}`,
|
||||||
|
EventTypes.ROOM_LIST_UPDATE,
|
||||||
|
JSON.stringify(deletedRoom)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(deletedRoom, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "SUCCESS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error deleting room!" },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
statusText: "ERROR",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { roomId: string } }
|
||||||
|
) {
|
||||||
|
const { userId } = getAuth(request as NextRequest);
|
||||||
|
|
||||||
|
const reqBody = (await request.json()) as {
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
scale: string;
|
||||||
|
reset: boolean;
|
||||||
|
log: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!params.roomId) {
|
||||||
|
return new NextResponse("RoomId Missing!", {
|
||||||
|
status: 400,
|
||||||
|
statusText: "BAD REQUEST!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reqBody.reset) {
|
||||||
|
if (reqBody.log) {
|
||||||
|
const oldRoom = await db.query.rooms.findFirst({
|
||||||
|
where: eq(rooms.id, params.roomId),
|
||||||
|
with: {
|
||||||
|
votes: true,
|
||||||
|
logs: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
oldRoom &&
|
||||||
|
(await db.insert(logs).values({
|
||||||
|
id: `log_${createId()}`,
|
||||||
|
userId: userId || "",
|
||||||
|
roomId: params.roomId,
|
||||||
|
scale: oldRoom.scale,
|
||||||
|
votes: oldRoom.votes.map((vote) => {
|
||||||
|
return {
|
||||||
|
name: vote.userId,
|
||||||
|
value: vote.value,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
roomName: oldRoom.roomName,
|
||||||
|
storyName: oldRoom.storyName,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(votes).where(eq(votes.roomId, params.roomId));
|
||||||
|
|
||||||
|
await invalidateCache(`kv_votes_${params.roomId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRoom = await db
|
||||||
|
.update(rooms)
|
||||||
|
.set({
|
||||||
|
storyName: reqBody.name,
|
||||||
|
visible: reqBody.visible,
|
||||||
|
scale: [...new Set(reqBody.scale.split(","))]
|
||||||
|
.filter((item) => item !== "")
|
||||||
|
.toString(),
|
||||||
|
})
|
||||||
|
.where(eq(rooms.id, params.roomId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const success = newRoom.length > 0;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await publishToChannel(
|
||||||
|
`${params.roomId}`,
|
||||||
|
EventTypes.ROOM_UPDATE,
|
||||||
|
JSON.stringify(newRoom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return NextResponse.json(newRoom, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "SUCCESS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Room update failed" },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
statusText: "ERROR",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
76
app/api/internal/room/[roomId]/vote/route.ts
Normal file
76
app/api/internal/room/[roomId]/vote/route.ts
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { invalidateCache } from "@/_lib/redis";
|
||||||
|
import { db } from "@/_lib/db";
|
||||||
|
import { votes } from "@/_lib/schema";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { publishToChannel } from "@/_lib/ably";
|
||||||
|
import { EventTypes } from "@/_utils/types";
|
||||||
|
import { getAuth } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = ["pdx1"];
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { roomId: string } }
|
||||||
|
) {
|
||||||
|
const { userId } = getAuth(request as NextRequest);
|
||||||
|
|
||||||
|
if (!params.roomId) {
|
||||||
|
return new NextResponse("RoomId Missing!", {
|
||||||
|
status: 400,
|
||||||
|
statusText: "BAD REQUEST!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqBody = (await request.json()) as { value: string };
|
||||||
|
|
||||||
|
const upsertResult = await db
|
||||||
|
.insert(votes)
|
||||||
|
.values({
|
||||||
|
id: `vote_${createId()}`,
|
||||||
|
value: reqBody.value,
|
||||||
|
userId: userId || "",
|
||||||
|
roomId: params.roomId,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [votes.userId, votes.roomId],
|
||||||
|
set: {
|
||||||
|
value: reqBody.value,
|
||||||
|
userId: userId || "",
|
||||||
|
roomId: params.roomId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = upsertResult.rowCount > 0;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await invalidateCache(`kv_votes_${params.roomId}`);
|
||||||
|
|
||||||
|
await publishToChannel(
|
||||||
|
`${params.roomId}`,
|
||||||
|
EventTypes.VOTE_UPDATE,
|
||||||
|
reqBody.value
|
||||||
|
);
|
||||||
|
|
||||||
|
await publishToChannel(
|
||||||
|
`stats`,
|
||||||
|
EventTypes.STATS_UPDATE,
|
||||||
|
JSON.stringify(success)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(upsertResult, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "SUCCESS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to set vote!" },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
statusText: "ERROR",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
49
app/api/internal/room/[roomId]/votes/route.ts
Normal file
49
app/api/internal/room/[roomId]/votes/route.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { fetchCache, setCache } from "@/_lib/redis";
|
||||||
|
import { db } from "@/_lib/db";
|
||||||
|
import { votes } from "@/_lib/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = ["pdx1"];
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { roomId: string } }
|
||||||
|
) {
|
||||||
|
if (!params.roomId) {
|
||||||
|
return new NextResponse("RoomId Missing!", {
|
||||||
|
status: 400,
|
||||||
|
statusText: "BAD REQUEST!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedResult = await fetchCache<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
created_at: Date;
|
||||||
|
userId: string;
|
||||||
|
roomId: string;
|
||||||
|
}[]
|
||||||
|
>(`kv_votes_${params.roomId}`);
|
||||||
|
|
||||||
|
if (cachedResult) {
|
||||||
|
return NextResponse.json(cachedResult, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "SUCCESS!",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const votesByRoomId = await db.query.votes.findMany({
|
||||||
|
where: eq(votes.roomId, params.roomId),
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCache(`kv_votes_${params.roomId}`, votesByRoomId);
|
||||||
|
|
||||||
|
return NextResponse.json(votesByRoomId, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "SUCCESS!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
88
app/api/internal/room/route.ts
Normal file
88
app/api/internal/room/route.ts
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { fetchCache, invalidateCache, setCache } from "@/_lib/redis";
|
||||||
|
import { db } from "@/_lib/db";
|
||||||
|
import { rooms } from "@/_lib/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { publishToChannel } from "@/_lib/ably";
|
||||||
|
import { EventTypes } from "@/_utils/types";
|
||||||
|
import { getAuth } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = ["pdx1"];
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { userId } = getAuth(request as NextRequest);
|
||||||
|
|
||||||
|
const cachedResult = await fetchCache<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
roomName: string;
|
||||||
|
}[]
|
||||||
|
>(`kv_roomlist_${userId}`);
|
||||||
|
|
||||||
|
if (cachedResult) {
|
||||||
|
return NextResponse.json(cachedResult, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "SUCCESS",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const roomList = await db.query.rooms.findMany({
|
||||||
|
where: eq(rooms.userId, userId || ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
await setCache(`kv_roomlist_${userId}`, roomList);
|
||||||
|
|
||||||
|
return NextResponse.json(roomList, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "SUCCESS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { userId } = getAuth(request as NextRequest);
|
||||||
|
|
||||||
|
const reqBody = (await request.json()) as { name: string };
|
||||||
|
|
||||||
|
const room = await db
|
||||||
|
.insert(rooms)
|
||||||
|
.values({
|
||||||
|
id: `room_${createId()}`,
|
||||||
|
userId: userId || "",
|
||||||
|
roomName: reqBody.name,
|
||||||
|
storyName: "First Story!",
|
||||||
|
scale: "0.5,1,2,3,5,8",
|
||||||
|
visible: false,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const success = room.length > 0;
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
await invalidateCache(`kv_roomlist_${userId}`);
|
||||||
|
|
||||||
|
await publishToChannel(
|
||||||
|
`${userId}`,
|
||||||
|
EventTypes.ROOM_LIST_UPDATE,
|
||||||
|
JSON.stringify(room)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return NextResponse.json(room, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "SUCCESS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create room!" },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
statusText: "ERROR",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,12 +2,12 @@ import { type NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
onUserCreatedHandler,
|
onUserCreatedHandler,
|
||||||
onUserDeletedHandler,
|
onUserDeletedHandler,
|
||||||
} from "@/utils/webhookHelpers";
|
} from "app/_utils/webhookHelpers";
|
||||||
|
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import type { WebhookEvent } from "@clerk/nextjs/server";
|
import type { WebhookEvent } from "@clerk/nextjs/server";
|
||||||
import { Webhook } from "svix";
|
import { Webhook } from "svix";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "env.mjs";
|
||||||
|
|
||||||
export const runtime = "edge";
|
export const runtime = "edge";
|
||||||
export const preferredRegion = ["pdx1"];
|
export const preferredRegion = ["pdx1"];
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
@ -1,7 +1,7 @@
|
||||||
import { ClerkLoaded, ClerkProvider } from "@clerk/nextjs";
|
import { ClerkLoaded, ClerkProvider } from "@clerk/nextjs";
|
||||||
import Footer from "@/app/_components/Footer";
|
import Footer from "@/_components/Footer";
|
||||||
import Header from "@/app/_components/Header";
|
import Header from "@/_components/Header";
|
||||||
import "@/styles/globals.css";
|
import "@/globals.css";
|
||||||
import { dark } from "@clerk/themes";
|
import { dark } from "@clerk/themes";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
|
@ -20,7 +20,11 @@ export default function RootLayout({
|
||||||
baseTheme: dark,
|
baseTheme: dark,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<html lang="en" className="h-[100%] w-[100%] fixed overflow-y-auto">
|
<html
|
||||||
|
data-theme="synthwave"
|
||||||
|
lang="en"
|
||||||
|
className="h-[100%] w-[100%] fixed overflow-y-auto"
|
||||||
|
>
|
||||||
<body className="h-[100%] w-[100%] fixed overflow-y-auto">
|
<body className="h-[100%] w-[100%] fixed overflow-y-auto">
|
||||||
<ClerkLoaded>
|
<ClerkLoaded>
|
||||||
<Header title={metadata.title} />
|
<Header title={metadata.title} />
|
5
app/loading.tsx
Normal file
5
app/loading.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import LoadingIndicator from "@/_components/LoadingIndicator";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import type { Config } from "drizzle-kit";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: "./src/server/schema.ts",
|
schema: "./app/_lib/schema.ts",
|
||||||
out: "./drizzle/generated",
|
out: "./drizzle/generated",
|
||||||
driver: "pg",
|
driver: "pg",
|
||||||
breakpoints: true,
|
breakpoints: true,
|
||||||
|
|
|
@ -9,21 +9,19 @@ export const env = createEnv({
|
||||||
UPSTASH_REDIS_EXPIRY_SECONDS: z.string(),
|
UPSTASH_REDIS_EXPIRY_SECONDS: z.string(),
|
||||||
UPSTASH_RATELIMIT_REQUESTS: z.string(),
|
UPSTASH_RATELIMIT_REQUESTS: z.string(),
|
||||||
UPSTASH_RATELIMIT_SECONDS: z.string(),
|
UPSTASH_RATELIMIT_SECONDS: z.string(),
|
||||||
ABLY_PRIVATE_KEY: z.string(),
|
ABLY_API_KEY: z.string(),
|
||||||
APP_ENV: z.string(),
|
APP_ENV: z.string(),
|
||||||
UNKEY_ROOT_KEY: z.string(),
|
UNKEY_ROOT_KEY: z.string(),
|
||||||
CLERK_SECRET_KEY: z.string(),
|
CLERK_SECRET_KEY: z.string(),
|
||||||
CLERK_WEBHOOK_SIGNING_SECRET: z.string(),
|
CLERK_WEBHOOK_SIGNING_SECRET: z.string(),
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
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(),
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(),
|
||||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL: z.string(),
|
NEXT_PUBLIC_CLERK_SIGN_UP_URL: z.string(),
|
||||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.string(),
|
NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.string(),
|
||||||
},
|
},
|
||||||
experimental__runtimeEnv: {
|
experimental__runtimeEnv: {
|
||||||
NEXT_PUBLIC_ABLY_PUBLIC_KEY: process.env.NEXT_PUBLIC_ABLY_PUBLIC_KEY,
|
|
||||||
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
|
||||||
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
|
@ -1,5 +1,5 @@
|
||||||
import { authMiddleware, redirectToSignIn } from "@clerk/nextjs";
|
import { authMiddleware, redirectToSignIn } from "@clerk/nextjs";
|
||||||
import { validateRequest } from "./server/unkey";
|
import { validateRequest } from "./app/_lib/unkey";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Ratelimit } from "@upstash/ratelimit";
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
import { Redis } from "@upstash/redis";
|
import { Redis } from "@upstash/redis";
|
||||||
|
@ -15,7 +15,12 @@ const rateLimit = new Ratelimit({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default authMiddleware({
|
export default authMiddleware({
|
||||||
publicRoutes: ["/", "/api/public/(.*)", "/api/webhooks"],
|
publicRoutes: [
|
||||||
|
"/",
|
||||||
|
"/api/external/public/(.*)",
|
||||||
|
"/api/webhooks",
|
||||||
|
"/api/webhooks/(.*)",
|
||||||
|
],
|
||||||
afterAuth: async (auth, req) => {
|
afterAuth: async (auth, req) => {
|
||||||
if (!auth.userId && auth.isPublicRoute) {
|
if (!auth.userId && auth.isPublicRoute) {
|
||||||
const { success } = await rateLimit.limit(req.ip || "");
|
const { success } = await rateLimit.limit(req.ip || "");
|
||||||
|
@ -28,18 +33,19 @@ export default authMiddleware({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.nextUrl.pathname.includes("/api/private")) {
|
if (req.nextUrl.pathname.includes("/api/internal")) {
|
||||||
const { success } = await rateLimit.limit(req.ip || "");
|
const { success } = await rateLimit.limit(req.ip || "");
|
||||||
|
|
||||||
const isValid = await validateRequest(req);
|
if (!success) {
|
||||||
if (isValid && success) {
|
|
||||||
return NextResponse.next();
|
|
||||||
} else if (!success) {
|
|
||||||
return new NextResponse("TOO MANY REQUESTS", {
|
return new NextResponse("TOO MANY REQUESTS", {
|
||||||
status: 429,
|
status: 429,
|
||||||
statusText: "Too many requests!",
|
statusText: "Too many requests!",
|
||||||
});
|
});
|
||||||
} else if (!isValid) {
|
}
|
||||||
|
|
||||||
|
if (auth.userId) {
|
||||||
|
return NextResponse.next();
|
||||||
|
} else {
|
||||||
return new NextResponse("UNAUTHORIZED", {
|
return new NextResponse("UNAUTHORIZED", {
|
||||||
status: 403,
|
status: 403,
|
||||||
statusText: "Unauthorized!",
|
statusText: "Unauthorized!",
|
||||||
|
@ -47,15 +53,32 @@ export default authMiddleware({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.userId && !auth.isPublicRoute) {
|
if (req.nextUrl.pathname.includes("/api/external/private")) {
|
||||||
const requestHeaders = new Headers(req.headers);
|
const { success } = await rateLimit.limit(req.ip || "");
|
||||||
requestHeaders.set("Cache-Control", "no-cache");
|
|
||||||
return NextResponse.next({
|
if (!success) {
|
||||||
headers: requestHeaders,
|
return new NextResponse("TOO MANY REQUESTS", {
|
||||||
|
status: 429,
|
||||||
|
statusText: "Too many requests!",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isValid = await validateRequest(req);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
return NextResponse.next();
|
||||||
|
} else {
|
||||||
|
return new NextResponse("UNAUTHORIZED", {
|
||||||
|
status: 403,
|
||||||
|
statusText: "Unauthorized!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!auth.userId && !auth.isPublicRoute) {
|
if (!auth.userId && !auth.isPublicRoute) {
|
||||||
|
if (req.nextUrl.pathname.includes("/api")) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
// This is annoying...
|
// This is annoying...
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any
|
||||||
return redirectToSignIn({ returnBackUrl: req.url });
|
return redirectToSignIn({ returnBackUrl: req.url });
|
|
@ -1,4 +1,4 @@
|
||||||
import "./src/env.mjs";
|
import "./env.mjs";
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
@ -15,11 +15,6 @@ const config = {
|
||||||
"img.clerk.com",
|
"img.clerk.com",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
experimental: {
|
|
||||||
serverActions: true,
|
|
||||||
serverMinification: true,
|
|
||||||
swcMinify: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
31
package.json
31
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "sprintpadawan",
|
"name": "sprintpadawan",
|
||||||
"version": "2.3.0",
|
"version": "3.0.0",
|
||||||
"description": "Plan. Sprint. Repeat.",
|
"description": "Plan. Sprint. Repeat.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -10,50 +10,47 @@
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"db:push": "pnpm drizzle-kit push:pg",
|
"db:push": "pnpm drizzle-kit push:pg",
|
||||||
"db:studio": "pnpm drizzle-kit studio"
|
"db:studio": "pnpm drizzle-kit studio",
|
||||||
|
"init:env": "cp .env.example .env"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ably-labs/react-hooks": "^2.1.1",
|
"@clerk/nextjs": "^4.24.1",
|
||||||
"@clerk/nextjs": "^4.23.5",
|
"@clerk/themes": "^1.7.6",
|
||||||
"@clerk/themes": "^1.7.5",
|
|
||||||
"@neondatabase/serverless": "^0.6.0",
|
"@neondatabase/serverless": "^0.6.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@t3-oss/env-nextjs": "^0.6.1",
|
"@t3-oss/env-nextjs": "^0.6.1",
|
||||||
"@unkey/api": "^0.8.0",
|
"@unkey/api": "^0.8.0",
|
||||||
"@upstash/ratelimit": "^0.4.4",
|
"@upstash/ratelimit": "^0.4.4",
|
||||||
"@upstash/redis": "^1.22.0",
|
"@upstash/redis": "^1.22.0",
|
||||||
"autoprefixer": "^10.4.15",
|
"ably": "^1.2.44",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
"csv42": "^5.0.0",
|
"csv42": "^5.0.0",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"drizzle-orm": "^0.28.6",
|
"drizzle-orm": "^0.28.6",
|
||||||
"next": "^13.5.1",
|
"next": "^13.5.2",
|
||||||
"nextjs-cors": "^2.1.2",
|
"nextjs-cors": "^2.1.2",
|
||||||
"postcss": "^8.4.30",
|
"postcss": "^8.4.30",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"superjson": "1.13.1",
|
|
||||||
"svix": "^1.12.0",
|
"svix": "^1.12.0",
|
||||||
"zod": "^3.22.2"
|
"zod": "^3.22.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/eslint": "^8.44.2",
|
"@types/eslint": "^8.44.3",
|
||||||
"@types/json2csv": "^5.0.4",
|
"@types/node": "^20.6.4",
|
||||||
"@types/node": "^20.6.3",
|
|
||||||
"@types/react": "^18.2.22",
|
"@types/react": "^18.2.22",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.2",
|
"@typescript-eslint/eslint-plugin": "^6.7.2",
|
||||||
"@typescript-eslint/parser": "^6.7.2",
|
"@typescript-eslint/parser": "^6.7.2",
|
||||||
"bufferutil": "^4.0.7",
|
"bufferutil": "^4.0.7",
|
||||||
"daisyui": "^3.7.6",
|
"daisyui": "^3.7.7",
|
||||||
"drizzle-kit": "^0.19.13",
|
"drizzle-kit": "^0.19.13",
|
||||||
"encoding": "^0.1.13",
|
"eslint": "^8.50.0",
|
||||||
"eslint": "^8.49.0",
|
"eslint-config-next": "^13.5.2",
|
||||||
"eslint-config-next": "^13.5.1",
|
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"utf-8-validate": "6.0.3",
|
"utf-8-validate": "6.0.3"
|
||||||
"ws": "^8.14.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
474
pnpm-lock.yaml
generated
474
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -1,230 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
|
||||||
import { db } from "../db";
|
|
||||||
import { logs, rooms, votes } from "../schema";
|
|
||||||
import { auth } from "@clerk/nextjs";
|
|
||||||
import { fetchCache, invalidateCache, setCache } from "../redis";
|
|
||||||
import { publishToChannel } from "../ably";
|
|
||||||
import { EventTypes } from "@/utils/types";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new room.
|
|
||||||
*
|
|
||||||
* @param {string} name - The name of the room.
|
|
||||||
* @returns {Promise<boolean>} - A promise that resolves to a boolean indicating the success of room creation.
|
|
||||||
*/
|
|
||||||
export const createRoom = async (name: string) => {
|
|
||||||
const { userId } = auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const room = await db
|
|
||||||
.insert(rooms)
|
|
||||||
.values({
|
|
||||||
id: `room_${createId()}`,
|
|
||||||
userId,
|
|
||||||
roomName: name,
|
|
||||||
storyName: "First Story!",
|
|
||||||
scale: "0.5,1,2,3,5,8",
|
|
||||||
visible: false,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const success = room.length > 0;
|
|
||||||
if (room) {
|
|
||||||
await invalidateCache(`kv_roomlist_${userId}`);
|
|
||||||
|
|
||||||
await publishToChannel(
|
|
||||||
`${userId}`,
|
|
||||||
EventTypes.ROOM_LIST_UPDATE,
|
|
||||||
JSON.stringify(room)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a room with the specified ID.
|
|
||||||
*
|
|
||||||
* @param {string} id - The ID of the room to delete.
|
|
||||||
* @returns {Promise<boolean>} - A promise that resolves to a boolean indicating the success of room deletion.
|
|
||||||
*/
|
|
||||||
export const deleteRoom = async (id: string) => {
|
|
||||||
const { userId } = auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedRoom = await db
|
|
||||||
.delete(rooms)
|
|
||||||
.where(eq(rooms.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const success = deletedRoom.length > 0;
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
await publishToChannel(
|
|
||||||
`${userId}`,
|
|
||||||
EventTypes.ROOM_LIST_UPDATE,
|
|
||||||
JSON.stringify(deletedRoom)
|
|
||||||
);
|
|
||||||
|
|
||||||
await publishToChannel(
|
|
||||||
`${id}`,
|
|
||||||
EventTypes.ROOM_UPDATE,
|
|
||||||
JSON.stringify(deletedRoom)
|
|
||||||
);
|
|
||||||
|
|
||||||
await invalidateCache(`kv_roomlist_${userId}`);
|
|
||||||
|
|
||||||
await publishToChannel(
|
|
||||||
`${userId}`,
|
|
||||||
EventTypes.ROOM_LIST_UPDATE,
|
|
||||||
JSON.stringify(deletedRoom)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return success;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a room with the specified ID.
|
|
||||||
*
|
|
||||||
* @param {string} id - The ID of the room to retrieve.
|
|
||||||
* @returns {Promise<object|null>} - A promise that resolves to the retrieved room object or null if not found.
|
|
||||||
*/
|
|
||||||
export const getRoom = async (id: string) => {
|
|
||||||
const { userId } = auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomFromDb = await db.query.rooms.findFirst({
|
|
||||||
where: eq(rooms.id, id),
|
|
||||||
with: {
|
|
||||||
logs: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return roomFromDb || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a list of rooms.
|
|
||||||
*
|
|
||||||
* @returns {Promise<object[]|null>} - A promise that resolves to an array of room objects or null if not found.
|
|
||||||
*/
|
|
||||||
export const getRooms = async () => {
|
|
||||||
const { userId } = auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedResult = await fetchCache<
|
|
||||||
{
|
|
||||||
id: string;
|
|
||||||
createdAt: Date;
|
|
||||||
roomName: string;
|
|
||||||
}[]
|
|
||||||
>(`kv_roomlist_${userId}`);
|
|
||||||
|
|
||||||
if (cachedResult) {
|
|
||||||
return cachedResult;
|
|
||||||
} else {
|
|
||||||
const roomList = await db.query.rooms.findMany({
|
|
||||||
where: eq(rooms.userId, userId),
|
|
||||||
});
|
|
||||||
|
|
||||||
await setCache(`kv_roomlist_${userId}`, roomList);
|
|
||||||
|
|
||||||
return roomList;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the properties of a room.
|
|
||||||
*
|
|
||||||
* @param {string} name - The new name of the room.
|
|
||||||
* @param {boolean} visible - Indicates if the room is visible.
|
|
||||||
* @param {string} scale - The scale values for the room.
|
|
||||||
* @param {string} roomId - The ID of the room to update.
|
|
||||||
* @param {boolean} reset - Indicates whether to reset room data.
|
|
||||||
* @param {boolean} log - Indicates whether to log changes.
|
|
||||||
* @returns {Promise<boolean>} - A promise that resolves to a boolean indicating the success of the room update.
|
|
||||||
*/
|
|
||||||
export const setRoom = async (
|
|
||||||
name: string,
|
|
||||||
visible: boolean,
|
|
||||||
scale: string,
|
|
||||||
roomId: string,
|
|
||||||
reset: boolean,
|
|
||||||
log: boolean
|
|
||||||
) => {
|
|
||||||
const { userId } = auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reset) {
|
|
||||||
if (log) {
|
|
||||||
const oldRoom = await db.query.rooms.findFirst({
|
|
||||||
where: eq(rooms.id, roomId),
|
|
||||||
with: {
|
|
||||||
votes: true,
|
|
||||||
logs: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
oldRoom &&
|
|
||||||
(await db.insert(logs).values({
|
|
||||||
id: `log_${createId()}`,
|
|
||||||
userId: userId,
|
|
||||||
roomId: roomId,
|
|
||||||
scale: oldRoom.scale,
|
|
||||||
votes: oldRoom.votes.map((vote) => {
|
|
||||||
return {
|
|
||||||
name: vote.userId,
|
|
||||||
value: vote.value,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
roomName: oldRoom.roomName,
|
|
||||||
storyName: oldRoom.storyName,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(votes).where(eq(votes.roomId, roomId));
|
|
||||||
|
|
||||||
await invalidateCache(`kv_votes_${roomId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newRoom = await db
|
|
||||||
.update(rooms)
|
|
||||||
.set({
|
|
||||||
storyName: name,
|
|
||||||
visible: visible,
|
|
||||||
scale: [...new Set(scale.split(","))]
|
|
||||||
.filter((item) => item !== "")
|
|
||||||
.toString(),
|
|
||||||
})
|
|
||||||
.where(eq(rooms.id, roomId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const success = newRoom.length > 0;
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
await publishToChannel(
|
|
||||||
`${roomId}`,
|
|
||||||
EventTypes.ROOM_UPDATE,
|
|
||||||
JSON.stringify(newRoom)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return success;
|
|
||||||
};
|
|
|
@ -1,94 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
|
||||||
import { db } from "../db";
|
|
||||||
import { votes } from "../schema";
|
|
||||||
import { auth } from "@clerk/nextjs";
|
|
||||||
import { fetchCache, invalidateCache, setCache } from "../redis";
|
|
||||||
import { publishToChannel } from "../ably";
|
|
||||||
import { EventTypes } from "@/utils/types";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves votes for a specific room.
|
|
||||||
*
|
|
||||||
* @param {string} roomId - The ID of the room for which votes are retrieved.
|
|
||||||
* @returns {Promise<object[]|null>} - A promise that resolves to an array of vote objects or null if not found.
|
|
||||||
*/
|
|
||||||
export const getVotes = async (roomId: string) => {
|
|
||||||
const { userId } = auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedResult = await fetchCache<
|
|
||||||
{
|
|
||||||
id: string;
|
|
||||||
value: string;
|
|
||||||
created_at: Date;
|
|
||||||
userId: string;
|
|
||||||
roomId: string;
|
|
||||||
}[]
|
|
||||||
>(`kv_votes_${roomId}`);
|
|
||||||
|
|
||||||
if (cachedResult) {
|
|
||||||
return cachedResult;
|
|
||||||
} else {
|
|
||||||
const votesByRoomId = await db.query.votes.findMany({
|
|
||||||
where: eq(votes.roomId, roomId),
|
|
||||||
});
|
|
||||||
|
|
||||||
await setCache(`kv_votes_${roomId}`, votesByRoomId);
|
|
||||||
|
|
||||||
return votesByRoomId;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets a vote value for a room.
|
|
||||||
*
|
|
||||||
* @param {string} value - The value of the vote.
|
|
||||||
* @param {string} roomId - The ID of the room for which the vote is being set.
|
|
||||||
* @returns {Promise<boolean>} - A promise that resolves to a boolean indicating the success of the vote setting.
|
|
||||||
*/
|
|
||||||
export const setVote = async (value: string, roomId: string) => {
|
|
||||||
const { userId } = auth();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const upsertResult = await db
|
|
||||||
.insert(votes)
|
|
||||||
.values({
|
|
||||||
id: `vote_${createId()}`,
|
|
||||||
value: value,
|
|
||||||
userId: userId,
|
|
||||||
roomId: roomId,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [votes.userId, votes.roomId],
|
|
||||||
set: {
|
|
||||||
value: value,
|
|
||||||
userId: userId,
|
|
||||||
roomId: roomId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const success = upsertResult.rowCount > 0;
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
await invalidateCache(`kv_votes_${roomId}`);
|
|
||||||
|
|
||||||
await publishToChannel(`${roomId}`, EventTypes.VOTE_UPDATE, value);
|
|
||||||
|
|
||||||
await publishToChannel(
|
|
||||||
`stats`,
|
|
||||||
EventTypes.STATS_UPDATE,
|
|
||||||
JSON.stringify(success)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return success;
|
|
||||||
};
|
|
|
@ -1,17 +0,0 @@
|
||||||
type BetterEnum<T> = T[keyof T];
|
|
||||||
|
|
||||||
export const EventTypes = {
|
|
||||||
ROOM_LIST_UPDATE: "room.list.update",
|
|
||||||
ROOM_UPDATE: "room.update",
|
|
||||||
VOTE_UPDATE: "vote.update",
|
|
||||||
STATS_UPDATE: "stats.update",
|
|
||||||
} as const;
|
|
||||||
export type EventType = BetterEnum<typeof EventTypes>;
|
|
||||||
|
|
||||||
export interface PresenceItem {
|
|
||||||
name: string;
|
|
||||||
image: string;
|
|
||||||
client_id: string;
|
|
||||||
isAdmin: boolean;
|
|
||||||
isVIP: boolean;
|
|
||||||
}
|
|
|
@ -18,7 +18,7 @@
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./app/*"]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|
Loading…
Add table
Reference in a new issue