This commit is contained in:
Atridad Lahiji 2023-08-12 18:17:36 -06:00
parent 92b057993e
commit 047ebe9c8a
No known key found for this signature in database
GPG key ID: 7CB8245F56BC3880
12 changed files with 999 additions and 176 deletions

12
drizzle.config.ts Normal file
View file

@ -0,0 +1,12 @@
import type { Config } from "drizzle-kit";
import "dotenv/config";
export default {
schema: "./src/server/schema.ts",
out: "./drizzle/generated",
driver: "mysql2",
breakpoints: true,
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config;

View file

@ -15,6 +15,8 @@
"@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",
"@clerk/nextjs": "^4.23.2", "@clerk/nextjs": "^4.23.2",
"@paralleldrive/cuid2": "^2.2.2",
"@planetscale/database": "^1.10.0",
"@prisma/client": "5.1.1", "@prisma/client": "5.1.1",
"@react-email/components": "^0.0.7", "@react-email/components": "^0.0.7",
"@tanstack/react-query": "^4.32.6", "@tanstack/react-query": "^4.32.6",
@ -27,6 +29,8 @@
"@upstash/redis": "^1.22.0", "@upstash/redis": "^1.22.0",
"ably": "^1.2.43", "ably": "^1.2.43",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.28.2",
"json2csv": "6.0.0-alpha.2", "json2csv": "6.0.0-alpha.2",
"next": "^13.4.13", "next": "^13.4.13",
"nextjs-cors": "^2.1.2", "nextjs-cors": "^2.1.2",
@ -49,6 +53,7 @@
"@typescript-eslint/eslint-plugin": "^6.3.0", "@typescript-eslint/eslint-plugin": "^6.3.0",
"@typescript-eslint/parser": "^6.3.0", "@typescript-eslint/parser": "^6.3.0",
"daisyui": "^3.5.1", "daisyui": "^3.5.1",
"drizzle-kit": "^0.19.12",
"eslint": "^8.46.0", "eslint": "^8.46.0",
"eslint-config-next": "^13.4.13", "eslint-config-next": "^13.4.13",
"prisma": "5.1.1", "prisma": "5.1.1",

796
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ export default createNextApiHandler({
env.NODE_ENV === "development" env.NODE_ENV === "development"
? ({ path, error }) => { ? ({ path, error }) => {
console.error( console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`, `❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
); );
} }
: undefined, : undefined,

View file

@ -165,16 +165,16 @@ const RoomBody = ({}) => {
}) })
.concat({ .concat({
id: "LATEST", id: "LATEST",
createdAt: new Date(), created_at: new Date(),
userId: roomFromDb.userId, userId: roomFromDb.userId,
roomId: roomFromDb.id, roomId: roomFromDb.id,
scale: roomScale, scale: roomScale,
votes: votesFromDb.map((vote) => { votes: votesFromDb.map((vote) => {
return { return {
name: vote.userId,
value: vote.value, value: vote.value,
}; };
}), }),
room: roomFromDb,
roomName: roomFromDb.roomName, roomName: roomFromDb.roomName,
storyName: storyNameText, storyName: storyNameText,
}); });

View file

