feat: scaffold holiday booking project

This commit is contained in:
2026-05-22 07:54:09 +00:00
commit abfc8dcf8e
39 changed files with 8941 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
CREATE TYPE "BookingStatus" AS ENUM ('PENDING_PAYMENT', 'PAYMENT_RECEIVED', 'CONFIRMED', 'CANCELLED', 'FAILED');
CREATE TYPE "PaymentStatus" AS ENUM ('REQUIRES_PAYMENT', 'COMPLETED', 'FAILED', 'REFUNDED');
CREATE TYPE "AvailabilityBlockReason" AS ENUM ('MAINTENANCE', 'OWNER_BLOCKED', 'BASE_RULE', 'OTHER');
CREATE TYPE "ContentPageStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
CREATE TABLE "SiteSettings" (
"id" TEXT NOT NULL,
"businessName" TEXT NOT NULL,
"tagline" TEXT,
"contactEmail" TEXT,
"contactPhone" TEXT,
"defaultSeoTitle" TEXT,
"defaultSeoDescription" TEXT,
"bookingHoldMinutes" INTEGER NOT NULL DEFAULT 30,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Property" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"longDescription" TEXT NOT NULL,
"locationText" TEXT NOT NULL,
"sleeps" INTEGER NOT NULL,
"bedrooms" INTEGER NOT NULL,
"bathrooms" INTEGER NOT NULL,
"petsAllowed" BOOLEAN NOT NULL DEFAULT false,
"published" BOOLEAN NOT NULL DEFAULT false,
"featured" BOOLEAN NOT NULL DEFAULT false,
"minStayNights" INTEGER,
"checkInTime" TEXT,
"checkOutTime" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Property_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Property_slug_key" ON "Property"("slug");
CREATE TABLE "PropertyImage" (
"id" TEXT NOT NULL,
"propertyId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"altText" TEXT NOT NULL,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"primaryImage" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PropertyImage_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "PropertyImage_propertyId_displayOrder_idx" ON "PropertyImage"("propertyId", "displayOrder");
CREATE TABLE "Amenity" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Amenity_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Amenity_slug_key" ON "Amenity"("slug");
CREATE TABLE "PropertyAmenity" (
"propertyId" TEXT NOT NULL,
"amenityId" TEXT NOT NULL
);
CREATE UNIQUE INDEX "PropertyAmenity_propertyId_amenityId_key" ON "PropertyAmenity"("propertyId", "amenityId");
CREATE INDEX "PropertyAmenity_amenityId_idx" ON "PropertyAmenity"("amenityId");
CREATE TABLE "PricingRule" (
"id" TEXT NOT NULL,
"propertyId" TEXT NOT NULL,
"label" TEXT,
"basePriceCents" INTEGER NOT NULL,
"weekendPriceCents" INTEGER,
"guestDeltaCents" INTEGER,
"validFrom" TIMESTAMP(3),
"validTo" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PricingRule_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "PricingRule_propertyId_validFrom_validTo_idx" ON "PricingRule"("propertyId", "validFrom", "validTo");
CREATE TABLE "AvailabilityBlock" (
"id" TEXT NOT NULL,
"propertyId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"reason" "AvailabilityBlockReason" NOT NULL DEFAULT 'OTHER',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AvailabilityBlock_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AvailabilityBlock_propertyId_startDate_endDate_idx" ON "AvailabilityBlock"("propertyId", "startDate", "endDate");
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"propertyId" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"arrivalDate" TIMESTAMP(3) NOT NULL,
"departureDate" TIMESTAMP(3) NOT NULL,
"adults" INTEGER NOT NULL,
"children" INTEGER NOT NULL DEFAULT 0,
"pets" INTEGER NOT NULL DEFAULT 0,
"specialRequests" TEXT,
"termsAccepted" BOOLEAN NOT NULL DEFAULT false,
"holdExpiresAt" TIMESTAMP(3),
"totalCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'GBP',
"status" "BookingStatus" NOT NULL DEFAULT 'PENDING_PAYMENT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Booking_propertyId_arrivalDate_departureDate_idx" ON "Booking"("propertyId", "arrivalDate", "departureDate");
CREATE INDEX "Booking_status_idx" ON "Booking"("status");
CREATE TABLE "Payment" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"stripeCheckoutSessionId" TEXT,
"stripePaymentIntentId" TEXT,
"stripeEventId" TEXT,
"amountCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'GBP',
"status" "PaymentStatus" NOT NULL DEFAULT 'REQUIRES_PAYMENT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Payment_bookingId_key" ON "Payment"("bookingId");
CREATE UNIQUE INDEX "Payment_stripeCheckoutSessionId_key" ON "Payment"("stripeCheckoutSessionId");
CREATE UNIQUE INDEX "Payment_stripePaymentIntentId_key" ON "Payment"("stripePaymentIntentId");
CREATE UNIQUE INDEX "Payment_stripeEventId_key" ON "Payment"("stripeEventId");
CREATE TABLE "Enquiry" (
"id" TEXT NOT NULL,
"propertyId" TEXT,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"message" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Enquiry_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Enquiry_propertyId_createdAt_idx" ON "Enquiry"("propertyId", "createdAt");
CREATE TABLE "Testimonial" (
"id" TEXT NOT NULL,
"propertyId" TEXT,
"authorName" TEXT NOT NULL,
"content" TEXT NOT NULL,
"rating" INTEGER,
"published" BOOLEAN NOT NULL DEFAULT false,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Testimonial_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Testimonial_published_displayOrder_idx" ON "Testimonial"("published", "displayOrder");
CREATE TABLE "ContentPage" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"status" "ContentPageStatus" NOT NULL DEFAULT 'DRAFT',
"seoTitle" TEXT,
"seoDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContentPage_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ContentPage_slug_key" ON "ContentPage"("slug");
ALTER TABLE "PropertyImage" ADD CONSTRAINT "PropertyImage_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PropertyAmenity" ADD CONSTRAINT "PropertyAmenity_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PropertyAmenity" ADD CONSTRAINT "PropertyAmenity_amenityId_fkey" FOREIGN KEY ("amenityId") REFERENCES "Amenity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PricingRule" ADD CONSTRAINT "PricingRule_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AvailabilityBlock" ADD CONSTRAINT "AvailabilityBlock_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Enquiry" ADD CONSTRAINT "Enquiry_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Testimonial" ADD CONSTRAINT "Testimonial_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE SET NULL ON UPDATE CASCADE;

227
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,227 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum BookingStatus {
PENDING_PAYMENT
PAYMENT_RECEIVED
CONFIRMED
CANCELLED
FAILED
}
enum PaymentStatus {
REQUIRES_PAYMENT
COMPLETED
FAILED
REFUNDED
}
enum AvailabilityBlockReason {
MAINTENANCE
OWNER_BLOCKED
BASE_RULE
OTHER
}
enum ContentPageStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model SiteSettings {
id String @id @default(cuid())
businessName String
tagline String?
contactEmail String?
contactPhone String?
defaultSeoTitle String?
defaultSeoDescription String?
bookingHoldMinutes Int @default(30)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Property {
id String @id @default(cuid())
slug String @unique
title String
summary String
longDescription String
locationText String
sleeps Int
bedrooms Int
bathrooms Int
petsAllowed Boolean @default(false)
published Boolean @default(false)
featured Boolean @default(false)
minStayNights Int?
checkInTime String?
checkOutTime String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
images PropertyImage[]
amenities PropertyAmenity[]
pricingRules PricingRule[]
availability AvailabilityBlock[]
bookings Booking[]
enquiries Enquiry[]
testimonials Testimonial[]
}
model PropertyImage {
id String @id @default(cuid())
propertyId String
url String
altText String
displayOrder Int @default(0)
primaryImage Boolean @default(false)
createdAt DateTime @default(now())
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
@@index([propertyId, displayOrder])
}
model Amenity {
id String @id @default(cuid())
slug String @unique
name String
createdAt DateTime @default(now())
properties PropertyAmenity[]
}
model PropertyAmenity {
propertyId String
amenityId String
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
amenity Amenity @relation(fields: [amenityId], references: [id], onDelete: Cascade)
@@id([propertyId, amenityId])
@@index([amenityId])
}
model PricingRule {
id String @id @default(cuid())
propertyId String
label String?
basePriceCents Int
weekendPriceCents Int?
guestDeltaCents Int?
validFrom DateTime?
validTo DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
@@index([propertyId, validFrom, validTo])
}
model AvailabilityBlock {
id String @id @default(cuid())
propertyId String
startDate DateTime
endDate DateTime
reason AvailabilityBlockReason @default(OTHER)
notes String?
createdAt DateTime @default(now())
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
@@index([propertyId, startDate, endDate])
}
model Booking {
id String @id @default(cuid())
propertyId String
firstName String
lastName String
email String
phone String?
arrivalDate DateTime
departureDate DateTime
adults Int
children Int @default(0)
pets Int @default(0)
specialRequests String?
termsAccepted Boolean @default(false)
holdExpiresAt DateTime?
totalCents Int
currency String @default("GBP")
status BookingStatus @default(PENDING_PAYMENT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
property Property @relation(fields: [propertyId], references: [id], onDelete: Restrict)
payment Payment?
@@index([propertyId, arrivalDate, departureDate])
@@index([status])
}
model Payment {
id String @id @default(cuid())
bookingId String @unique
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String? @unique
stripeEventId String? @unique
amountCents Int
currency String @default("GBP")
status PaymentStatus @default(REQUIRES_PAYMENT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
}
model Enquiry {
id String @id @default(cuid())
propertyId String?
name String
email String
phone String?
message String
createdAt DateTime @default(now())
property Property? @relation(fields: [propertyId], references: [id], onDelete: SetNull)
@@index([propertyId, createdAt])
}
model Testimonial {
id String @id @default(cuid())
propertyId String?
authorName String
content String
rating Int?
published Boolean @default(false)
displayOrder Int @default(0)
createdAt DateTime @default(now())
property Property? @relation(fields: [propertyId], references: [id], onDelete: SetNull)
@@index([published, displayOrder])
}
model ContentPage {
id String @id @default(cuid())
slug String @unique
title String
body String
status ContentPageStatus @default(DRAFT)
seoTitle String?
seoDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

31
prisma/seed.ts Normal file
View File

@@ -0,0 +1,31 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const existing = await prisma.siteSettings.findFirst();
if (existing) {
return;
}
await prisma.siteSettings.create({
data: {
businessName: 'Holiday Property Booking',
tagline: 'Curated stays, clear availability, and a direct booking flow.',
contactEmail: 'hello@example.com',
defaultSeoTitle: 'Holiday Property Booking',
defaultSeoDescription: 'Book holiday properties with live availability, clear pricing, and secure checkout.',
},
});
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});