Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ pre-commit:
pre-push:
commands:
biome-ci:
run: pnpm biome ci
run: pnpm biome check --write
103 changes: 103 additions & 0 deletions src/app/meal/page.tsx
Comment thread
jasutiin marked this conversation as resolved.
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 @@ -20,6 +21,7 @@ export const appRouter = createTRPCRouter({
scores: scoresRouter,
users: usersRouter,
teams: teamsRouter,
meals: mealsRouter,
criteria: criteriaRouter,
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 { adminProcedure, createTRPCRouter } from "@/server/api/trpc";
import { meal, mealAttendance } from "@/server/db/meal-schema";

export const mealsRouter = createTRPCRouter({
addMeal: adminProcedure
.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: adminProcedure
.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]
})
}));
Loading