@ -4,6 +4,8 @@ import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { fetchCache, setCache } from "~/server/redis"; import { fetchCache, setCache } from "~/server/redis";
import { sql } from "drizzle-orm";
import { rooms, votes } from "~/server/schema";
export const restRouter = createTRPCRouter({ export const restRouter = createTRPCRouter({
dbWarmer: publicProcedure dbWarmer: publicProcedure
@ -13,7 +15,7 @@ export const restRouter = createTRPCRouter({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const isValidKey = await validateApiKey(input.key); const isValidKey = await validateApiKey(input.key);
if (isValidKey) { if (isValidKey) {
await ctx.prisma.vote.findMany(); await ctx.db.query.votes.findMany();
return "Toasted the DB"; return "Toasted the DB";
} else { } else {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
@ -30,7 +32,11 @@ export const restRouter = createTRPCRouter({
if (cachedResult) { if (cachedResult) {
return cachedResult; return cachedResult;
} else { } else {
const votesCount = await ctx.prisma.vote.count(); const votesResult = (
await ctx.db.select({ count: sql<number>`count(*)` }).from(votes)
)[0];
const votesCount = votesResult ? Number(votesResult.count) : 0;
await setCache(`kv_votecount`, votesCount); await setCache(`kv_votecount`, votesCount);
@ -48,7 +54,11 @@ export const restRouter = createTRPCRouter({
if (cachedResult) { if (cachedResult) {
return cachedResult; return cachedResult;
} else { } else {
const roomsCount = await ctx.prisma.room.count(); const roomsResult = (
await ctx.db.select({ count: sql<number>`count(*)` }).from(rooms)
)[0];
const roomsCount = roomsResult ? Number(roomsResult.count) : 0;
await setCache(`kv_roomcount`, roomsCount); await setCache(`kv_roomcount`, roomsCount);

View file

@ -3,7 +3,10 @@ import { publishToChannel } from "~/server/ably";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { fetchCache, invalidateCache, setCache } from "~/server/redis"; import { fetchCache, invalidateCache, setCache } from "~/server/redis";
import { logs, rooms, votes } from "~/server/schema";
import { EventTypes } from "~/utils/types"; import { EventTypes } from "~/utils/types";
import { createId } from "@paralleldrive/cuid2";
import { eq } from "drizzle-orm";
export const roomRouter = createTRPCRouter({ export const roomRouter = createTRPCRouter({
// Create // Create
@ -14,18 +17,18 @@ export const roomRouter = createTRPCRouter({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const room = await ctx.prisma.room.create({ const room = await ctx.db.insert(rooms).values({
data: { id: createId(),
userId: ctx.auth.userId, userId: ctx.auth.userId,
roomName: input.name, roomName: input.name,
storyName: "First Story!", storyName: "First Story!",
scale: "0.5,1,2,3,5,8", scale: "0.5,1,2,3,5,8",
visible: false, visible: false,
},
}); });
const success = room.rowsAffected > 0;
if (room) { if (room) {
await invalidateCache(`kv_roomcount`); await invalidateCache(`kv_roomcount`);
console.log("PUBLISHED TO ", `kv_roomlist_${ctx.auth.userId}`);
await invalidateCache(`kv_roomlist_${ctx.auth.userId}`); await invalidateCache(`kv_roomlist_${ctx.auth.userId}`);
await publishToChannel( await publishToChannel(
@ -40,26 +43,26 @@ export const roomRouter = createTRPCRouter({
JSON.stringify(room) JSON.stringify(room)
); );
} }
// happy path return success;
return !!room;
}), }),
// Get One // Get One
get: protectedProcedure get: protectedProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(({ ctx, input }) => { .query(({ ctx, input }) => {
return ctx.prisma.room.findUnique({ return ctx.db.query.rooms.findFirst({
where: { where: eq(rooms.id, input.id),
id: input.id, with: {
logs: {
with: {
room: true,
},
},
votes: {
with: {
room: true,
},
}, },
select: {
id: true,
userId: true,
logs: true,
roomName: true,
storyName: true,
visible: true,
scale: true,
}, },
}); });
}), }),
@ -77,15 +80,8 @@ export const roomRouter = createTRPCRouter({
if (cachedResult) { if (cachedResult) {
return cachedResult; return cachedResult;
} else { } else {
const roomList = await ctx.prisma.room.findMany({ const roomList = await ctx.db.query.rooms.findMany({
where: { where: eq(rooms.userId, ctx.auth.userId),
userId: ctx.auth.userId,
},
select: {
id: true,
createdAt: true,
roomName: true,
},
}); });
await setCache(`kv_roomlist_${ctx.auth.userId}`, roomList); await setCache(`kv_roomlist_${ctx.auth.userId}`, roomList);
@ -109,26 +105,17 @@ export const roomRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (input.reset) { if (input.reset) {
if (input.log) { if (input.log) {
const oldRoom = await ctx.prisma.room.findUnique({ const oldRoom = await ctx.db.query.rooms.findFirst({
where: { where: eq(rooms.id, input.roomId),
id: input.roomId, with: {
}, votes: true,
select: { logs: true,
roomName: true,
storyName: true,
scale: true,
votes: {
select: {
userId: true,
value: true,
},
},
}, },
}); });
oldRoom && oldRoom &&
(await ctx.prisma.log.create({ (await ctx.db.insert(logs).values({
data: { id: createId(),
userId: ctx.auth.userId, userId: ctx.auth.userId,
roomId: input.roomId, roomId: input.roomId,
scale: oldRoom.scale, scale: oldRoom.scale,
@ -140,67 +127,54 @@ export const roomRouter = createTRPCRouter({
}), }),
roomName: oldRoom.roomName, roomName: oldRoom.roomName,
storyName: oldRoom.storyName, storyName: oldRoom.storyName,
},
})); }));
} }
await ctx.prisma.vote.deleteMany({ await ctx.db.delete(votes).where(eq(votes.roomId, input.roomId));
where: {
roomId: input.roomId,
},
});
await invalidateCache(`kv_votes_${input.roomId}`); await invalidateCache(`kv_votes_${input.roomId}`);
} }
const newRoom = await ctx.prisma.room.update({ const newRoom = await ctx.db
where: { .update(rooms)
id: input.roomId, .set({
},
data: {
storyName: input.name, storyName: input.name,
userId: ctx.auth.userId, userId: ctx.auth.userId,
visible: input.visible, visible: input.visible,
scale: [...new Set(input.scale.split(","))] scale: [...new Set(input.scale.split(","))]
.filter((item) => item !== "") .filter((item) => item !== "")
.toString(), .toString(),
}, })
select: { .where(eq(rooms.id, input.roomId));
id: true,
roomName: true,
storyName: true,
visible: true,
scale: true,
votes: {
select: {
value: true,
},
},
},
});
if (newRoom) { const success = newRoom.rowsAffected > 0;
if (success) {
await publishToChannel( await publishToChannel(
`${newRoom.id}`, `${input.roomId}`,
EventTypes.ROOM_UPDATE, EventTypes.ROOM_UPDATE,
JSON.stringify(newRoom) JSON.stringify(newRoom)
); );
} }
return !!newRoom; return success;
}), }),
// Delete One // Delete One
delete: protectedProcedure delete: protectedProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const deletedRoom = await ctx.prisma.room.delete({ const deletedRoom = await ctx.db
where: { .delete(rooms)
id: input.id, .where(eq(rooms.id, input.id));
},
}); const success = deletedRoom.rowsAffected > 0;
if (success) {
await ctx.db.delete(votes).where(eq(votes.roomId, input.id));
await ctx.db.delete(logs).where(eq(logs.roomId, input.id));
if (deletedRoom) {
await invalidateCache(`kv_roomcount`); await invalidateCache(`kv_roomcount`);
await invalidateCache(`kv_votecount`); await invalidateCache(`kv_votecount`);
await invalidateCache(`kv_roomlist_${ctx.auth.userId}`); await invalidateCache(`kv_roomlist_${ctx.auth.userId}`);
@ -212,7 +186,7 @@ export const roomRouter = createTRPCRouter({
); );
await publishToChannel( await publishToChannel(
`${deletedRoom.id}`, `${input.id}`,
EventTypes.ROOM_UPDATE, EventTypes.ROOM_UPDATE,
JSON.stringify(deletedRoom) JSON.stringify(deletedRoom)
); );
@ -224,6 +198,6 @@ export const roomRouter = createTRPCRouter({
); );
} }
return !!deletedRoom; return success;
}), }),
}); });

View file

@ -1,10 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { publishToChannel } from "~/server/ably"; import { publishToChannel } from "~/server/ably";
import type { Room } from "@prisma/client";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { fetchCache, invalidateCache, setCache } from "~/server/redis"; import { fetchCache, invalidateCache, setCache } from "~/server/redis";
import { EventTypes } from "~/utils/types"; import { EventTypes } from "~/utils/types";
import { eq } from "drizzle-orm";
import { votes } from "~/server/schema";
import { createId } from "@paralleldrive/cuid2";
export const voteRouter = createTRPCRouter({ export const voteRouter = createTRPCRouter({
getAllByRoomId: protectedProcedure getAllByRoomId: protectedProcedure
@ -12,10 +14,9 @@ export const voteRouter = createTRPCRouter({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const cachedResult = await fetchCache< const cachedResult = await fetchCache<
{ {
value: string;
room: Room;
id: string; id: string;
createdAt: Date; value: string;
created_at: Date;
userId: string; userId: string;
roomId: string; roomId: string;
}[] }[]
@ -24,18 +25,8 @@ export const voteRouter = createTRPCRouter({
if (cachedResult) { if (cachedResult) {
return cachedResult; return cachedResult;
} else { } else {
const votesByRoomId = await ctx.prisma.vote.findMany({ const votesByRoomId = await ctx.db.query.votes.findMany({
where: { where: eq(votes.roomId, input.roomId),
roomId: input.roomId,
},
select: {
id: true,
createdAt: true,
room: true,
roomId: true,
userId: true,
value: true,
},
}); });
await setCache(`kv_votes_${input.roomId}`, votesByRoomId); await setCache(`kv_votes_${input.roomId}`, votesByRoomId);
@ -46,37 +37,30 @@ export const voteRouter = createTRPCRouter({
set: protectedProcedure set: protectedProcedure
.input(z.object({ value: z.string(), roomId: z.string() })) .input(z.object({ value: z.string(), roomId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const vote = await ctx.prisma.vote.upsert({ const vote = await ctx.db
where: { .insert(votes)
userId_roomId: { .values({
roomId: input.roomId, id: createId(),
userId: ctx.auth.userId,
},
},
create: {
value: input.value, value: input.value,
userId: ctx.auth.userId, userId: ctx.auth.userId,
roomId: input.roomId, roomId: input.roomId,
}, })
update: { .onDuplicateKeyUpdate({
set: {
value: input.value, value: input.value,
userId: ctx.auth.userId, userId: ctx.auth.userId,
roomId: input.roomId, roomId: input.roomId,
}, },
select: {
value: true,
userId: true,
roomId: true,
id: true,
},
}); });
if (vote) { const success = vote.rowsAffected > 0;
if (success) {
await invalidateCache(`kv_votecount`); await invalidateCache(`kv_votecount`);
await invalidateCache(`kv_votes_${input.roomId}`); await invalidateCache(`kv_votes_${input.roomId}`);
await publishToChannel( await publishToChannel(
`${vote.roomId}`, `${input.roomId}`,
EventTypes.VOTE_UPDATE, EventTypes.VOTE_UPDATE,
input.value input.value
); );
@ -84,10 +68,10 @@ export const voteRouter = createTRPCRouter({
await publishToChannel( await publishToChannel(
`stats`, `stats`,
EventTypes.STATS_UPDATE, EventTypes.STATS_UPDATE,
JSON.stringify(vote) JSON.stringify(success)
); );
} }
return !!vote; return success;
}), }),
}); });

View file

@ -23,7 +23,7 @@ import type {
SignedOutAuthObject, SignedOutAuthObject,
} from "@clerk/nextjs/api"; } from "@clerk/nextjs/api";
import { prisma } from "../db"; import { db } from "../db";
interface AuthContext { interface AuthContext {
auth: SignedInAuthObject | SignedOutAuthObject; auth: SignedInAuthObject | SignedOutAuthObject;
@ -40,7 +40,7 @@ interface AuthContext {
const createInnerTRPCContext = ({ auth }: AuthContext) => { const createInnerTRPCContext = ({ auth }: AuthContext) => {
return { return {
auth, auth,
prisma, db,
}; };
}; };
@ -61,7 +61,7 @@ export const createTRPCContext = (opts: CreateNextContextOptions) => {
*/ */
import { initTRPC, TRPCError } from "@trpc/server"; import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson"; import superjson from "superjson";
import { OpenApiMeta } from "trpc-openapi"; import type { OpenApiMeta } from "trpc-openapi";
const t = initTRPC const t = initTRPC
.context<typeof createTRPCContext>() .context<typeof createTRPCContext>()

View file

@ -1,14 +1,11 @@
import { PrismaClient } from "@prisma/client"; import { drizzle } from "drizzle-orm/planetscale-serverless";
import { connect } from "@planetscale/database";
import { env } from "~/env.mjs"; import { env } from "~/env.mjs";
import * as schema from "~/server/schema";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; // create the connection
const connection = connect({
export const prisma = url: env.DATABASE_URL,
globalForPrisma.prisma ||
new PrismaClient({
log:
env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
}); });
if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; export const db = drizzle(connection, { schema });

View file

@ -1,14 +1,10 @@
import { Redis } from "@upstash/redis"; import { Redis } from "@upstash/redis";
import https from "https";
import { env } from "~/env.mjs"; import { env } from "~/env.mjs";
export const redis = Redis.fromEnv({ export const redis = Redis.fromEnv();
agent: new https.Agent({ keepAlive: true }),
});
export const setCache = async <T>(key: string, value: T) => { export const setCache = async <T>(key: string, value: T) => {
try { try {
console.log("KEY: ", key);
await redis.set(`${env.APP_ENV}_${key}`, value, { await redis.set(`${env.APP_ENV}_${key}`, value, {
ex: Number(env.UPSTASH_REDIS_EXPIRY_SECONDS), ex: Number(env.UPSTASH_REDIS_EXPIRY_SECONDS),
}); });
@ -21,14 +17,8 @@ export const setCache = async <T>(key: string, value: T) => {
export const fetchCache = async <T>(key: string) => { export const fetchCache = async <T>(key: string) => {
try { try {
const result = await redis.get(`${env.APP_ENV}_${key}`); const result = await redis.get(`${env.APP_ENV}_${key}`);
if (result) {
console.log("CACHE HIT");
} else {
console.log("CACHE MISS");
}
return result as T; return result as T;
} catch { } catch {
console.log("CACHE ERROR");
return null; return null;
} }
}; };

65
src/server/schema.ts Normal file
View file

@ -0,0 +1,65 @@
import {
timestamp,
mysqlTable,
varchar,
boolean,
json,
} from "drizzle-orm/mysql-core";
import { relations } from "drizzle-orm";
export const rooms = mysqlTable("Room", {
id: varchar("id", { length: 255 }).notNull().primaryKey(),
created_at: timestamp("created_at", {
mode: "date",
fsp: 3,
}).defaultNow(),
userId: varchar("userId", { length: 255 }).notNull(),
roomName: varchar("roomName", { length: 255 }),
storyName: varchar("storyName", { length: 255 }),
visible: boolean("visible").default(false).notNull(),
scale: varchar("scale", { length: 255 }).default("0.5,1,2,3,5").notNull(),
});
export const roomsRelations = relations(rooms, ({ many }) => ({
votes: many(votes),
logs: many(logs),
}));
export const votes = mysqlTable("Vote", {
id: varchar("id", { length: 255 }).notNull().primaryKey(),
created_at: timestamp("created_at", {
mode: "date",
fsp: 3,
}).defaultNow(),
userId: varchar("userId", { length: 255 }).notNull(),
roomId: varchar("roomId", { length: 255 }).notNull(),
value: varchar("value", { length: 255 }).notNull(),
});
export const votesRelations = relations(votes, ({ one }) => ({
room: one(rooms, {
fields: [votes.roomId],
references: [rooms.id],
}),
}));
export const logs = mysqlTable("Log", {
id: varchar("id", { length: 255 }).notNull().primaryKey(),
created_at: timestamp("created_at", {
mode: "date",
fsp: 3,
}).defaultNow(),
userId: varchar("userId", { length: 255 }).notNull(),
roomId: varchar("roomId", { length: 255 }).notNull(),
scale: varchar("scale", { length: 255 }),
votes: json("votes"),
roomName: varchar("roomName", { length: 255 }),
storyName: varchar("storyName", { length: 255 }),
});
export const logsRelations = relations(logs, ({ one }) => ({
room: one(rooms, {
fields: [logs.roomId],
references: [rooms.id],
}),
}));