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
68 changes: 68 additions & 0 deletions database/booking.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Schema, model, models, Types, type SaveOptions } from "mongoose";

// ─── TypeScript Interface ────────────────────────────────────────────────────
// Describes the raw shape of a Booking document stored in MongoDB.
export interface IBooking {
eventId: Types.ObjectId;
email: string;
createdAt: Date;
updatedAt: Date;
}

// ─── Email Validation ────────────────────────────────────────────────────────
// Standard RFC-compliant email regex used by the schema validator.
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

// ─── Schema Definition ──────────────────────────────────────────────────────
const BookingSchema = new Schema<IBooking>(
{
eventId: {
type: Schema.Types.ObjectId,
ref: "Event",
required: [true, "Event ID is required"],
// Index for fast lookups when querying bookings by event
index: true,
},
email: {
type: String,
required: [true, "Email is required"],
trim: true,
lowercase: true,
validate: {
validator: (v: string) => EMAIL_REGEX.test(v),
message: "Email must be a valid email address",
},
},
},
{
// Automatically adds createdAt and updatedAt fields
timestamps: true,
}
);
Comment on lines +17 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent duplicate bookings for the same event/email pair.

This schema currently allows multiple identical { eventId, email } documents. Since there is no quantity or ticket identifier on the record, retries and double-submits become indistinguishable duplicate bookings. Replace the single-field index with a compound unique index.

Proposed fix
     eventId: {
       type: Schema.Types.ObjectId,
       ref: "Event",
       required: [true, "Event ID is required"],
-      // Index for fast lookups when querying bookings by event
-      index: true,
     },
@@
 );
+
+BookingSchema.index({ eventId: 1, email: 1 }, { unique: true });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@database/booking.model.ts` around lines 17 - 41, The schema currently only
indexes eventId which allows duplicate {eventId, email} bookings; update
BookingSchema by removing the single-field index on eventId and add a compound
unique index on { eventId, email } to enforce one booking per email per event
(use BookingSchema.index({ eventId: 1, email: 1 }, { unique: true })). Ensure
the new index is declared after the schema definition so Mongoose applies it at
model creation.


// ─── Pre-save Hook ───────────────────────────────────────────────────────────
// Mongoose v9 pre-save hooks use async/return instead of a next() callback.
// Verifies that the referenced event actually exists in the database before
// allowing a booking to be saved. Prevents orphan bookings.
BookingSchema.pre("save", async function (_opts: SaveOptions) {
// Only validate on create or when eventId is changed
if (!this.isModified("eventId")) {
return;
}

// Dynamically import Event model to avoid circular dependency issues
const EventModel = models.Event ?? (await import("./event.model")).default;

const eventExists = await EventModel.exists({ _id: this.eventId });

if (!eventExists) {
throw new Error(`Event with ID "${String(this.eventId)}" does not exist`);
}
});

// ─── Model Export ────────────────────────────────────────────────────────────
// Reuse the existing compiled model in development (HMR) to prevent
// OverwriteModelError; fall back to compiling a new one.
const Booking = models.Booking ?? model<IBooking>("Booking", BookingSchema);

export default Booking;
161 changes: 161 additions & 0 deletions database/event.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Schema, model, models, type SaveOptions } from "mongoose";

// ─── TypeScript Interface ────────────────────────────────────────────────────
// Describes the raw shape of an Event document stored in MongoDB.
// Used as the generic parameter for Schema/Model to enforce type safety.
export interface IEvent {
title: string;
slug: string;
description: string;
overview: string;
image: string;
venue: string;
location: string;
date: string;
time: string;
mode: "online" | "offline" | "hybrid";
audience: string;
agenda: string[];
organizer: string;
tags: string[];
createdAt: Date;
updatedAt: Date;
}

// ─── Slug Helper ─────────────────────────────────────────────────────────────
// Converts a title into a URL-friendly slug:
// "My Cool Event!!" → "my-cool-event"
function generateSlug(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "") // strip non-word chars (except spaces & hyphens)
.replace(/[\s_]+/g, "-") // collapse whitespace / underscores to a single dash
.replace(/-+/g, "-") // collapse consecutive dashes
.replace(/^-+|-+$/g, ""); // trim leading/trailing dashes
}

// ─── Date / Time Normalizers ─────────────────────────────────────────────────
// Normalises a date string to ISO 8601 (YYYY-MM-DD).
// Throws if the value cannot be parsed into a valid date.
function normalizeDate(raw: string): string {
const parsed = new Date(raw);
if (isNaN(parsed.getTime())) {
throw new Error(`Invalid date value: "${raw}"`);
}
// Return date-only portion in ISO format
return parsed.toISOString().split("T")[0];
}

// Normalises a time string to 24-hour HH:mm format.
// Accepts common inputs like "2:30 PM", "14:30", "2:30pm".
function normalizeTime(raw: string): string {
const trimmed = raw.trim().toUpperCase();

// Try matching "H:MM AM/PM" or "HH:MM AM/PM"
const match12 = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/);
if (match12) {
let hours = parseInt(match12[1], 10);
const minutes = parseInt(match12[2], 10);
const period = match12[3];

if (period === "PM" && hours !== 12) hours += 12;
if (period === "AM" && hours === 12) hours = 0;

return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
Comment on lines +55 to +66
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate 12-hour inputs before converting them.

13:30 PM, 00:30 AM, and 10:75 PM all match this branch today and get normalized into impossible 24-hour values. Reject out-of-range hours/minutes before applying the AM/PM conversion.

Proposed fix
   const match12 = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/);
   if (match12) {
     let hours = parseInt(match12[1], 10);
     const minutes = parseInt(match12[2], 10);
     const period = match12[3];
+
+    if (hours < 1 || hours > 12 || minutes > 59) {
+      throw new Error(`Invalid time value: "${raw}"`);
+    }
 
     if (period === "PM" && hours !== 12) hours += 12;
     if (period === "AM" && hours === 12) hours = 0;
 
     return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@database/event.model.ts` around lines 55 - 66, The 12-hour parsing branch
