A deep dive into the architecture, design decisions, and implementation details of a real-world room booking system built for the College of Computing, PSU Phuket Campus.
If you've ever tried to build a room or resource booking system, you know it's one of those deceptively tricky domains. On the surface it sounds simple — "just let people pick a time slot" — but the moment you layer in role-based access, conflict detection, automated status transitions, and audit history, complexity compounds fast.
This post walks through CoC Meet Room, a mobile-first, responsive booking system built for the College of Computing at Prince of Songkla University, Phuket Campus. I'll focus on the why and how behind each key decision so you can apply these patterns to your own projects.
What Is CoC Meet Room?
CoC Meet Room is a web-based meeting room reservation system that allows students and staff to:
- Check real-time room availability in a visual timeline view
- Book meeting rooms in hourly slots (up to 3 hours/day per account)
- Manage reservations through role-scoped dashboards
- Automatically sync booking statuses via a scheduled cron job
The system supports three roles — USER, STAFF, and SUPERUSER — each with clearly scoped capabilities. The UI is built mobile-first and is fully responsive, making it practical for students quickly checking availability on their phones between classes.
The Modern Tech Stack
Here's a quick overview of what powers the system:
| Layer | Technology |
|---|---|
| Framework | Next.js (App Router) |
| UI Library | React 19 + TailwindCSS |
| Server Logic | Next.js Server Actions |
| ORM | Prisma ORM |
| Database | PostgreSQL |
| Validation | Zod |
| Scheduling | cron-job.org |
| External API | Open-Meteo (weather) |
Each choice was deliberate. Let's unpack the most important ones.
Why Next.js App Router?
The App Router unlocks React Server Components and Server Actions natively. This means we can co-locate data fetching, mutations, and UI in a way that drastically reduces boilerplate — no need for a separate Express server, no Redux for async state, no fetch-on-mount patterns for most pages.
Why Prisma + PostgreSQL?
Prisma gives you type-safe database access with zero-friction schema management. For a system where data integrity matters — overlapping bookings are a hard business rule — having TypeScript types generated directly from your schema eliminates an entire class of bugs.
Why Zod?
Validation is enforced at two levels:
-
Frontend — HTML attributes (
required,minLength,type="email") for a fast first layer of UX feedback. - Backend — Zod schemas inside Server Actions re-validate every input independently, because you can never trust the client.
// Example: Booking validation schema with Zod
const bookingSchema = z.object({
roomId: z.string().cuid(),
startTime: z.string().datetime(),
endTime: z.string().datetime(),
}).refine(data => data.endTime > data.startTime, {
message: "End time must be after start time",
path: ["endTime"],
});
This dual-validation strategy means both your users and your database are protected.
Architecture Highlight: Server Actions Over Internal APIs
One of the most impactful architectural decisions was using Server Actions for all core business logic — authentication, booking creation, user management, and room operations.
app/
└── actions/
├── auth.ts // login, register, session
├── bookings.ts // create, cancel, update status
├── rooms.ts // create room, fetch queue
└── users.ts // profile updates, role changes
The Traditional Approach vs. Server Actions
In a typical Next.js + Express setup, you'd have:
Client → fetch('/api/bookings', { method: 'POST', body: ... }) → API Route → DB
With Server Actions, the flow collapses to:
Client → <form action={createBooking}> or direct call → DB
Why this matters for a booking system:
- No network round-trip overhead for internal mutations — Server Actions execute on the server directly.
- No API route boilerplate to write and maintain for each operation.
- Automatic CSRF protection — Server Actions are natively protected against cross-site request forgery.
- Simpler error handling — you return structured results from the action, no need to decode HTTP status codes.
The one place we did use an API Route is the cron endpoint:
// app/api/cron/bookings/route.ts
export async function GET(request: Request) {
const authHeader = request.headers.get('Authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// ... status sync logic
}
This is intentional — external services like cron-job.org need a real HTTP endpoint to call, so this is the appropriate place for a traditional API route.
Database Design & the Soft Delete Strategy
The Data Model
The database is organized around four core entities with clear separation of concerns:
User (1) ──────── (0..1) Person
User (1) ──────── (many) Booking
Room (1) ──────── (many) Booking
Booking belongs to one User and one Room
The key insight here is splitting User and Person:
-
User holds authentication data:
studentId,email,phone,password,role,refreshToken -
Person holds profile data:
name,major,instagram,facebook
This separation follows the single-responsibility principle at the data layer. Auth logic never has to touch profile fields, and profile updates never risk corrupting credential data.
The Soft Delete Philosophy
Here's where things get interesting for booking systems. When a user cancels a booking, we do not delete the record. Instead, we transition its status to CANCELLED.
// ❌ Hard delete — data gone forever
await prisma.booking.delete({ where: { id: bookingId } });
// ✅ Soft delete — record preserved for audit history
await prisma.booking.update({
where: { id: bookingId },
data: { status: 'CANCELLED' },
});
Why does this matter?
- Audit trail — You can always answer "who booked what, when, and what happened to it?"
- Conflict resolution — Staff can verify whether a disputed booking was cancelled before or after a conflict arose.
- Reporting — Usage statistics remain accurate even after cancellations.
-
No data loss — A
CANCELLEDrecord is still a historical fact worth keeping.
The four booking statuses form a clear state machine:
PENDING ──── (time arrives) ────► ACTIVE ──── (time ends) ────► COMPLETED
│ │
└── (cancelled by user/staff) ────┴────────────────────────► CANCELLED
CANCELLED records are excluded from conflict detection but remain visible in History for all roles.
Smart Business Logic
The 3-Hour Daily Limit
One of the core fairness rules: each account can book a maximum of 3 hours per day across all rooms. This is enforced server-side before any booking is committed:
// Pseudo-code: Enforcing the daily booking limit
const todaysActiveBookings = await prisma.booking.findMany({
where: {
userId: session.userId,
status: { in: ['PENDING', 'ACTIVE'] },
startTime: { gte: startOfDay(new Date()) },
endTime: { lte: endOfDay(new Date()) },
},
});
const totalHoursBooked = todaysActiveBookings.reduce((acc, booking) => {
return acc + differenceInHours(booking.endTime, booking.startTime);
}, 0);
if (totalHoursBooked + requestedHours > 3) {
return { error: 'You have reached the 3-hour daily booking limit.' };
}
Key nuances:
-
CANCELLEDbookings are not counted toward the limit — a fair policy that doesn't punish users for cancelling. - The check happens inside the Server Action, making it impossible to bypass from the client.
- A user can select up to 3 consecutive 1-hour slots from the Overview page in a single booking session.
Conflict Detection
Before a booking is created, the system also verifies no PENDING or ACTIVE booking exists for the same room in an overlapping time range:
const conflict = await prisma.booking.findFirst({
where: {
roomId: input.roomId,
status: { in: ['PENDING', 'ACTIVE'] },
AND: [
{ startTime: { lt: input.endTime } },
{ endTime: { gt: input.startTime } },
],
},
});
if (conflict) {
return { error: 'This room is already booked for the selected time.' };
}
Automated Status Management with Cron Jobs
A booking system that requires manual status updates is broken by design. CoC Meet Room uses cron-job.org to call a protected API endpoint every hour, automating the PENDING → ACTIVE → COMPLETED lifecycle.
Setting It Up
On cron-job.org:
URL: https://your-domain.com/api/cron/bookings
Method: GET
Schedule: Every 1 hour
Headers: Authorization: Bearer <CRON_SECRET>
The endpoint logic:
// app/api/cron/bookings/route.ts
export async function GET(request: Request) {
// 1. Validate the secret
const authHeader = request.headers.get('Authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const now = new Date();
// 2. PENDING → ACTIVE: booking window has started
await prisma.booking.updateMany({
where: {
status: 'PENDING',
startTime: { lte: now },
endTime: { gt: now },
},
data: { status: 'ACTIVE' },
});
// 3. ACTIVE → COMPLETED: booking window has ended
await prisma.booking.updateMany({
where: {
status: 'ACTIVE',
endTime: { lte: now },
},
data: { status: 'COMPLETED' },
});
return Response.json({ success: true });
}
Why an external cron service instead of a serverless timer?
On most Vercel/serverless deployments, there's no persistent process to run background jobs. Using cron-job.org means:
- No infrastructure to manage
- A free, reliable external trigger
- Easy monitoring via the cron-job.org dashboard
- The secret header ensures only authorized callers can trigger the update
There's also a bonus: if a user books a time slot that is currently happening, the Server Action immediately syncs its status to ACTIVE right after creation — no waiting for the next cron cycle.
External API Integration: Live Campus Weather
The Overview dashboard displays real-time weather for the PSU Phuket area, fetched from the Open-Meteo API — a free, no-auth-required weather API.
// lib/campus-weather.ts
const PSU_PHUKET_LAT = 8.076; // Approximate coordinates
const PSU_PHUKET_LON = 98.298;
export async function getCampusWeather() {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${PSU_PHUKET_LAT}&longitude=${PSU_PHUKET_LON}¤t_weather=true`;
const res = await fetch(url, {
next: { revalidate: 3600 }, // Cache for 1 hour — matches our cron interval
});
const raw = await res.json();
// Validate the shape of the API response with Zod
const parsed = weatherSchema.safeParse(raw);
if (!parsed.success) {
return null; // Graceful degradation if the API response changes shape
}
return parsed.data.current_weather;
}
Design decisions worth noting:
-
revalidate: 3600— Next.js caches the fetch result for an hour, meaning we're not hammering the Open-Meteo API on every page load. - Zod validation on the API response — External APIs can change. By parsing the response through a Zod schema, we fail gracefully and never crash the dashboard if Open-Meteo changes its response shape.
- Graceful degradation — If weather data is unavailable, the dashboard simply hides the widget rather than erroring out.
Security: RBAC and Password Hashing
Role-Based Access Control (RBAC)
Every data-access function in lib/dal.ts (the Data Access Layer) checks the session role before returning data. There is no "just filter on the frontend" — the server never sends data the role isn't allowed to see.
| Operation | USER | STAFF | SUPERUSER |
|---|---|---|---|
| View own bookings | ✅ | ✅ | ✅ |
| View all bookings | ❌ | ✅ | ✅ |
| Cancel own booking | ✅ | ✅ | ✅ |
| Update any booking status | ❌ | ✅ | ✅ |
| Create rooms | ❌ | ❌ | ✅ |
| Change user roles | ❌ | ❌ | ✅ |
// Example from lib/dal.ts
export async function getBookings(session: Session) {
if (session.role === 'USER') {
return prisma.booking.findMany({ where: { userId: session.userId } });
}
if (session.role === 'STAFF') {
// STAFF sees all USER bookings, but not SUPERUSER bookings
return prisma.booking.findMany({
where: { user: { right: 'USER' } },
});
}
// SUPERUSER sees everything
return prisma.booking.findMany();
}
Password Hashing
Passwords are hashed before storage (using bcrypt or Argon2) and the system supports on-the-fly migration: if a user logs in with a legacy hash format, their password is automatically re-hashed to the current standard and saved. This enables zero-downtime security upgrades.
Project Structure at a Glance
app/
├── actions/ # Server Actions (auth, booking, room, user)
├── api/
│ └── cron/
│ └── bookings/ # Cron endpoint for status sync
├── dashboard/
│ ├── bookings/ # Booking management UI
│ ├── rooms/ # Room queue (STAFF/SUPERUSER)
│ ├── users/ # User management (STAFF/SUPERUSER)
│ └── profile/ # Profile + logout
├── login/ # Auth pages
├── register/
└── schedule/ # Booking history
lib/
├── dal.ts # Data Access Layer (role-scoped queries)
├── campus-weather.ts # Open-Meteo integration
└── form-validation.ts # Zod schemas
💡 Key Takeaways for Fellow Developers
Building CoC Meet Room surfaced several patterns that translate well to any booking or scheduling system:
- Server Actions reduce boilerplate without sacrificing control — use them for all internal mutations.
- Soft delete isn't just "best practice" — for booking systems, it's a business requirement. Audit history has real value.
- Validate twice — once on the client for UX, once on the server for security. Never skip the server layer.
- Automate state transitions — a booking system without automated status management will drift out of sync. External cron services are a pragmatic solution on serverless platforms.
-
Parse external APIs with Zod — API response shapes change. Zod
safeParselets you fail gracefully instead of crashing in production. -
Separate auth data from profile data —
UserandPersonas separate models keeps concerns clean and makes each simpler to reason about.
🚀 Getting Started
If you want to run the project locally:
# 1. Install dependencies
pnpm install
# 2. Set your environment variables
cp .env.example .env
# Fill in DATABASE_URL and CRON_SECRET
# 3. Generate Prisma client and push schema
pnpm prisma generate
pnpm prisma db push
# 4. Start the development server
pnpm dev
For the cron job, register a free account on cron-job.org, create a new job pointing to https://your-domain.com/api/cron/bookings with a GET method, hourly schedule, and your Authorization: Bearer <CRON_SECRET> header.
Final Thoughts
CoC Meet Room started as a class project but ended up as a solid reference architecture for full-stack Next.js applications. The combination of Server Actions, Prisma, and Zod creates a remarkably tight and type-safe development experience — and the cron + soft-delete patterns address real operational concerns that most tutorials skip entirely.
If you have questions about any part of the implementation — the state machine, the role-based DAL, or the cron setup — drop them in the comments. Happy to go deeper on any section. 🙌
Built with Next.js · Prisma · PostgreSQL · Zod · Open-Meteo · cron-job.org
Top comments (0)