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:
commit
13f41715d1
14 changed files with 511 additions and 435 deletions
|
@ -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=""
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
47
package.json
47
package.json
|
@ -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
772
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -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.
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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!`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
25
src/server/api/createTokenRequest.ts
Normal file
25
src/server/api/createTokenRequest.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
Loading…
Add table
Reference in a new issue