(the match12 result in database/event.model.ts) currently accepts invalid inputs
like "13:30 PM", "00:30 AM", or minutes >59; add validation after parsing the
captured groups to ensure hours is between 1 and 12 and minutes is between 0 and
59 before applying AM/PM conversion, and if invalid, reject/return an error or
null instead of continuing the conversion; update the logic around match12
handling (the variables hours, minutes, period and the AM/PM conversion) to
perform this guard check first.


// Try matching 24-hour format "HH:MM"
const match24 = trimmed.match(/^(\d{1,2}):(\d{2})$/);
if (match24) {
const hours = parseInt(match24[1], 10);
const minutes = parseInt(match24[2], 10);

if (hours > 23 || minutes > 59) {
throw new Error(`Invalid time value: "${raw}"`);
}
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}

throw new Error(`Unrecognised time format: "${raw}". Expected "HH:MM" or "H:MM AM/PM".`);
}

// ─── Schema Definition ──────────────────────────────────────────────────────
const EventSchema = new Schema<IEvent>(
{
title: { type: String, required: [true, "Title is required"], trim: true },
slug: { type: String, unique: true },
description: { type: String, required: [true, "Description is required"], trim: true },
overview: { type: String, required: [true, "Overview is required"], trim: true },
image: { type: String, required: [true, "Image URL is required"], trim: true },
venue: { type: String, required: [true, "Venue is required"], trim: true },
location: { type: String, required: [true, "Location is required"], trim: true },
date: { type: String, required: [true, "Date is required"] },
time: { type: String, required: [true, "Time is required"] },
mode: {
type: String,
required: [true, "Mode is required"],
enum: {
values: ["online", "offline", "hybrid"],
message: "Mode must be one of: online, offline, hybrid",
},
lowercase: true,
trim: true,
},
audience: { type: String, required: [true, "Audience is required"], trim: true },
agenda: {
type: [String],
required: [true, "Agenda is required"],
validate: {
validator: (v: string[]) => v.length > 0,
message: "Agenda must contain at least one item",
},
},
organizer: { type: String, required: [true, "Organizer is required"], trim: true },
tags: {
type: [String],
required: [true, "Tags are required"],
validate: {
validator: (v: string[]) => v.length > 0,
message: "Tags must contain at least one item",
},
},
},
{
// Automatically adds createdAt and updatedAt fields
timestamps: true,
}
);

// ─── Unique Index on slug ────────────────────────────────────────────────────
EventSchema.index({ slug: 1 }, { unique: true });

// ─── Pre-save Hook ───────────────────────────────────────────────────────────
// Mongoose v9 pre-save hooks use async/return instead of a next() callback.
// Runs before every save to:
// 1. Generate/regenerate the slug when the title changes.
// 2. Normalize the date to ISO format (YYYY-MM-DD).
// 3. Normalize the time to 24-hour format (HH:MM).
EventSchema.pre("save", function (_opts: SaveOptions) {
// Only regenerate slug if the title was modified (or is new)
if (this.isModified("title")) {
this.slug = generateSlug(this.title);
}

// Normalize date to ISO 8601 when modified
if (this.isModified("date")) {
this.date = normalizeDate(this.date);
}

// Normalize time to consistent HH:MM format when modified
if (this.isModified("time")) {
this.time = normalizeTime(this.time);
}
});

// ─── Model Export ────────────────────────────────────────────────────────────
// Reuse the existing compiled model in development (HMR) to prevent
// OverwriteModelError; fall back to compiling a new one.
const Event = models.Event ?? model<IEvent>("Event", EventSchema);

