From b086f719c8d0d5d5f0a4e44be5f94ed7126d612b Mon Sep 17 00:00:00 2001
From: Atridad Lahiji <88056492+atridadl@users.noreply.github.com>
Date: Thu, 20 Apr 2023 04:20:00 -0600
Subject: [PATCH] Old stuff
---
.dockerignore | 6 +
.env.example | 24 +
.eslintrc.cjs | 4 +
.github/workflows/fly.yml | 15 +
.gitignore | 6 +
.npmrc | 1 +
Dockerfile | 49 +
README.md | 38 +
app/components/Footer.tsx | 33 +
app/components/Header.tsx | 59 +
app/components/LoadingIndicator.tsx | 9 +
app/entry.client.tsx | 18 +
app/entry.server.tsx | 137 +
app/root.tsx | 61 +
app/routes/_index.tsx | 40 +
app/routes/api.room.create.tsx | 43 +
app/routes/api.room.delete.$roomId.tsx | 42 +
app/routes/api.room.get.$roomId.tsx | 66 +
app/routes/api.room.get.all.tsx | 44 +
app/routes/api.room.presence.get.$roomId.tsx | 82 +
app/routes/api.room.set.$roomId.tsx | 87 +
app/routes/api.vote.set.$roomId.tsx | 50 +
app/routes/api.votes.get.$roomId.tsx | 55 +
app/routes/api.webhooks.clerk.ts | 79 +
app/routes/dashboard.tsx | 155 +
app/routes/room.$roomId.tsx | 456 +
app/routes/sign-in.$.tsx | 10 +
app/routes/sign-up.$.tsx | 10 +
app/services/db.server.ts | 10 +
app/services/emitter.server.ts | 18 +
app/services/helpers.ts | 47 +
app/services/schema.ts | 95 +
app/services/types.ts | 71 +
app/services/webhookhelpers.server.ts | 44 +
app/tailwind.css | 3 +
drizzle.config.ts | 13 +
fly.toml | 17 +
package.json | 49 +
pnpm-lock.yaml | 8335 ++++++++++++++++++
public/favicon.ico | Bin 0 -> 15086 bytes
public/logo.webp | Bin 0 -> 11680 bytes
remix.config.js | 9 +
remix.env.d.ts | 2 +
tailwind.config.ts | 9 +
tsconfig.json | 22 +
45 files changed, 10423 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 .eslintrc.cjs
create mode 100644 .github/workflows/fly.yml
create mode 100644 .gitignore
create mode 100644 .npmrc
create mode 100644 Dockerfile
create mode 100644 README.md
create mode 100644 app/components/Footer.tsx
create mode 100644 app/components/Header.tsx
create mode 100644 app/components/LoadingIndicator.tsx
create mode 100644 app/entry.client.tsx
create mode 100644 app/entry.server.tsx
create mode 100644 app/root.tsx
create mode 100644 app/routes/_index.tsx
create mode 100644 app/routes/api.room.create.tsx
create mode 100644 app/routes/api.room.delete.$roomId.tsx
create mode 100644 app/routes/api.room.get.$roomId.tsx
create mode 100644 app/routes/api.room.get.all.tsx
create mode 100644 app/routes/api.room.presence.get.$roomId.tsx
create mode 100644 app/routes/api.room.set.$roomId.tsx
create mode 100644 app/routes/api.vote.set.$roomId.tsx
create mode 100644 app/routes/api.votes.get.$roomId.tsx
create mode 100644 app/routes/api.webhooks.clerk.ts
create mode 100644 app/routes/dashboard.tsx
create mode 100644 app/routes/room.$roomId.tsx
create mode 100644 app/routes/sign-in.$.tsx
create mode 100644 app/routes/sign-up.$.tsx
create mode 100644 app/services/db.server.ts
create mode 100644 app/services/emitter.server.ts
create mode 100644 app/services/helpers.ts
create mode 100644 app/services/schema.ts
create mode 100644 app/services/types.ts
create mode 100644 app/services/webhookhelpers.server.ts
create mode 100644 app/tailwind.css
create mode 100644 drizzle.config.ts
create mode 100644 fly.toml
create mode 100644 package.json
create mode 100644 pnpm-lock.yaml
create mode 100644 public/favicon.ico
create mode 100644 public/logo.webp
create mode 100644 remix.config.js
create mode 100644 remix.env.d.ts
create mode 100644 tailwind.config.ts
create mode 100644 tsconfig.json
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3f7bf98
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+
+/.cache
+/build
+/public/build
+.env
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2dbc567
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,24 @@
+#Database
+DATABASE_URL=""
+DATABASE_AUTH_TOKEN=""
+
+# Redis
+REDIS_URL=""
+REDIS_EXPIRY_SECONDS=""
+
+#Auth
+NEXT_PUBLIC_CLERK_SIGN_UP_URL="/sign-up"
+NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in"
+NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
+CLERK_SECRET_KEY=""
+CLERK_WEBHOOK_SIGNING_SECRET=""
+
+# Ably
+ABLY_API_KEY=""
+
+# Unkey
+UNKEY_ROOT_KEY=""
+
+# Misc
+APP_ENV=""
+NEXT_PUBLIC_APP_ENV=""
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 0000000..2061cd2
--- /dev/null
+++ b/.eslintrc.cjs
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
+};
diff --git a/.github/workflows/fly.yml b/.github/workflows/fly.yml
new file mode 100644
index 0000000..3a7e876
--- /dev/null
+++ b/.github/workflows/fly.yml
@@ -0,0 +1,15 @@
+name: Fly Deploy
+on:
+ push:
+ branches:
+ - master
+jobs:
+ deploy:
+ name: Deploy app
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: superfly/flyctl-actions/setup-flyctl@master
+ - run: flyctl deploy --remote-only
+ env:
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3f7bf98
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+
+/.cache
+/build
+/public/build
+.env
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..f87a044
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+auto-install-peers=true
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..5537664
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,49 @@
+# syntax = docker/dockerfile:1
+
+# Adjust NODE_VERSION as desired
+ARG NODE_VERSION=18.14.2
+FROM node:${NODE_VERSION}-slim as base
+
+LABEL fly_launch_runtime="Remix"
+
+# Remix app lives here
+WORKDIR /app
+
+# Set production environment
+ENV NODE_ENV="production"
+
+# Install pnpm
+ARG PNPM_VERSION=8.9.2
+RUN npm install -g pnpm@$PNPM_VERSION
+
+
+# Throw-away build stage to reduce size of final image
+FROM base as build
+
+# Install packages needed to build node modules
+RUN apt-get update -qq && \
+ apt-get install -y build-essential pkg-config python-is-python3
+
+# Install node modules
+COPY --link package.json pnpm-lock.yaml ./
+RUN pnpm install --frozen-lockfile --prod=false
+
+# Copy application code
+COPY --link . .
+
+# Build application
+RUN pnpm run build
+
+# Remove development dependencies
+RUN pnpm prune --prod
+
+
+# Final stage for app image
+FROM base
+
+# Copy built application
+COPY --from=build /app /app
+
+# Start the server by default, this can be overwritten at runtime
+EXPOSE 3000
+CMD [ "pnpm", "run", "start" ]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..da8d02a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# Welcome to Remix!
+
+- [Remix Docs](https://remix.run/docs)
+
+## Development
+
+From your terminal:
+
+```sh
+npm run dev
+```
+
+This starts your app in development mode, rebuilding assets on file changes.
+
+## Deployment
+
+First, build your app for production:
+
+```sh
+npm run build
+```
+
+Then run the app in production mode:
+
+```sh
+npm start
+```
+
+Now you'll need to pick a host to deploy it to.
+
+### DIY
+
+If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
+
+Make sure to deploy the output of `remix build`
+
+- `build/`
+- `public/build/`
diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx
new file mode 100644
index 0000000..fb16572
--- /dev/null
+++ b/app/components/Footer.tsx
@@ -0,0 +1,33 @@
+import { HeartIcon } from "lucide-react";
+import packagejson from "../../package.json";
+
+const Footer = () => {
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/app/components/Header.tsx b/app/components/Header.tsx
new file mode 100644
index 0000000..bd8e46f
--- /dev/null
+++ b/app/components/Header.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { UserButton, useUser } from "@clerk/remix";
+import { Link, useLocation, useNavigate } from "@remix-run/react";
+
+interface NavbarProps {
+ title: string;
+}
+
+const Navbar = ({ title }: NavbarProps) => {
+ const { isSignedIn } = useUser();
+ const { pathname } = useLocation();
+ const navigate = useNavigate();
+
+ const navigationMenu = () => {
+ if (pathname !== "/dashboard" && isSignedIn) {
+ return (
+
+ Dashboard
+
+ );
+ } else if (!isSignedIn) {
+ return (
+
+ );
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default Navbar;
diff --git a/app/components/LoadingIndicator.tsx b/app/components/LoadingIndicator.tsx
new file mode 100644
index 0000000..7b9b00e
--- /dev/null
+++ b/app/components/LoadingIndicator.tsx
@@ -0,0 +1,9 @@
+const LoadingIndicator = () => {
+ return (
+
+
+
+ );
+};
+
+export default LoadingIndicator;
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
new file mode 100644
index 0000000..94d5dc0
--- /dev/null
+++ b/app/entry.client.tsx
@@ -0,0 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` â¨
+ * For more information, see https://remix.run/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+});
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
new file mode 100644
index 0000000..0c7712b
--- /dev/null
+++ b/app/entry.server.tsx
@@ -0,0 +1,137 @@
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` â¨
+ * For more information, see https://remix.run/file-conventions/entry.server
+ */
+
+import { PassThrough } from "node:stream";
+
+import type { AppLoadContext, EntryContext } from "@remix-run/node";
+import { createReadableStreamFromReadable } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+ loadContext: AppLoadContext
+) {
+ return isbot(request.headers.get("user-agent"))
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+}
+
+function handleBotRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ shellRendered = true;
+ const body = new PassThrough();
+ const stream = createReadableStreamFromReadable(body);
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ // Log streaming rendering errors from inside the shell. Don't log
+ // errors encountered during initial shell rendering since they'll
+ // reject and get logged in handleDocumentRequest.
+ if (shellRendered) {
+ console.error(error);
+ }
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ shellRendered = true;
+ const body = new PassThrough();
+ const stream = createReadableStreamFromReadable(body);
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ // Log streaming rendering errors from inside the shell. Don't log
+ // errors encountered during initial shell rendering since they'll
+ // reject and get logged in handleDocumentRequest.
+ if (shellRendered) {
+ console.error(error);
+ }
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/app/root.tsx b/app/root.tsx
new file mode 100644
index 0000000..c1ffd24
--- /dev/null
+++ b/app/root.tsx
@@ -0,0 +1,61 @@
+import { rootAuthLoader } from "@clerk/remix/ssr.server";
+import { ClerkApp, ClerkErrorBoundary, ClerkLoaded } from "@clerk/remix";
+import type {
+ LinksFunction,
+ LoaderFunction,
+ MetaFunction,
+} from "@remix-run/node";
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+} from "@remix-run/react";
+import stylesheet from "~/tailwind.css";
+import Footer from "./components/Footer";
+import Header from "./components/Header";
+
+export const links: LinksFunction = () => [
+ { rel: "stylesheet", href: stylesheet },
+];
+
+export const meta: MetaFunction = () => {
+ return [
+ { title: "Sprint Padawan" },
+ { name: "description", content: "Plan. Sprint. Repeat." },
+ ];
+};
+
+export const loader: LoaderFunction = (args) => rootAuthLoader(args);
+
+export const ErrorBoundary = ClerkErrorBoundary();
+
+function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ClerkApp(App);
diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx
new file mode 100644
index 0000000..13c20f0
--- /dev/null
+++ b/app/routes/_index.tsx
@@ -0,0 +1,40 @@
+export default function Index() {
+ return (
+
+
+ Sprint{" "}
+
+ Padawan
+
+
+
+
+ A{" "}
+
+ scrum poker{" "}
+ {" "}
+ tool that helps{" "}
+
+ agile teams{" "}
+ {" "}
+ plan their sprints in{" "}
+
+ real-time
+
+ .
+
+
+
+
+
Features:
+
+ - đ Real-time votes!
+ - đ Customizable room name and vote scale!
+ - đ CSV Reports for every room!
+ - đ 100% free and open-source... forever!
+
+
+
+
+ );
+}
diff --git a/app/routes/api.room.create.tsx b/app/routes/api.room.create.tsx
new file mode 100644
index 0000000..d26de04
--- /dev/null
+++ b/app/routes/api.room.create.tsx
@@ -0,0 +1,43 @@
+import { getAuth } from "@clerk/remix/ssr.server";
+import { ActionFunctionArgs, json } from "@remix-run/node";
+import { createId } from "@paralleldrive/cuid2";
+import { db } from "~/services/db.server";
+import { emitter } from "~/services/emitter.server";
+import { rooms } from "~/services/schema";
+
+export async function action({ request, params, context }: ActionFunctionArgs) {
+ const { userId } = await getAuth({ context, params, request });
+
+ if (!userId) {
+ return json("Not Signed In!", {
+ status: 403,
+ statusText: "UNAUTHORIZED!",
+ });
+ }
+
+ const data = await request.json();
+
+ const room = await db
+ .insert(rooms)
+ .values({
+ id: `room_${createId()}`,
+ created_at: Date.now().toString(),
+ userId: userId || "",
+ roomName: data.name,
+ storyName: "First Story!",
+ scale: "0.5,1,2,3,5,8",
+ visible: 0,
+ })
+ .returning();
+
+ const success = room.length > 0;
+
+ if (success) {
+ emitter.emit("roomlist");
+
+ return json(room, {
+ status: 200,
+ statusText: "SUCCESS",
+ });
+ }
+}
diff --git a/app/routes/api.room.delete.$roomId.tsx b/app/routes/api.room.delete.$roomId.tsx
new file mode 100644
index 0000000..76a18e1
--- /dev/null
+++ b/app/routes/api.room.delete.$roomId.tsx
@@ -0,0 +1,42 @@
+import { getAuth } from "@clerk/remix/ssr.server";
+import { ActionFunctionArgs, json } from "@remix-run/node";
+import { eq } from "drizzle-orm";
+import { db } from "~/services/db.server";
+import { emitter } from "~/services/emitter.server";
+import { rooms } from "~/services/schema";
+
+export async function action({ request, params, context }: ActionFunctionArgs) {
+ const { userId } = await getAuth({ context, params, request });
+
+ if (!userId) {
+ return json("Not Signed In!", {
+ status: 403,
+ statusText: "UNAUTHORIZED!",
+ });
+ }
+
+ const roomId = params.roomId;
+
+ if (!roomId) {
+ return json("RoomId Missing!", {
+ status: 400,
+ statusText: "BAD REQUEST!",
+ });
+ }
+
+ const deletedRoom = await db
+ .delete(rooms)
+ .where(eq(rooms.id, roomId))
+ .returning();
+
+ const success = deletedRoom.length > 0;
+
+ if (success) {
+ emitter.emit("roomlist");
+
+ return json(deletedRoom, {
+ status: 200,
+ statusText: "SUCCESS",
+ });
+ }
+}
diff --git a/app/routes/api.room.get.$roomId.tsx b/app/routes/api.room.get.$roomId.tsx
new file mode 100644
index 0000000..718411b
--- /dev/null
+++ b/app/routes/api.room.get.$roomId.tsx
@@ -0,0 +1,66 @@
+import { getAuth } from "@clerk/remix/ssr.server";
+import { LoaderFunctionArgs, json } from "@remix-run/node";
+import { eq } from "drizzle-orm";
+import { eventStream } from "remix-utils/sse/server";
+import { db } from "~/services/db.server";
+import { emitter } from "~/services/emitter.server";
+import { rooms } from "~/services/schema";
+
+// Get Room List
+export async function loader({ context, params, request }: LoaderFunctionArgs) {
+ const { userId } = await getAuth({ context, params, request });
+
+ const roomId = params.roomId;
+
+ if (!roomId) {
+ return json("RoomId Missing!", {
+ status: 400,
+ statusText: "BAD REQUEST!",
+ });
+ }
+
+ if (!userId) {
+ return json("Not Signed In!", {
+ status: 403,
+ statusText: "UNAUTHORIZED!",
+ });
+ }
+
+ return eventStream(request.signal, function setup(send) {
+ async function handler() {
+ const roomFromDb = await db.query.rooms.findFirst({
+ where: eq(rooms.id, roomId || ""),
+ with: {
+ logs: true,
+ },
+ });
+ send({
+ event: `room-${roomId}`,
+ data: JSON.stringify(roomFromDb),
+ });
+ }
+
+ // Initial fetch
+ console.log("HI");
+ db.query.rooms
+ .findFirst({
+ where: eq(rooms.id, roomId || ""),
+ with: {
+ logs: true,
+ },
+ })
+ .then((roomFromDb) => {
+ console.log(roomId);
+ return send({
+ event: `room-${roomId}`,
+ data: JSON.stringify(roomFromDb),
+ });
+ });
+
+ emitter.on("room", handler);
+
+ return function clear() {
+ emitter.off("room", handler);
+ };
+ });
+}
diff --git a/app/routes/api.room.get.all.tsx b/app/routes/api.room.get.all.tsx
new file mode 100644
index 0000000..2941158
--- /dev/null
+++ b/app/routes/api.room.get.all.tsx
@@ -0,0 +1,44 @@
+import { getAuth } from "@clerk/remix/ssr.server";
+import { LoaderFunctionArgs, json } from "@remix-run/node";
+import { eq } from "drizzle-orm";
+import { eventStream } from "remix-utils/sse/server";
+import { db } from "~/services/db.server";
+import { emitter } from "~/services/emitter.server";
+import { rooms } from "~/services/schema";
+
+// Get Room List
+export async function loader({ context, params, request }: LoaderFunctionArgs) {
+ const { userId } = await getAuth({ context, params, request });
+
+ if (!userId) {
+ return json("Not Signed In!", {
+ status: 403,
+ statusText: "UNAUTHORIZED!",
+ });
+ }
+
+ return eventStream(request.signal, function setup(send) {
+ async function handler() {
+ const roomList = await db.query.rooms.findMany({
+ where: eq(rooms.userId, userId || ""),
+ });
+
+ send({ event: userId!, data: JSON.stringify(roomList) });
+ }
+
+ // Initial fetch
+ db.query.rooms
+ .findMany({
+ where: eq(rooms.userId, userId || ""),
+ })
+ .then((roomList) => {
+ send({ event: userId!, data: JSON.stringify(roomList) });
+ });
+
+ emitter.on("roomlist", handler);
+
+ return function clear() {
+ emitter.off("roomlist", handler);
+ };
+ });
+}
diff --git a/app/routes/api.room.presence.get.$roomId.tsx b/app/routes/api.room.presence.get.$roomId.tsx
new file mode 100644
index 0000000..12fb2d1
--- /dev/null
+++ b/app/routes/api.room.presence.get.$roomId.tsx
@@ -0,0 +1,82 @@
+import { getAuth } from "@clerk/remix/ssr.server";
+import { LoaderFunctionArgs, json } from "@remix-run/node";
+import { and, eq } from "drizzle-orm";
+import { eventStream } from "remix-utils/sse/server";
+import { db } from "~/services/db.server";
+import { emitter } from "~/services/emitter.server";
+import { presence } from "~/services/schema";
+import { createId } from "@paralleldrive/cuid2";
+
+export async function loader({ context, params, request }: LoaderFunctionArgs) {
+ const { userId, sessionClaims } = await getAuth({ context, params, request });
+
+ const roomId = params.roomId;
+
+ if (!roomId) {
+ return json("RoomId Missing!", {
+ status: 400,
+ statusText: "BAD REQUEST!",
+ });
+ }
+
+ if (!userId) {
+ return json("Not Signed In!", {
+ status: 403,
+ statusText: "UNAUTHORIZED!",
+ });
+ }
+
+ const name = sessionClaims.name as string;
+ const image = sessionClaims.image as string;
+
+ return eventStream(request.signal, function setup(send) {
+ async function handler() {
+ const presenceData = await db.query.presence.findMany({
+ where: and(eq(presence.roomId, roomId || "")),
+ });
+
+ send({
+ event: `${userId}-${params.roomId}`,
+ data: JSON.stringify(presenceData),
+ });
+ }
+
+ db.insert(presence)
+ .values({
+ id: `presence_${createId()}`,
+ roomId: roomId || "",
+ userFullName: name,
+ userId: userId,
+ userImageUrl: image,
+ isAdmin: 0,
+ isVIP: 0,
+ })
+ .onConflictDoUpdate({
+ target: [presence.userId, presence.roomId],
+ set: {
+ roomId: roomId || "",
+ userFullName: name,
+ userId: userId,
+ userImageUrl: image,
+ isAdmin: 0,
+ isVIP: 0,
+ },
+ })
+ .then(async () => {
+ emitter.emit("presence");
+ });
+
+ // Initial fetch
+ emitter.on("presence", handler);
+
+ return function clear() {
+ db.delete(presence)
+ .where(and(eq(presence.roomId, roomId), eq(presence.userId, userId)))
+ .returning()
+ .then(async () => {
+ emitter.emit("presence");
+ });
+ emitter.off("presence", handler);
+ };
+ });
+}
diff --git a/app/routes/api.room.set.$roomId.tsx b/app/routes/api.room.set.$roomId.tsx
new file mode 100644
index 0000000..ea673a7
--- /dev/null
+++ b/app/routes/api.room.set.$roomId.tsx
@@ -0,0 +1,87 @@
+import { getAuth } from "@clerk/remix/ssr.server";
+import { ActionFunctionArgs, json } from "@remix-run/node";
+import { createId } from "@paralleldrive/cuid2";
+import { db } from "~/services/db.server";
+import { emitter } from "~/services/emitter.server";
+import { logs, rooms, votes } from "~/services/schema";
+import { eq } from "drizzle-orm";
+
+export async function action({ request, params, context }: ActionFunctionArgs) {
+ const { userId } = await getAuth({ context, params, request });
+
+ if (!userId) {
+ return json("Not Signed In!", {
+ status: 403,
+ statusText: "UNAUTHORIZED!",
+ });
+ }
+
+ const data = await request.json();
+ const roomId = params.roomId;
+
+ if (data.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()}`,
+ created_at: Date.now().toString(),
+ userId: userId || "",
+ roomId: roomId || "",
+ scale: oldRoom.scale,
+ votes: JSON.stringify(
+ oldRoom.votes.map((vote) => {
+ return {
+ name: vote.userId,
+ value: vote.value,
+ };
+ })
+ ),
+ roomName: oldRoom.roomName,
+ storyName: oldRoom.storyName,
+ }));
+ }
+
+ if (data.reset) {
+ await db.delete(votes).where(eq(votes.roomId, params.roomId || ""));
+ }
+
+ const newRoom = data.reset
+ ? await db
+ .update(rooms)
+ .set({
+ storyName: data.name,
+ visible: data.visible,
+ scale: [...new Set(data.scale.split(","))]
+ .filter((item) => item !== "")
+ .toString(),
+ })
+ .where(eq(rooms.id, params.roomId || ""))
+ .returning()
+ : await db
+ .update(rooms)
+ .set({
+ visible: data.visible,
+ })
+ .where(eq(rooms.id, params.roomId || ""))
+ .returning();
+
+ const success = newRoom.length > 0;
+
+ if (success) {
+ console.log(success);
+ emitter.emit("room");
+ emitter.emit("votes");
+
+ return json(newRoom, {
+ status: 200,
+ statusText: "SUCCESS",
+ });
+ }
+}
diff --git a/app/routes/api.vote.set.$roomId.tsx b/app/routes/api.vote.set.$roomId.tsx
new file mode 100644
index 0000000..4be7231
--- /dev/null
+++ b/app/routes/api.vote.set.$roomId.tsx
@@ -0,0 +1,50 @@
+import { getAuth } from "@clerk/remix/ssr.server";
+import { ActionFunctionArgs, json } from "@remix-run/node";
+import { createId } from "@paralleldrive/cuid2";
+import { db } from "~/services/db.server";
+import { emitter } from "~/services/emitter.server";
+import { votes } from "~/services/schema";
+
+export async function action({ request, params, context }: ActionFunctionArgs) {
+ const { userId } = await getAuth({ context, params, request });
+
+ if (!userId) {
+ return json("Not Signed In!", {
+ status: 403,
+ statusText: "UNAUTHORIZED!",
+ });
+ }
+
+ const data = await request.json();
+ const roomId = params.roomId;
+
+ const upsertResult = await db
+ .insert(votes)
+ .values({
+ id: `vote_${createId()}`,
+ created_at: Date.now().toString(),
+ value: data.value,
+ userId: userId || "",
+ roomId: roomId || "",
+ })
+ .onConflictDoUpdate({
+ target: [votes.userId, votes.roomId],
+ set: {
+ created_at: Date.now().toString(),
+ value: data.value,
+ userId: userId || "",
+ roomId: roomId,
+ },
+ });
+
+ const success = upsertResult.rowsAffected > 0;
+
+ if (success) {
+ emitter.emit("votes");
+
+ return json(upsertResult, {
+ status: 200,
+ statusText: "SUCCESS",
+ });
+ }
+}
diff --git a/app/routes/api.votes.get.$roomId.tsx b/app/routes/api.votes.get.$roomId.tsx
new file mode 100644
index 0000000..30be487
--- /dev/null
+++ b/app/routes/api.votes.get.$roomId.tsx
@@ -0,0 +1,55 @@
+import { getAuth } from "@clerk/remix/ssr.server";
+import { LoaderFunctionArgs, json } from "@remix-run/node";
+import { eq } from "drizzle-orm";
+import { eventStream } from "remix-utils/sse/server";
+import { db } from "~/services/db.server";
+import { emitter } from "~/services/emitter.server";
+import { votes } from "~/services/schema";
+
+// Get Room List
+export async function loader({ context, params, request }: LoaderFunctionArgs) {
+ const { userId } = await getAuth({ context, params, request });
+
+ const roomId = params.roomId;
+
+ if (!roomId) {
+ return json("RoomId Missing!", {
+ status: 400,
+ statusText: "BAD REQUEST!",
+ });
+ }
+
+ if (!userId) {
+ return json("Not Signed In!", {
+ status: 403,
+ statusText: "UNAUTHORIZED!",
+ });
+ }
+
+ return eventStream(request.signal, function setup(send) {
+ async function handler() {
+ const votesByRoomId = await db.query.votes.findMany({
+ where: eq(votes.roomId, roomId || ""),
+ });
+ send({ event: `votes-${roomId}`, data: JSON.stringify(votesByRoomId) });
+ }
+
+ // Initial fetch
+ db.query.votes
+ .findMany({
+ where: eq(votes.roomId, roomId || ""),
+ })
+ .then((votesByRoomId) => {
+ return send({
+ event: `votes-${roomId}`,
+ data: JSON.stringify(votesByRoomId),
+ });
+ });
+
+ emitter.on("votes", handler);
+
+ return function clear() {
+ emitter.off("votes", handler);
+ };
+ });
+}
diff --git a/app/routes/api.webhooks.clerk.ts b/app/routes/api.webhooks.clerk.ts
new file mode 100644
index 0000000..1f4b4b1
--- /dev/null
+++ b/app/routes/api.webhooks.clerk.ts
@@ -0,0 +1,79 @@
+import { ActionFunctionArgs, json } from "@remix-run/node";
+import { Webhook } from "svix";
+import { WebhookEvent } from "@clerk/remix/api.server";
+import {
+ onUserCreatedHandler,
+ onUserDeletedHandler,
+} from "~/services/webhookhelpers.server";
+
+export async function action({ request, params, context }: ActionFunctionArgs) {
+ // Get the headers
+ const headerPayload = request.headers;
+ const svix_id = headerPayload.get("svix-id");
+ const svix_timestamp = headerPayload.get("svix-timestamp");
+ const svix_signature = headerPayload.get("svix-signature");
+
+ // If there are no headers, error out
+ if (!svix_id || !svix_timestamp || !svix_signature) {
+ return new Response("Error occured -- no svix headers", {
+ status: 400,
+ });
+ }
+
+ // Get the body
+ const body = JSON.stringify(await request.json());
+
+ // Create a new SVIX instance with your secret.
+ const wh = new Webhook(process.env.CLERK_WEBHOOK_SIGNING_SECRET!);
+
+ let evt: WebhookEvent;
+
+ // Verify the payload with the headers
+ try {
+ evt = wh.verify(body, {
+ "svix-id": svix_id,
+ "svix-timestamp": svix_timestamp,
+ "svix-signature": svix_signature,
+ }) as WebhookEvent;
+ } catch (err) {
+ console.error("Error verifying webhook:", err);
+ return new Response("Error occured", {
+ status: 400,
+ });
+ }
+
+ // Get the ID and type
+ const { id } = evt.data;
+ const eventType = evt.type;
+ let success = false;
+
+ switch (eventType) {
+ case "user.created":
+ success = await onUserCreatedHandler(id);
+ if (success) {
+ return json(
+ { result: "USER CREATED" },
+ { status: 200, statusText: "USER CREATED" }
+ );
+ } else {
+ return json(
+ { result: "USER WITH THIS ID NOT FOUND" },
+ { status: 404, statusText: "USER WITH THIS ID NOT FOUND" }
+ );
+ }
+
+ case "user.deleted":
+ success = await onUserDeletedHandler(id);
+
+ return json(
+ { result: "USER DELETED" },
+ { status: 200, statusText: "USER DELETED" }
+ );
+
+ default:
+ return json(
+ { result: "INVALID WEBHOOK EVENT TYPE" },
+ { status: 400, statusText: "INVALID WEBHOOK EVENT TYPE" }
+ );
+ }
+}
diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx
new file mode 100644
index 0000000..310f8fe
--- /dev/null
+++ b/app/routes/dashboard.tsx
@@ -0,0 +1,155 @@
+import { getAuth } from "@clerk/remix/ssr.server";
+import { LoaderFunction, redirect } from "@remix-run/node";
+import { Link } from "@remix-run/react";
+import { LogInIcon, TrashIcon } from "lucide-react";
+import { useState } from "react";
+import LoadingIndicator from "~/components/LoadingIndicator";
+import { useEventSource } from "remix-utils/sse/react";
+import { useAuth } from "@clerk/remix";
+
+export const loader: LoaderFunction = async (args) => {
+ const { userId } = await getAuth(args);
+
+ if (!userId) {
+ return redirect("/sign-in");
+ }
+ return {};
+};
+
+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 default function Dashboard() {
+ const { userId } = useAuth();
+ let roomsFromDb = useEventSource("/api/room/get/all", { event: userId! });
+
+ let roomsFromDbParsed = JSON.parse(roomsFromDb!) as RoomsResponse;
+
+ const [roomName, setRoomName] = useState("");
+
+ const createRoomHandler = async () => {
+ await fetch("/api/room/create", {
+ cache: "no-cache",
+ method: "POST",
+ body: JSON.stringify({ name: roomName }),
+ });
+
+ setRoomName("");
+ (document.querySelector("#roomNameInput") as HTMLInputElement).value = "";
+ (document.querySelector("#new-room-modal") as HTMLInputElement).checked =
+ false;
+ };
+
+ const deleteRoomHandler = async (roomId: string) => {
+ await fetch(`/api/room/delete/${roomId}`, {
+ cache: "no-cache",
+ method: "DELETE",
+ });
+ };
+
+ return (
+
+ {/* Modal for Adding Rooms */}
+
+
+
+
+
+
Create a new room!
+
+
+
+ {
+ setRoomName(event.target.value);
+ }}
+ />
+
+
+
+ {roomName.length > 0 && (
+
+ )}
+
+
+
+
+ {roomsFromDbParsed && roomsFromDbParsed.length > 0 && (
+
+
+ {/* head */}
+
+
+ Room Name |
+ Actions |
+
+
+
+ {roomsFromDbParsed?.map((room) => {
+ return (
+
+
+ {room.roomName}
+ |
+
+
+
+
+
+
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+
+ {!roomsFromDbParsed &&
}
+
+ );
+}
diff --git a/app/routes/room.$roomId.tsx b/app/routes/room.$roomId.tsx
new file mode 100644
index 0000000..16db1a7
--- /dev/null
+++ b/app/routes/room.$roomId.tsx
@@ -0,0 +1,456 @@
+import { getAuth } from "@clerk/remix/ssr.server";
+import { LoaderFunction, redirect } from "@remix-run/node";
+import { Link, useParams } from "@remix-run/react";
+import {
+ CheckCircleIcon,
+ CopyIcon,
+ CrownIcon,
+ DownloadIcon,
+ EyeIcon,
+ EyeOffIcon,
+ HourglassIcon,
+ RefreshCwIcon,
+ SaveIcon,
+ ShieldIcon,
+ StarIcon,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import LoadingIndicator from "~/components/LoadingIndicator";
+import { useEventSource } from "remix-utils/sse/react";
+import { PresenceItem, RoomResponse, VoteResponse } from "~/services/types";
+import { isAdmin, jsonToCsv } from "~/services/helpers";
+import { useUser } from "@clerk/remix";
+
+export const loader: LoaderFunction = async (args) => {
+ const { userId } = await getAuth(args);
+
+ if (!userId) {
+ return redirect("/sign-in");
+ }
+ return {};
+};
+
+export default function Room() {
+ const { user } = useUser();
+ const params = useParams();
+ const roomId = params.roomId;
+
+ let roomFromDb = useEventSource(`/api/room/get/${roomId}`, {
+ event: `room-${params.roomId}`,
+ });
+
+ let votesFromDb = useEventSource(`/api/votes/get/${roomId}`, {
+ event: `votes-${params.roomId}`,
+ });
+
+ let presenceData = useEventSource(`/api/room/presence/get/${roomId}`, {
+ event: `${user?.id}-${params.roomId}`,
+ });
+
+ let roomFromDbParsed = JSON.parse(roomFromDb!) as RoomResponse | undefined;
+ let votesFromDbParsed = JSON.parse(votesFromDb!) as VoteResponse | undefined;
+ let presenceDateParsed = JSON.parse(presenceData!) as
+ | PresenceItem[]
+ | undefined;
+
+ const [storyNameText, setStoryNameText] = useState("");
+ const [roomScale, setRoomScale] = useState("");
+
+ const [copied, setCopied] = useState(false);
+
+ // Handlers
+ // =================================
+ async function getRoomHandler() {
+ const response = await fetch(`/api/internal/room/${roomId}`, {
+ cache: "no-cache",
+ method: "GET",
+ });
+
+ return (await response.json()) as RoomResponse;
+ }
+
+ async function getVotesHandler() {
+ const dbVotesResponse = await fetch(`/api/internal/room/${roomId}/votes`, {
+ cache: "no-cache",
+ method: "GET",
+ });
+ const dbVotes = (await dbVotesResponse.json()) as VoteResponse;
+ return dbVotes;
+ }
+
+ async function setVoteHandler(value: string) {
+ if (roomFromDb) {
+ await fetch(`/api/vote/set/${roomId}`, {
+ cache: "no-cache",
+ method: "PUT",
+ body: JSON.stringify({
+ value,
+ }),
+ });
+ }
+ }
+
+ async function setRoomHandler(data: {
+ visible: boolean;
+ reset: boolean | undefined;
+ log: boolean | undefined;
+ }) {
+ if (roomFromDb) {
+ await fetch(`/api/room/set/${roomId}`, {
+ cache: "no-cache",
+ method: "PUT",
+ body: JSON.stringify({
+ name: storyNameText,
+ visible: data.visible,
+ scale: roomScale,
+ reset: data.reset ? data.reset : false,
+ log: data.log ? data.log : false,
+ }),
+ });
+ }
+ }
+
+ // Helpers
+ // =================================
+ const getVoteForCurrentUser = () => {
+ if (roomFromDb) {
+ return (
+ votesFromDbParsed &&
+ votesFromDbParsed.find((vote) => vote.userId === user?.id)
+ );
+ } else {
+ return null;
+ }
+ };
+
+ const downloadLogs = () => {
+ if (roomFromDb && votesFromDb) {
+ const jsonObject = roomFromDbParsed?.logs
+ .map((item) => {
+ return {
+ id: item.id,
+ created_at: item.created_at,
+ userId: item.userId,
+ roomId: item.roomId,
+ roomName: item.roomName,
+ storyName: item.storyName,
+ scale: item.scale,
+ votes: item.votes,
+ };
+ })
+ .concat({
+ id: "LATEST",
+ created_at: new Date(),
+ userId: roomFromDbParsed.userId,
+ roomId: roomFromDbParsed.id,
+ roomName: roomFromDbParsed.roomName,
+ storyName: storyNameText,
+ scale: roomScale,
+ votes: votesFromDbParsed?.map((vote) => {
+ return {
+ value: vote.value,
+ };
+ }),
+ });
+
+ jsonToCsv(jsonObject!, `sp_${roomId}.csv`);
+ }
+ };
+
+ const copyRoomURLHandler = () => {
+ navigator.clipboard
+ .writeText(window.location.href)
+ .then(() => {
+ console.log(`Copied Room Link to Clipboard!`);
+ setCopied(true);
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ })
+ .catch(() => {
+ console.log(`Error Copying Room Link to Clipboard!`);
+ });
+ };
+
+ const voteString = (
+ visible: boolean,
+ votes: typeof votesFromDbParsed,
+ presenceItem: PresenceItem
+ ) => {
+ const matchedVote = votes?.find(
+ (vote) => vote.userId === presenceItem.userId
+ );
+
+ if (visible) {
+ if (!!matchedVote) {
+ return {matchedVote.value}
;
+ } else {
+ return ;
+ }
+ } else if (!!matchedVote) {
+ return ;
+ } else {
+ return ;
+ }
+ };
+
+ // Hooks
+ // =================================
+ useEffect(() => {
+ if (roomFromDb) {
+ setStoryNameText(roomFromDbParsed?.storyName || "");
+ setRoomScale(roomFromDbParsed?.scale || "ERROR");
+ }
+ }, [roomFromDb]);
+
+ // UI
+ // =================================
+ // Room is loading
+ if (!roomFromDbParsed) {
+ return ;
+ // Room has been loaded
+ } else {
+ return roomFromDb ? (
+
+
{roomFromDbParsed.roomName}
+
+
ID:
+
{roomFromDbParsed.id}
+
+
+
+
+ {roomFromDb && (
+
+
+
+ Story: {roomFromDbParsed.storyName}
+
+
+
+
+
+ {roomFromDbParsed.scale?.split(",").map((scaleItem, index) => {
+ return (
+
+ );
+ })}
+
+
+
+ )}
+
+ {!!roomFromDbParsed &&
+ (roomFromDbParsed.userId === user?.id ||
+ isAdmin(user?.publicMetadata)) && (
+ <>
+
+
+
Room Settings
+
+
+
+
{
+ setRoomScale(event.target.value);
+ }}
+ />
+
+
+
+
{
+ setStoryNameText(event.target.value);
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+ {votesFromDb &&
+ (roomFromDbParsed?.logs.length > 0 ||
+ votesFromDb.length > 0) && (
+
+
+
+ )}
+
+
+
+ >
+ )}
+
+ ) : (
+
+ 4ī¸âŖ0ī¸âŖ4ī¸âŖ
+
+ Oops! This room does not appear to exist, or may have been deleted! đĸ
+
+
+ Back to Home
+
+
+ );
+ }
+}
diff --git a/app/routes/sign-in.$.tsx b/app/routes/sign-in.$.tsx
new file mode 100644
index 0000000..0421a36
--- /dev/null
+++ b/app/routes/sign-in.$.tsx
@@ -0,0 +1,10 @@
+import { SignIn } from "@clerk/remix";
+
+export default function SignInPage() {
+ return (
+
+
Sign In route
+
+
+ );
+}
diff --git a/app/routes/sign-up.$.tsx b/app/routes/sign-up.$.tsx
new file mode 100644
index 0000000..9a9d777
--- /dev/null
+++ b/app/routes/sign-up.$.tsx
@@ -0,0 +1,10 @@
+import { SignUp } from "@clerk/remix";
+
+export default function SignUpPage() {
+ return (
+
+
Sign Up route
+
+
+ );
+}
diff --git a/app/services/db.server.ts b/app/services/db.server.ts
new file mode 100644
index 0000000..5f38aae
--- /dev/null
+++ b/app/services/db.server.ts
@@ -0,0 +1,10 @@
+import { drizzle } from "drizzle-orm/libsql";
+import { createClient } from "@libsql/client";
+import * as schema from "./schema";
+
+const client = createClient({
+ url: process.env.DATABASE_URL!,
+ authToken: process.env.DATABASE_AUTH_TOKEN!,
+});
+
+export const db = drizzle(client, { schema });
diff --git a/app/services/emitter.server.ts b/app/services/emitter.server.ts
new file mode 100644
index 0000000..27baefc
--- /dev/null
+++ b/app/services/emitter.server.ts
@@ -0,0 +1,18 @@
+import { EventEmitter } from "events";
+
+let emitter: EventEmitter;
+
+declare global {
+ var __emitter: EventEmitter | undefined;
+}
+
+if (process.env.NODE_ENV === "production") {
+ emitter = new EventEmitter();
+} else {
+ if (!global.__emitter) {
+ global.__emitter = new EventEmitter();
+ }
+ emitter = global.__emitter;
+}
+
+export { emitter };
diff --git a/app/services/helpers.ts b/app/services/helpers.ts
new file mode 100644
index 0000000..937ed6e
--- /dev/null
+++ b/app/services/helpers.ts
@@ -0,0 +1,47 @@
+import { json2csv } from "csv42";
+
+export const jsonToCsv = (jsonObject: Array