Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/app/meal/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Date | null>(null);
const [endTime, setEndTime] = useState<Date | null>(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 (
<main>
<header>
<h1>Meal Portal</h1>
</header>
<div>
<div>
<h2>Create a meal</h2>
<label htmlFor="title">Title:</label>
<input
id="title"
name="title"
onChange={(e) => setTitle(e.target.value)}
type="text"
/>
<label htmlFor="start-time">Start time:</label>
<input
id="start-time"
name="start-time"
onChange={(e) => {
const nextStartTime = new Date(e.target.value);
setStartTime(
Number.isNaN(nextStartTime.getTime()) ? null : nextStartTime
);
}}
type="datetime-local"
/>
<label htmlFor="end-time">End time:</label>
<input
id="end-time"
name="end-time"
onChange={(e) => {
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 */}
<button
onClick={() => {
handleCreateMeal();
}}
type="button"
>
Submit
</button>
</div>
<div>
<h2>Scan user in for a meal</h2>
<label htmlFor="meal-id">Meal Id:</label>
<input
id="meal-id"
name="meal-id"
onChange={(e) => setMealId(e.target.value)}
type="text"
/>
<label htmlFor="user-id">User Id:</label>
<input
id="start-time"
name="start-time"
onChange={(e) => setUserId(e.target.value)}
type="text"
/>
{/* TODO: make buttons use loading state after mutations are fired */}
<button
onClick={() => {
handleScanUserIn();
}}
type="button"
>
Submit
</button>
</div>
</div>
</main>
);
}
4 changes: 4 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export default async function Home() {
<h3 className={styles.cardTitle}>Participant →</h3>
<div className={styles.cardText}>Participant dashboard.</div>
</Link>
<Link className={styles.card} href="/meal">
<h3 className={styles.cardTitle}>Meal →</h3>
<div className={styles.cardText}>Meal dashboard.</div>
</Link>
<Link className={styles.card} href="/login">
<h3 className={styles.cardTitle}>Login →</h3>
<div className={styles.cardText}>Login to the app.</div>
Expand Down
2 changes: 2 additions & 0 deletions src/server/api/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +20,7 @@ export const appRouter = createTRPCRouter({
scores: scoresRouter,
users: usersRouter,
teams: teamsRouter,
meals: mealsRouter,
judgingRooms: judgingRoomsRouter
});

Expand Down
60 changes: 60 additions & 0 deletions src/server/api/routers/meals.ts
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is nitpicky but we have an adminProcedure Fiona created in trpc.ts so we should use it just so the codebase is more cohesive.

.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;
})
});
3 changes: 2 additions & 1 deletion src/server/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 }
});
64 changes: 64 additions & 0 deletions src/server/db/meal-schema.ts
Original file line number Diff line number Diff line change
@@ -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]
})
}));