1.2.5 Release!

🚧 Stability improvements for real-time updates
🚧 Back to React
🚧 Rate limiting returns!
🚧 Dependency updates!
🚧 Type cleanup!
This commit is contained in:
Atridad Lahiji 2023-07-25 12:07:25 -06:00 committed by GitHub
commit 13f41715d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 511 additions and 435 deletions

View file

@ -5,6 +5,8 @@ DATABASE_URL=""
UPSTASH_REDIS_REST_URL="" UPSTASH_REDIS_REST_URL=""
UPSTASH_REDIS_REST_TOKEN="" UPSTASH_REDIS_REST_TOKEN=""
UPSTASH_REDIS_EXPIRY_SECONDS="" UPSTASH_REDIS_EXPIRY_SECONDS=""
UPSTASH_RATELIMIT_REQUESTS=""
UPSTASH_RATELIMIT_SECONDS=""
#Next Auth Core #Next Auth Core
NEXTAUTH_SECRET="" NEXTAUTH_SECRET=""

View file

@ -5,7 +5,7 @@ A scrum poker tool that helps agile teams plan their sprints in real-time.
## Stack ## Stack
- Front-end framework: Nextjs - Front-end framework: Nextjs
- Front-end library: Preact - Front-end library: React
- Rendering method: SSR SPA - Rendering method: SSR SPA
- Hosting: Vercel - Hosting: Vercel
- Real-time pub/sub: Ably - Real-time pub/sub: Ably

View file

@ -12,17 +12,6 @@ const config = {
images: { images: {
domains: ["avatars.githubusercontent.com", "lh3.googleusercontent.com"], domains: ["avatars.githubusercontent.com", "lh3.googleusercontent.com"],
}, },
webpack: (config, { dev, isServer }) => {
if (!dev && !isServer) {
Object.assign(config.resolve.alias, {
"react/jsx-runtime.js": "preact/compat/jsx-runtime",
react: "preact/compat",
"react-dom/test-utils": "preact/test-utils",
"react-dom": "preact/compat",
});
}
return config;
},
}; };
export default config; export default config;

View file

