commit
dec3ba1334
12 changed files with 536 additions and 199 deletions
26
package.json
26
package.json
|
@ -14,13 +14,13 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ably-labs/react-hooks": "^2.1.1",
|
"@ably-labs/react-hooks": "^2.1.1",
|
||||||
"@auth/prisma-adapter": "^1.0.1",
|
"@auth/prisma-adapter": "^1.0.1",
|
||||||
"@prisma/client": "5.0.0",
|
"@prisma/client": "5.1.1",
|
||||||
"@react-email/components": "^0.0.7",
|
"@react-email/components": "^0.0.7",
|
||||||
"@tanstack/react-query": "^4.32.0",
|
"@tanstack/react-query": "^4.32.1",
|
||||||
"@trpc/client": "10.36.0",
|
"@trpc/client": "10.37.1",
|
||||||
"@trpc/next": "10.36.0",
|
"@trpc/next": "10.37.1",
|
||||||
"@trpc/react-query": "10.36.0",
|
"@trpc/react-query": "10.37.1",
|
||||||
"@trpc/server": "10.36.0",
|
"@trpc/server": "10.37.1",
|
||||||
"@upstash/ratelimit": "^0.4.3",
|
"@upstash/ratelimit": "^0.4.3",
|
||||||
"@upstash/redis": "^1.22.0",
|
"@upstash/redis": "^1.22.0",
|
||||||
"ably": "^1.2.42",
|
"ably": "^1.2.42",
|
||||||
|
@ -28,6 +28,7 @@
|
||||||
"json2csv": "6.0.0-alpha.2",
|
"json2csv": "6.0.0-alpha.2",
|
||||||
"next": "^13.4.12",
|
"next": "^13.4.12",
|
||||||
"next-auth": "^4.22.3",
|
"next-auth": "^4.22.3",
|
||||||
|
"nextjs-cors": "^2.1.2",
|
||||||
"postcss": "^8.4.27",
|
"postcss": "^8.4.27",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
@ -36,19 +37,20 @@
|
||||||
"resend": "^0.17.2",
|
"resend": "^0.17.2",
|
||||||
"sharp": "^0.32.4",
|
"sharp": "^0.32.4",
|
||||||
"superjson": "1.13.1",
|
"superjson": "1.13.1",
|
||||||
|
"trpc-openapi": "^1.2.0",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/eslint": "^8.44.1",
|
"@types/eslint": "^8.44.1",
|
||||||
"@types/json2csv": "^5.0.3",
|
"@types/json2csv": "^5.0.3",
|
||||||
"@types/node": "^20.4.5",
|
"@types/node": "^20.4.6",
|
||||||
"@types/react": "^18.2.17",
|
"@types/react": "^18.2.18",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||||
"@typescript-eslint/parser": "^6.2.0",
|
"@typescript-eslint/parser": "^6.2.1",
|
||||||
"daisyui": "^3.5.0",
|
"daisyui": "^3.5.1",
|
||||||
"eslint": "^8.46.0",
|
"eslint": "^8.46.0",
|
||||||
"eslint-config-next": "^13.4.12",
|
"eslint-config-next": "^13.4.12",
|
||||||
"prisma": "5.0.0",
|
"prisma": "5.1.1",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^5.1.6"
|
"typescript": "^5.1.6"
|
||||||
},
|
},
|
||||||
|
|
549
pnpm-lock.yaml
generated
549
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
19
src/pages/api/[...trpc].ts
Normal file
19
src/pages/api/[...trpc].ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createOpenApiNextHandler } from "trpc-openapi";
|
||||||
|
import cors from "nextjs-cors";
|
||||||
|
|
||||||
|
import { appRouter } from "~/server/api/root";
|
||||||
|
import { createTRPCContext } from "~/server/api/trpc";
|
||||||
|
|
||||||
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
// Setup CORS
|
||||||
|
await cors(req, res);
|
||||||
|
|
||||||
|
// Handle incoming OpenAPI requests
|
||||||
|
return createOpenApiNextHandler({
|
||||||
|
router: appRouter,
|
||||||
|
createContext: createTRPCContext,
|
||||||
|
})(req, res);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handler;
|
10
src/pages/api/openapi.json.ts
Normal file
10
src/pages/api/openapi.json.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { openApiDocument } from "../../server/openapi";
|
||||||
|
|
||||||
|
// Respond with our OpenAPI schema
|
||||||
|
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
res.status(200).send(openApiDocument);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handler;
|
|
@ -1,5 +1,4 @@
|
||||||
import { createNextApiHandler } from "@trpc/server/adapters/next";
|
import { createNextApiHandler } from "@trpc/server/adapters/next";
|
||||||
|
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env.mjs";
|
||||||
import { appRouter } from "~/server/api/root";
|
import { appRouter } from "~/server/api/root";
|
||||||
import { createTRPCContext } from "~/server/api/trpc";
|
import { createTRPCContext } from "~/server/api/trpc";
|
|
@ -20,35 +20,35 @@ export default Home;
|
||||||
const HomePageBody: React.FC = () => {
|
const HomePageBody: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-6xl font-bold">
|
<h1 className="text-3xl sm:text-6xl font-bold">
|
||||||
Sprint{ " " }
|
Sprint{" "}
|
||||||
<span className="bg-gradient-to-br from-pink-600 to-cyan-400 bg-clip-text text-transparent box-decoration-clone">
|
<span className="bg-gradient-to-br from-pink-600 to-cyan-400 bg-clip-text text-transparent box-decoration-clone">
|
||||||
Padawan
|
Padawan
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<h2 className="my-4 text-3xl font-bold">
|
<h2 className="my-4 text-xl sm:text-3xl font-bold">
|
||||||
A{ " " }
|
A{" "}
|
||||||
<span className="bg-gradient-to-br from-pink-600 to-pink-400 bg-clip-text text-transparent box-decoration-clone">
|
<span className="bg-gradient-to-br from-pink-600 to-pink-400 bg-clip-text text-transparent box-decoration-clone">
|
||||||
scrum poker{ " " }
|
scrum poker{" "}
|
||||||
</span>{ " " }
|
</span>{" "}
|
||||||
tool that helps{ " " }
|
tool that helps{" "}
|
||||||
<span className="bg-gradient-to-br from-purple-600 to-purple-400 bg-clip-text text-transparent box-decoration-clone">
|
<span className="bg-gradient-to-br from-purple-600 to-purple-400 bg-clip-text text-transparent box-decoration-clone">
|
||||||
agile teams{ " " }
|
agile teams{" "}
|
||||||
</span>{ " " }
|
</span>{" "}
|
||||||
plan their sprints in{ " " }
|
plan their sprints in{" "}
|
||||||
<span className="bg-gradient-to-br from-cyan-600 to-cyan-400 bg-clip-text text-transparent box-decoration-clone">
|
<span className="bg-gradient-to-br from-cyan-600 to-cyan-400 bg-clip-text text-transparent box-decoration-clone">
|
||||||
real-time
|
real-time
|
||||||
</span>
|
</span>
|
||||||
.
|
.
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="card bg-secondary text-black font-bold text-left">
|
<div className="card card-compact bg-secondary text-black font-bold text-left">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="card-title">Features:</h2>
|
<h2 className="card-title">Features:</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>🚀 Real-time votes!</li>
|
<li>🚀 Real-time votes!</li>
|
||||||
<li>🚀 Granular control of room name and vote scale!</li>
|
<li>🚀 Customizable room name and vote scale!</li>
|
||||||
<li>🚀 CSV Reports for every room!</li>
|
<li>🚀 CSV Reports for every room!</li>
|
||||||
<li>🚀 100% free and open-source... forever!</li>
|
<li>🚀 100% free and open-source... forever!</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { publishToChannel } from "~/server/ably";
|
import { publishToChannel } from "~/server/ably";
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
adminProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
|
|
||||||
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
||||||
|
|
||||||
|
@ -92,7 +96,7 @@ export const roomRouter = createTRPCRouter({
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
countAll: protectedProcedure.query(async ({ ctx }) => {
|
countAll: adminProcedure.query(async ({ ctx }) => {
|
||||||
const cachedResult = await fetchCache<number>(`kv_roomcount_admin`);
|
const cachedResult = await fetchCache<number>(`kv_roomcount_admin`);
|
||||||
|
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { adminProcedure, createTRPCRouter } from "~/server/api/trpc";
|
||||||
import { invalidateCache } from "~/server/redis";
|
import { invalidateCache } from "~/server/redis";
|
||||||
|
|
||||||
export const sessionRouter = createTRPCRouter({
|
export const sessionRouter = createTRPCRouter({
|
||||||
deleteAllByUserId: protectedProcedure
|
deleteAllByUserId: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
|
@ -22,7 +22,7 @@ export const sessionRouter = createTRPCRouter({
|
||||||
|
|
||||||
return !!sessions;
|
return !!sessions;
|
||||||
}),
|
}),
|
||||||
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
|
deleteAll: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
const sessions = await ctx.prisma.session.deleteMany();
|
const sessions = await ctx.prisma.session.deleteMany();
|
||||||
|
|
||||||
if (!!sessions) {
|
if (!!sessions) {
|
||||||
|
|
|
@ -3,14 +3,18 @@ import { Resend } from "resend";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Goodbye } from "~/components/templates/Goodbye";
|
import { Goodbye } from "~/components/templates/Goodbye";
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env.mjs";
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import {
|
||||||
|
adminProcedure,
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
|
|
||||||
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
countAll: protectedProcedure.query(async ({ ctx }) => {
|
countAll: adminProcedure.query(async ({ ctx }) => {
|
||||||
const cachedResult = await fetchCache<number>(`kv_usercount_admin`);
|
const cachedResult = await fetchCache<number>(`kv_usercount_admin`);
|
||||||
|
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
|
@ -150,7 +154,7 @@ export const userRouter = createTRPCRouter({
|
||||||
|
|
||||||
return !!user;
|
return !!user;
|
||||||
}),
|
}),
|
||||||
setAdmin: protectedProcedure
|
setAdmin: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
|
@ -172,7 +176,7 @@ export const userRouter = createTRPCRouter({
|
||||||
return !!user;
|
return !!user;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setVIP: protectedProcedure
|
setVIP: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
|
|
|
@ -2,23 +2,31 @@ import { z } from "zod";
|
||||||
import { publishToChannel } from "~/server/ably";
|
import { publishToChannel } from "~/server/ably";
|
||||||
|
|
||||||
import type { Room } from "@prisma/client";
|
import type { Room } from "@prisma/client";
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import {
|
||||||
|
adminProcedure,
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
import { fetchCache, invalidateCache, setCache } from "~/server/redis";
|
||||||
|
|
||||||
export const voteRouter = createTRPCRouter({
|
export const voteRouter = createTRPCRouter({
|
||||||
countAll: protectedProcedure.query(async ({ ctx }) => {
|
countAll: adminProcedure
|
||||||
const cachedResult = await fetchCache<number>(`kv_votecount_admin`);
|
.input(z.void())
|
||||||
|
.output(z.number())
|
||||||
|
.meta({ openapi: { method: "GET", path: "/votes/count" } })
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
const cachedResult = await fetchCache<number>(`kv_votecount_admin`);
|
||||||
|
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
} else {
|
} else {
|
||||||
const votesCount = await ctx.prisma.vote.count();
|
const votesCount = await ctx.prisma.vote.count();
|
||||||
|
|
||||||
await setCache(`kv_votecount_admin`, votesCount);
|
await setCache(`kv_votecount_admin`, votesCount);
|
||||||
|
|
||||||
return votesCount;
|
return votesCount;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
getAllByRoomId: protectedProcedure
|
getAllByRoomId: protectedProcedure
|
||||||
.input(z.object({ roomId: z.string() }))
|
.input(z.object({ roomId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { prisma } from "~/server/db";
|
||||||
|
|
||||||
type CreateContextOptions = {
|
type CreateContextOptions = {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
|
ip: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -37,6 +38,7 @@ type CreateContextOptions = {
|
||||||
const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
||||||
return {
|
return {
|
||||||
session: opts.session,
|
session: opts.session,
|
||||||
|
ip: opts.ip,
|
||||||
prisma,
|
prisma,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -54,6 +56,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||||
const session = await getServerAuthSession({ req, res });
|
const session = await getServerAuthSession({ req, res });
|
||||||
|
|
||||||
return createInnerTRPCContext({
|
return createInnerTRPCContext({
|
||||||
|
ip: req.socket.remoteAddress,
|
||||||
session,
|
session,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -64,17 +67,21 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||||
* This is where the tRPC API is initialized, connecting the context and transformer.
|
* This is where the tRPC API is initialized, connecting the context and transformer.
|
||||||
*/
|
*/
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
|
import type { OpenApiMeta } from "trpc-openapi";
|
||||||
import { Ratelimit } from "@upstash/ratelimit";
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env.mjs";
|
||||||
import { Redis } from "@upstash/redis";
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
const t = initTRPC.context<typeof createTRPCContext>().create({
|
const t = initTRPC
|
||||||
transformer: superjson,
|
.meta<OpenApiMeta>()
|
||||||
errorFormatter({ shape }) {
|
.context<typeof createTRPCContext>()
|
||||||
return shape;
|
.create({
|
||||||
},
|
transformer: superjson,
|
||||||
});
|
errorFormatter({ shape }) {
|
||||||
|
return shape;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
|
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
|
||||||
|
@ -100,7 +107,7 @@ export const createTRPCRouter = t.router;
|
||||||
export const publicProcedure = t.procedure;
|
export const publicProcedure = t.procedure;
|
||||||
|
|
||||||
/** Reusable middleware that enforces users are logged in before running the procedure. */
|
/** Reusable middleware that enforces users are logged in before running the procedure. */
|
||||||
const enforceRouteProtection = t.middleware(async ({ ctx, next }) => {
|
const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
|
||||||
// Auth
|
// Auth
|
||||||
if (!ctx.session || !ctx.session.user) {
|
if (!ctx.session || !ctx.session.user) {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
@ -116,7 +123,7 @@ const enforceRouteProtection = t.middleware(async ({ ctx, next }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { success } = await rateLimit.limit(
|
const { success } = await rateLimit.limit(
|
||||||
`${env.APP_ENV}_${ctx.session.user.id}`
|
`${env.APP_ENV}_${ctx.session?.user.id}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!success) throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
|
if (!success) throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
|
||||||
|
@ -128,6 +135,17 @@ const enforceRouteProtection = t.middleware(async ({ ctx, next }) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const enforceAdminRole = t.middleware(async ({ ctx, next }) => {
|
||||||
|
if (!ctx.session || !ctx.session.user || !ctx.session?.user.isAdmin)
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
session: { ...ctx.session, user: ctx.session.user },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protected (authenticated) procedure
|
* Protected (authenticated) procedure
|
||||||
*
|
*
|
||||||
|
@ -136,4 +154,6 @@ const enforceRouteProtection = t.middleware(async ({ ctx, next }) => {
|
||||||
*
|
*
|
||||||
* @see https://trpc.io/docs/procedures
|
* @see https://trpc.io/docs/procedures
|
||||||
*/
|
*/
|
||||||
export const protectedProcedure = t.procedure.use(enforceRouteProtection);
|
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
||||||
|
|
||||||
|
export const adminProcedure = t.procedure.use(enforceAdminRole);
|
||||||
|
|
12
src/server/openapi.ts
Normal file
12
src/server/openapi.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { generateOpenApiDocument } from 'trpc-openapi';
|
||||||
|
|
||||||
|
import { appRouter } from './api/root';
|
||||||
|
|
||||||
|
// Generate OpenAPI schema document
|
||||||
|
export const openApiDocument = generateOpenApiDocument(appRouter, {
|
||||||
|
title: 'Example CRUD API',
|
||||||
|
description: 'OpenAPI compliant REST API built using tRPC with Next.js',
|
||||||
|
version: '1.0.0',
|
||||||
|
baseUrl: 'http://localhost:3000/api',
|
||||||
|
docsUrl: 'https://github.com/jlalmes/trpc-openapi',
|
||||||
|
});
|
Loading…
Add table
Reference in a new issue