diff --git a/src/app/meal/page.tsx b/src/app/meal/page.tsx new file mode 100644 index 0000000..36cb998 --- /dev/null +++ b/src/app/meal/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { api } from "@/trpc/react"; + +export default function MealPage() { + const [title, setTitle] = useState(""); + const [startTime, setStartTime] = useState(null); + const [endTime, setEndTime] = useState(null); + const [mealId, setMealId] = useState(""); + const [userId, setUserId] = useState(""); + + const createMeal = api.meals.addMeal.useMutation(); + const scanUserIn = api.meals.scanUserIn.useMutation(); + + function handleCreateMeal() { + if (!startTime || !endTime) return; + createMeal.mutate({ title, startTime, endTime }); + } + + function handleScanUserIn() { + scanUserIn.mutate({ mealId, userId }); + } + + return ( +
+
+

Meal Portal

+
+
+
+

Create a meal

+ + setTitle(e.target.value)} + type="text" + /> + + { + const nextStartTime = new Date(e.target.value); + setStartTime( + Number.isNaN(nextStartTime.getTime()) ? null : nextStartTime + ); + }} + type="datetime-local" + /> + + { + const nextEndTime = new Date(e.target.value); + setEndTime( + Number.isNaN(nextEndTime.getTime()) ? null : nextEndTime + ); + }} + type="datetime-local" + /> + {/* TODO: make buttons use loading state after mutations are fired */} + +
+
+

Scan user in for a meal

+ + setMealId(e.target.value)} + type="text" + /> + + setUserId(e.target.value)} + type="text" + /> + {/* TODO: make buttons use loading state after mutations are fired */} + +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index f7eab6d..9b9d235 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -56,6 +56,10 @@ export default async function Home() {

Participant →

Participant dashboard.
+ +

Meal →

+
Meal dashboard.
+

Login →

Login to the app.
diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 7c9f016..7165efb 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -2,6 +2,7 @@ import { hackathonSettingsRouter } from "@/server/api/routers/hackathon-settings import { judgingAssignmentsRouter } from "@/server/api/routers/judging-assignments"; import { judgingRoomsRouter } from "@/server/api/routers/judging-rooms"; import { judgingRoundsRouter } from "@/server/api/routers/judging-rounds"; +import { mealsRouter } from "@/server/api/routers/meals"; import { scoresRouter } from "@/server/api/routers/scores"; import { teamsRouter } from "@/server/api/routers/teams"; import { usersRouter } from "@/server/api/routers/users"; @@ -19,6 +20,7 @@ export const appRouter = createTRPCRouter({ scores: scoresRouter, users: usersRouter, teams: teamsRouter, + meals: mealsRouter, judgingRooms: judgingRoomsRouter }); diff --git a/src/server/api/routers/meals.ts b/src/server/api/routers/meals.ts new file mode 100644 index 0000000..518f6cd --- /dev/null +++ b/src/server/api/routers/meals.ts @@ -0,0 +1,60 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { meal, mealAttendance } from "@/server/db/meal-schema"; + +export const mealsRouter = createTRPCRouter({ + addMeal: protectedProcedure + .input( + z + .object({ + title: z.string().trim().min(1), + startTime: z.coerce.date(), + endTime: z.coerce.date() + }) + .refine((data) => data.endTime > data.startTime, { + message: "End time must be after start time.", + path: ["endTime"] + }) + ) + .mutation(async ({ input, ctx }) => { + const [newMeal] = await ctx.db + .insert(meal) + .values({ + title: input.title, + startTime: input.startTime, + endTime: input.endTime + }) + .returning(); + return newMeal; + }), + + scanUserIn: protectedProcedure + .input( + z.object({ + mealId: z.string().uuid(), + userId: z.string().min(1) + }) + ) + .mutation(async ({ input, ctx }) => { + const [record] = await ctx.db + .insert(mealAttendance) + .values({ + mealId: input.mealId, + userId: input.userId + }) + .onConflictDoNothing({ + target: [mealAttendance.userId, mealAttendance.mealId] + }) + .returning(); + + if (!record) { + throw new TRPCError({ + code: "CONFLICT", + message: "User is already checked in for this meal." + }); + } + + return record; + }) +}); diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 9fd3567..bc6e09c 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -3,6 +3,7 @@ import postgres from "postgres"; import { env } from "@/env"; import * as authSchema from "./auth-schema"; +import * as mealSchema from "./meal-schema"; import * as schema from "./schema"; import * as scoresSchema from "./scores-schema"; @@ -18,5 +19,5 @@ const conn = globalForDb.conn ?? postgres(env.DATABASE_URL); if (env.NODE_ENV !== "production") globalForDb.conn = conn; export const db = drizzle(conn, { - schema: { ...schema, ...authSchema, ...scoresSchema } + schema: { ...schema, ...authSchema, ...scoresSchema, ...mealSchema } }); diff --git a/src/server/db/meal-schema.ts b/src/server/db/meal-schema.ts new file mode 100644 index 0000000..74a13dc --- /dev/null +++ b/src/server/db/meal-schema.ts @@ -0,0 +1,64 @@ +import { relations } from "drizzle-orm"; +import { + pgTableCreator, + text, + timestamp, + unique, + uuid +} from "drizzle-orm/pg-core"; +import { user } from "./auth-schema"; + +export const createTable = pgTableCreator((name) => `hackathon_${name}`); + +export const meal = createTable("meal", { + id: uuid("id").primaryKey().defaultRandom(), + title: text("title").notNull(), // can be breakfast, lunch, dinner, breakfast leftovers... + startTime: timestamp("start_time", { withTimezone: true }).notNull().unique(), + endTime: timestamp("end_time", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull() +}); + +export const mealAttendance = createTable( + "meal_attendance", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + mealId: uuid("meal_id") + .notNull() + .references(() => meal.id, { onDelete: "cascade" }), + // createdAt is used to check when the user checked in for the meal + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull() + }, + (t) => [ + unique().on(t.userId, t.mealId) // each user can only attend a meal once + ] +); + +export const mealRelations = relations(meal, ({ many }) => ({ + attendance: many(mealAttendance) +})); + +export const mealAttendanceRelations = relations(mealAttendance, ({ one }) => ({ + user: one(user, { + fields: [mealAttendance.userId], + references: [user.id] + }), + meal: one(meal, { + fields: [mealAttendance.mealId], + references: [meal.id] + }) +}));