export default Event;
6 changes: 6 additions & 0 deletions database/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// ─── Database Models ─────────────────────────────────────────────────────────
// Central barrel export for all Mongoose models.
// Usage: import { Event, Booking } from "@/database";

export { default as Event, type IEvent } from "./event.model";
export { default as Booking, type IBooking } from "./booking.model";
68 changes: 68 additions & 0 deletions lib/mongodb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import mongoose, { type Mongoose } from "mongoose";

/**
* Global type declaration for caching the Mongoose connection.
*
* In Next.js development, hot-reload causes modules to re-execute on every
* change. Without caching, each reload would open a new database connection,
* quickly exhausting the connection pool. We store the pending/resolved
* connection promise on `globalThis` so it persists across reloads.
*/
interface MongooseCache {
conn: Mongoose | null;
promise: Promise<Mongoose> | null;
}

declare global {
// eslint-disable-next-line no-var -- var is required for globalThis augmentation
var mongoose: MongooseCache | undefined;
}

const MONGODB_URI = process.env.MONGODB_URI;

if (!MONGODB_URI) {
throw new Error(
"MONGODB_URI is not defined. Please add it to your .env.local file."
);
}

/**
* Retrieve the existing cache or initialise a new one.
* Using `globalThis` ensures the cache survives Next.js hot-reloads in dev,
* while remaining a plain module-scoped value in production (where modules
* are only evaluated once).
*/
const cached: MongooseCache = globalThis.mongoose ?? { conn: null, promise: null };

if (!globalThis.mongoose) {
globalThis.mongoose = cached;
}

/**
* Connect to MongoDB via Mongoose and return the connection.
*
* - On the first call, a connection promise is created and cached.
* - Subsequent calls return the same promise (or the already-resolved connection).
* - The `bufferCommands: false` option disables Mongoose's internal command
* buffering so operations fail fast if the connection drops, rather than
* silently queueing.
*/
async function dbConnect(): Promise<Mongoose> {
// Return the existing connection if it's already established.
if (cached.conn) {
return cached.conn;
}

// If no connection promise exists yet, start one.
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI as string, {
bufferCommands: false,
});
}

// Await the pending connection and cache the result.
cached.conn = await cached.promise;
Comment on lines +52 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset the cache when the connection is no longer usable.

dbConnect() currently treats any non-null cached.conn as healthy and never clears a rejected cached.promise. After a transient connect error or a later disconnect, every caller keeps reusing the same dead state until the process restarts.

Proposed fix
 async function dbConnect(): Promise<Mongoose> {
   // Return the existing connection if it's already established.
-  if (cached.conn) {
+  if (cached.conn?.connection.readyState === 1) {
     return cached.conn;
   }
+  if (cached.conn) {
+    cached.conn = null;
+    cached.promise = null;
+  }
 
   // If no connection promise exists yet, start one.
   if (!cached.promise) {
-    cached.promise = mongoose.connect(MONGODB_URI as string, {
-      bufferCommands: false,
-    });
+    cached.promise = mongoose
+      .connect(MONGODB_URI as string, {
+        bufferCommands: false,
+      })
+      .catch((error) => {
+        cached.conn = null;
+        cached.promise = null;
+        throw error;
+      });
   }
 
   // Await the pending connection and cache the result.
   cached.conn = await cached.promise;
   return cached.conn;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (cached.conn) {
return cached.conn;
}
// If no connection promise exists yet, start one.
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI as string, {
bufferCommands: false,
});
}
// Await the pending connection and cache the result.
cached.conn = await cached.promise;
async function dbConnect(): Promise<Mongoose> {
// Return the existing connection if it's already established.
if (cached.conn?.connection.readyState === 1) {
return cached.conn;
}
if (cached.conn) {
cached.conn = null;
cached.promise = null;
}
// If no connection promise exists yet, start one.
if (!cached.promise) {
cached.promise = mongoose
.connect(MONGODB_URI as string, {
bufferCommands: false,
})
.catch((error) => {
cached.conn = null;
cached.promise = null;
throw error;
});
}
// Await the pending connection and cache the result.
cached.conn = await cached.promise;
return cached.conn;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/mongodb.ts` around lines 52 - 64, dbConnect currently returns cached.conn
unconditionally and never clears cached.promise or cached.conn after a failed
connect or later disconnect; update dbConnect (and the cached handling around
cached.promise/cached.conn) to clear the cache on errors and on disconnects:
wrap the await of cached.promise in a try/catch and on catch set cached.promise
= null and cached.conn = null before rethrowing, and register a
mongoose.connection listener (e.g., 'disconnected' or 'close' and/or 'error')
that resets cached.conn and cached.promise to null so subsequent calls to
dbConnect will attempt a fresh mongoose.connect; reference cached.promise,
cached.conn, and the dbConnect function to locate the change.

return cached.conn;
}

export default dbConnect;
Loading