-
Notifications
You must be signed in to change notification settings - Fork 0
Database Models #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| } | ||
| ); | ||
|
|
||
| // ─── 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; | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate 12-hour inputs before converting them.
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 |
||
|
|
||
| // 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; | ||
| 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"; |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reset the cache when the connection is no longer usable.
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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cached.conn; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export default dbConnect; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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