@ -1,6 +1,6 @@
{ {
"name": "sprintpadawan", "name": "sprintpadawan",
"version": "1.2.4", "version": "1.2.5",
"description": "Plan. Sprint. Repeat.", "description": "Plan. Sprint. Repeat.",
"private": true, "private": true,
"scripts": { "scripts": {
@ -16,39 +16,40 @@
"@auth/prisma-adapter": "^1.0.1", "@auth/prisma-adapter": "^1.0.1",
"@prisma/client": "5.0.0", "@prisma/client": "5.0.0",
"@react-email/components": "^0.0.7", "@react-email/components": "^0.0.7",
"@tanstack/react-query": "^4.29.19", "@tanstack/react-query": "^4.32.0",
"@trpc/client": "10.34.0", "@trpc/client": "10.35.0",
"@trpc/next": "10.34.0", "@trpc/next": "10.35.0",
"@trpc/react-query": "10.34.0", "@trpc/react-query": "10.35.0",
"@trpc/server": "10.34.0", "@trpc/server": "10.35.0",
"@upstash/ratelimit": "^0.4.3",
"@upstash/redis": "^1.22.0", "@upstash/redis": "^1.22.0",
"ably": "^1.2.41", "ably": "^1.2.42",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"json2csv": "6.0.0-alpha.2", "json2csv": "6.0.0-alpha.2",
"next": "^13.4.9", "next": "^13.4.12",
"next-auth": "^4.22.1", "next-auth": "^4.22.3",
"postcss": "^8.4.25", "postcss": "^8.4.27",
"preact": "^10.16.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0",
"react-email": "^1.9.4", "react-email": "^1.9.4",
"react-icons": "^4.10.1", "react-icons": "^4.10.1",
"resend": "^0.16.0", "resend": "^0.17.1",
"sharp": "^0.32.2", "sharp": "^0.32.4",
"superjson": "1.12.4", "superjson": "1.13.1",
"zod": "^3.21.4" "zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@types/eslint": "^8.44.0", "@types/eslint": "^8.44.1",
"@types/json2csv": "^5.0.3", "@types/json2csv": "^5.0.3",
"@types/node": "^20.4.1", "@types/node": "^20.4.4",
"@types/react": "^18.2.14", "@types/react": "^18.2.16",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.2.0",
"daisyui": "^3.2.1", "daisyui": "^3.4.0",
"eslint": "^8.44.0", "eslint": "^8.45.0",
"eslint-config-next": "^13.4.9", "eslint-config-next": "^13.4.12",
"prisma": "5.0.0", "prisma": "5.0.0",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.3",
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"ct3aMetadata": { "ct3aMetadata": {

772
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,7 @@ const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}` ? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000"; : "http://localhost:3000";
export const Goodbye: React.FC<Readonly<GoodbyeTemplateProps>> = ({ name }) => ( export const Goodbye = ({ name }: GoodbyeTemplateProps) => (
<Html> <Html>
<Head /> <Head />
<Preview>Sorry to see you go... 😭</Preview> <Preview>Sorry to see you go... 😭</Preview>
@ -30,15 +30,15 @@ export const Goodbye: React.FC<Readonly<GoodbyeTemplateProps>> = ({ name }) => (
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] w-[465px]"> <Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] w-[465px]">
<Section className="mt-[32px]"> <Section className="mt-[32px]">
<Img <Img
src={ `${baseUrl}/logo.webp` } src={`${baseUrl}/logo.webp`}
width="40" width="40"
height="37" height="37"
alt={ `Sprint Padawan Logo` } alt={`Sprint Padawan Logo`}
className="my-0 mx-auto" className="my-0 mx-auto"
/> />
</Section> </Section>
<Heading className="text-4xl">Farewell, { name }...</Heading> <Heading className="text-4xl">Farewell, {name}...</Heading>
<Text>{ "Were sorry to see you go." }</Text> <Text>{"Were sorry to see you go."}</Text>
<Text> <Text>
Your data has been deleted, including all room history, user data, Your data has been deleted, including all room history, user data,
votes, etc. votes, etc.

View file

@ -21,7 +21,7 @@ const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}` ? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000"; : "http://localhost:3000";
export const Welcome: React.FC<Readonly<WelcomeTemplateProps>> = ({ name }) => ( export const Welcome = ({ name }: WelcomeTemplateProps) => (
<Html> <Html>
<Head /> <Head />
<Preview>🎉 Welcome to Sprint Padawan! 🎉</Preview> <Preview>🎉 Welcome to Sprint Padawan! 🎉</Preview>
@ -30,18 +30,18 @@ export const Welcome: React.FC<Readonly<WelcomeTemplateProps>> = ({ name }) => (
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] w-[465px]"> <Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] w-[465px]">
<Section className="mt-[32px]"> <Section className="mt-[32px]">
<Img <Img
src={ `${baseUrl}/logo.webp` } src={`${baseUrl}/logo.webp`}
width="40" width="40"
height="37" height="37"
alt={ `Sprint Padawan Logo` } alt={`Sprint Padawan Logo`}
className="my-0 mx-auto" className="my-0 mx-auto"
/> />
</Section> </Section>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0"> <Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
🎉 Welcome to Sprint Padawan, <strong>{ name }</strong>! 🎉 🎉 Welcome to Sprint Padawan, <strong>{name}</strong>! 🎉
</Heading> </Heading>
<Text className="text-black text-[14px] leading-[24px]"> <Text className="text-black text-[14px] leading-[24px]">
Hello { name }, Hello {name},
</Text> </Text>
<Text>Thank you for signing up for Sprint Padawan!</Text> <Text>Thank you for signing up for Sprint Padawan!</Text>
<Text> <Text>

View file

@ -9,6 +9,8 @@ const server = z.object({
UPSTASH_REDIS_REST_URL: z.string().url(), UPSTASH_REDIS_REST_URL: z.string().url(),
UPSTASH_REDIS_REST_TOKEN: z.string(), UPSTASH_REDIS_REST_TOKEN: z.string(),
UPSTASH_REDIS_EXPIRY_SECONDS: z.string(), UPSTASH_REDIS_EXPIRY_SECONDS: z.string(),
UPSTASH_RATELIMIT_REQUESTS: z.string(),
UPSTASH_RATELIMIT_SECONDS: z.string(),
NODE_ENV: z.enum(["development", "test", "production"]), NODE_ENV: z.enum(["development", "test", "production"]),
NEXTAUTH_SECRET: NEXTAUTH_SECRET:
process.env.NODE_ENV === "production" process.env.NODE_ENV === "production"
@ -50,6 +52,8 @@ const processEnv = {
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
UPSTASH_REDIS_EXPIRY_SECONDS: process.env.UPSTASH_REDIS_EXPIRY_SECONDS, UPSTASH_REDIS_EXPIRY_SECONDS: process.env.UPSTASH_REDIS_EXPIRY_SECONDS,
UPSTASH_RATELIMIT_REQUESTS: process.env.UPSTASH_RATELIMIT_REQUESTS,
UPSTASH_RATELIMIT_SECONDS: process.env.UPSTASH_RATELIMIT_SECONDS,
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL, NEXTAUTH_URL: process.env.NEXTAUTH_URL,

View file

@ -2,12 +2,16 @@ import Ably from "ably";
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 ( const ablyRest = new Ably.Rest(env.ABLY_PRIVATE_KEY);
ablyInstance: Ably.Types.RealtimePromise,
export const publishToChannel = (
channel: string, channel: string,
event: EventType, event: EventType,
message: string message: string
) => { ) => {
const channelName = ablyInstance.channels.get(`${env.APP_ENV}-${channel}`); try {
await channelName.publish(event, message); ablyRest.channels.get(`${env.APP_ENV}-${channel}`).publish(event, message);
} catch (error) {
console.log(`❌❌❌ Failed to send message!`);
}
}; };

View file

@ -0,0 +1,25 @@
import Ably from "ably/promises";
import { NextApiRequest, NextApiResponse } from "next";
import { env } from "~/env.mjs";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getServerSession(req, res, authOptions);
if (session) {
// Signed in
const client = new Ably.Realtime(env.ABLY_PRIVATE_KEY);
const tokenRequestData = await client.auth.createTokenRequest({
clientId: session.user.id,
});
res.status(200).json(tokenRequestData);
} else {
// Not Signed in
res.status(401);
}
}

View file

@ -27,8 +27,7 @@ export const roomRouter = createTRPCRouter({
await invalidateCache(`kv_roomcount_admin`); await invalidateCache(`kv_roomcount_admin`);
await invalidateCache(`kv_roomlist_${ctx.session.user.id}`); await invalidateCache(`kv_roomlist_${ctx.session.user.id}`);
await publishToChannel( publishToChannel(
ctx.ably,
`${ctx.session.user.id}`, `${ctx.session.user.id}`,
"ROOM_LIST_UPDATE", "ROOM_LIST_UPDATE",
"CREATE" "CREATE"
@ -202,12 +201,7 @@ export const roomRouter = createTRPCRouter({
}); });
if (newRoom) { if (newRoom) {
await publishToChannel( publishToChannel(`${newRoom.id}`, "ROOM_UPDATE", "UPDATE");
ctx.ably,
`${newRoom.id}`,
"ROOM_UPDATE",
"UPDATE"
);
} }
return !!newRoom; return !!newRoom;
@ -228,19 +222,13 @@ export const roomRouter = createTRPCRouter({
await invalidateCache(`kv_votecount_admin`); await invalidateCache(`kv_votecount_admin`);
await invalidateCache(`kv_roomlist_${ctx.session.user.id}`); await invalidateCache(`kv_roomlist_${ctx.session.user.id}`);
await publishToChannel( publishToChannel(
ctx.ably,
`${ctx.session.user.id}`, `${ctx.session.user.id}`,
"ROOM_LIST_UPDATE", "ROOM_LIST_UPDATE",
"DELETE" "DELETE"
); );
await publishToChannel( publishToChannel(`${deletedRoom.id}`, "ROOM_UPDATE", "DELETE");
ctx.ably,
`${deletedRoom.id}`,
"ROOM_UPDATE",
"DELETE"
);
} }
return !!deletedRoom; return !!deletedRoom;

View file

@ -118,11 +118,10 @@ export const userRouter = createTRPCRouter({
} }
if (!!user && user.name && user.email) { if (!!user && user.name && user.email) {
await resend.sendEmail({ await resend.emails.send({
from: "no-reply@sprintpadawan.dev", from: "Sprint Padawan <no-reply@sprintpadawan.dev>",
to: user.email, to: user.email,
subject: "Sorry to see you go... 😭", subject: "Sorry to see you go... 😭",
//@ts-ignore: IDK why this doesn't work...
react: Goodbye({ name: user.name }), react: Goodbye({ name: user.name }),
}); });

View file

@ -100,12 +100,7 @@ export const voteRouter = createTRPCRouter({
await invalidateCache(`kv_votecount_admin`); await invalidateCache(`kv_votecount_admin`);
await invalidateCache(`kv_votes_${input.roomId}`); await invalidateCache(`kv_votes_${input.roomId}`);
await publishToChannel( publishToChannel(`${vote.roomId}`, "VOTE_UPDATE", "UPDATE");
ctx.ably,
`${vote.roomId}`,
"VOTE_UPDATE",
"UPDATE"
);
} }
return !!vote; return !!vote;

View file

@ -19,7 +19,6 @@ import { type Session } from "next-auth";
import { getServerAuthSession } from "~/server/auth"; import { getServerAuthSession } from "~/server/auth";
import { prisma } from "~/server/db"; import { prisma } from "~/server/db";
import Ably from "ably";
type CreateContextOptions = { type CreateContextOptions = {
session: Session | null; session: Session | null;
@ -38,7 +37,6 @@ type CreateContextOptions = {
const createInnerTRPCContext = (opts: CreateContextOptions) => { const createInnerTRPCContext = (opts: CreateContextOptions) => {
return { return {
session: opts.session, session: opts.session,
ably: new Ably.Realtime.Promise(env.ABLY_PRIVATE_KEY),
prisma, prisma,
}; };
}; };
@ -66,8 +64,10 @@ 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 { 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";
const t = initTRPC.context<typeof createTRPCContext>().create({ const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson, transformer: superjson,
@ -106,6 +106,21 @@ const enforceRouteProtection = t.middleware(async ({ ctx, next }) => {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
} }
const rateLimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(
Number(env.UPSTASH_RATELIMIT_REQUESTS),
`${Number(env.UPSTASH_RATELIMIT_SECONDS)}s`
),
analytics: true,
});
const { success } = await rateLimit.limit(
`${env.APP_ENV}_${ctx.session.user.id}`
);
console.log(success);
if (!success) throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
return next({ return next({
ctx: { ctx: {
session: { ...ctx.session, user: ctx.session.user }, session: { ...ctx.session, user: ctx.session.user },