This commit is contained in:
Atridad Lahiji 2023-08-12 18:17:36 -06:00 committed by atridadl
parent 24a6aac703
commit 869fdc3bbe
No known key found for this signature in database
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

@ -10,9 +10,9 @@ export default createNextApiHandler({
onError: onError:
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: {
select: { with: {
id: true, room: true,
userId: true, },
logs: true, },
roomName: true, votes: {
storyName: true, with: {
visible: true, room: 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,98 +105,76 @@ 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,
votes: oldRoom.votes.map((vote) => { votes: oldRoom.votes.map((vote) => {
return { return {
name: vote.userId, name: vote.userId,
value: vote.value, value: vote.value,
}; };
}), }),
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(),
value: input.value,
userId: ctx.auth.userId,
roomId: input.roomId,
})
.onDuplicateKeyUpdate({
set: {
value: input.value,
userId: ctx.auth.userId, userId: ctx.auth.userId,
roomId: input.roomId,
}, },
}, });
create: {
value: input.value,
userId: ctx.auth.userId,
roomId: input.roomId,
},
update: {
value: input.value,
userId: ctx.auth.userId,
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({
url: env.DATABASE_URL,
});
export const prisma = export const db = drizzle(connection, { schema });
globalForPrisma.prisma ||
new PrismaClient({
log:
env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

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],
}),
}));