Base app architecture:

    Route groups for customer, staff and owner.
* Reusable design system with around 20 UI components.
* Complete Prisma schema.
* Authentication with email/password and Google.
* Role and permission model.
* Protected routes.
* Clean separation between features, services, components and shared utilities.
This commit is contained in:
Stefan Lazic committed 2026-08-02 17:47:23 +01:00
1 parent 468d163299
commit 090646fdfd
201 files changed
+31984

No files matched your search

@@ -0,0 +1,729 @@
-- CreateEnum
CREATE TYPE "Weekday" AS ENUM ('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY');
-- CreateEnum
CREATE TYPE "Permission" AS ENUM ('MANAGE_BUSINESS_PROFILE', 'MANAGE_BOOKING_RULES', 'MANAGE_SERVICES', 'MANAGE_STAFF', 'MANAGE_LOYALTY_PROGRAMME', 'MANAGE_NOTIFICATION_SETTINGS', 'VIEW_ALL_STAFF_APPOINTMENTS', 'MANAGE_OWN_APPOINTMENTS', 'CHECK_IN_CUSTOMERS', 'MANAGE_LOYALTY_STAMPS', 'MANAGE_OWN_AVAILABILITY', 'VIEW_CUSTOMER_DIRECTORY', 'BOOK_ON_BEHALF_OF_CUSTOMER');
-- CreateEnum
CREATE TYPE "StaffStatus" AS ENUM ('ACTIVE', 'ON_BREAK', 'INACTIVE');
-- CreateEnum
CREATE TYPE "AppointmentStatus" AS ENUM ('PENDING_APPROVAL', 'CONFIRMED', 'ARRIVED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW');
-- CreateEnum
CREATE TYPE "TimeOffStatus" AS ENUM ('PENDING', 'APPROVED', 'DECLINED');
-- CreateEnum
CREATE TYPE "RewardType" AS ENUM ('PERCENTAGE', 'FIXED_AMOUNT', 'FREE_SERVICE');
-- CreateEnum
CREATE TYPE "RewardStatus" AS ENUM ('AVAILABLE', 'REDEEMED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "LoyaltyTransactionType" AS ENUM ('STAMP_ADDED', 'STAMP_REMOVED', 'STAMP_EXPIRED', 'REWARD_REDEEMED');
-- CreateEnum
CREATE TYPE "NotificationCategory" AS ENUM ('BOOKING_CONFIRMATION', 'BOOKING_REMINDER', 'BOOKING_CHANGED', 'BOOKING_CANCELLED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_AVAILABLE', 'MARKETING');
-- CreateEnum
CREATE TYPE "NotificationChannel" AS ENUM ('EMAIL', 'SMS', 'PUSH');
-- CreateEnum
CREATE TYPE "NotificationStatus" AS ENUM ('PENDING', 'SENT', 'FAILED');
-- CreateTable
CREATE TABLE "Business" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"logoUrl" TEXT,
"phone" TEXT,
"email" TEXT,
"website" TEXT,
"instagramUrl" TEXT,
"facebookUrl" TEXT,
"timezone" TEXT NOT NULL DEFAULT 'Europe/London',
"currency" TEXT NOT NULL DEFAULT 'GBP',
"locale" TEXT NOT NULL DEFAULT 'en-GB',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Business_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BusinessLocation" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT 'Main Location',
"addressLine1" TEXT NOT NULL,
"addressLine2" TEXT,
"city" TEXT NOT NULL,
"region" TEXT,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL DEFAULT 'GB',
"latitude" DECIMAL(9,6),
"longitude" DECIMAL(9,6),
"isPrimary" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BusinessLocation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BusinessHours" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"dayOfWeek" "Weekday" NOT NULL,
"isClosed" BOOLEAN NOT NULL DEFAULT false,
"opensAtMinutes" INTEGER,
"closesAtMinutes" INTEGER,
CONSTRAINT "BusinessHours_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BusinessSettings" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"cancellationPolicy" TEXT,
"lateArrivalPolicy" TEXT,
"bookingTerms" TEXT,
"privacyPolicyUrl" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BusinessSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BookingRule" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"minNoticeMinutes" INTEGER NOT NULL DEFAULT 120,
"maxAdvanceDays" INTEGER NOT NULL DEFAULT 60,
"cancellationWindowHours" INTEGER NOT NULL DEFAULT 24,
"rescheduleWindowHours" INTEGER NOT NULL DEFAULT 24,
"slotIntervalMinutes" INTEGER NOT NULL DEFAULT 30,
"bufferBeforeMinutes" INTEGER NOT NULL DEFAULT 0,
"bufferAfterMinutes" INTEGER NOT NULL DEFAULT 5,
"maxAppointmentsPerDay" INTEGER,
"customerCanChooseStaff" BOOLEAN NOT NULL DEFAULT true,
"allowGuestBooking" BOOLEAN NOT NULL DEFAULT true,
"requireApproval" BOOLEAN NOT NULL DEFAULT false,
"requireDeposit" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BookingRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"phone" TEXT,
"phoneVerified" TIMESTAMP(3),
"image" TEXT,
"passwordHash" TEXT,
"isSuperAdmin" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "CustomerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"isGuest" BOOLEAN NOT NULL DEFAULT true,
"preferredBarberId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomerProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StaffProfile" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"bio" TEXT,
"status" "StaffStatus" NOT NULL DEFAULT 'ACTIVE',
"isBookableOnline" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StaffProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isSystem" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"id" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"permission" "Permission" NOT NULL,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ServiceCategory" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ServiceCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Service" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"durationMinutes" INTEGER NOT NULL,
"price" DECIMAL(10,2) NOT NULL,
"bufferBeforeMinutes" INTEGER NOT NULL DEFAULT 0,
"bufferAfterMinutes" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"allowOnlineBooking" BOOLEAN NOT NULL DEFAULT true,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Service_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StaffService" (
"id" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"priceOverride" DECIMAL(10,2),
"durationOverrideMinutes" INTEGER,
CONSTRAINT "StaffService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkingHours" (
"id" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"dayOfWeek" "Weekday" NOT NULL,
"isOff" BOOLEAN NOT NULL DEFAULT false,
"startMinutes" INTEGER,
"endMinutes" INTEGER,
CONSTRAINT "WorkingHours_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Break" (
"id" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"startMinutes" INTEGER NOT NULL,
"endMinutes" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Break_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BreakDay" (
"id" TEXT NOT NULL,
"breakId" TEXT NOT NULL,
"dayOfWeek" "Weekday" NOT NULL,
CONSTRAINT "BreakDay_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TimeOff" (
"id" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"startDate" DATE NOT NULL,
"endDate" DATE NOT NULL,
"reason" TEXT,
"status" "TimeOffStatus" NOT NULL DEFAULT 'APPROVED',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TimeOff_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Appointment" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"status" "AppointmentStatus" NOT NULL DEFAULT 'CONFIRMED',
"startAt" TIMESTAMP(3) NOT NULL,
"endAt" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"cancellationReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Appointment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AppointmentService" (
"id" TEXT NOT NULL,
"appointmentId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"priceAtBooking" DECIMAL(10,2) NOT NULL,
"durationAtBookingMinutes" INTEGER NOT NULL,
CONSTRAINT "AppointmentService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyProgramme" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT false,
"stampsRequired" INTEGER NOT NULL DEFAULT 10,
"rewardType" "RewardType" NOT NULL DEFAULT 'PERCENTAGE',
"rewardValue" DECIMAL(10,2),
"rewardLabel" TEXT,
"maxRewardValue" DECIMAL(10,2),
"autoApplyReward" BOOLEAN NOT NULL DEFAULT true,
"requiresStaffApproval" BOOLEAN NOT NULL DEFAULT false,
"stampsExpireAfterDays" INTEGER,
"rewardExpiresAfterDays" INTEGER,
"earnOnCancelled" BOOLEAN NOT NULL DEFAULT false,
"earnOnNoShow" BOOLEAN NOT NULL DEFAULT false,
"cardBackgroundColor" TEXT,
"cardBackgroundImageUrl" TEXT,
"stampIcon" TEXT,
"termsLine" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltyProgramme_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyEligibleService" (
"id" TEXT NOT NULL,
"loyaltyProgrammeId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
CONSTRAINT "LoyaltyEligibleService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyEligibleStaff" (
"id" TEXT NOT NULL,
"loyaltyProgrammeId" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
CONSTRAINT "LoyaltyEligibleStaff_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyAccount" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"loyaltyProgrammeId" TEXT NOT NULL,
"currentStamps" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltyAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyTransaction" (
"id" TEXT NOT NULL,
"loyaltyAccountId" TEXT NOT NULL,
"type" "LoyaltyTransactionType" NOT NULL,
"amount" INTEGER NOT NULL,
"reason" TEXT,
"appointmentId" TEXT,
"authoredByStaffId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoyaltyTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyReward" (
"id" TEXT NOT NULL,
"loyaltyAccountId" TEXT NOT NULL,
"rewardType" "RewardType" NOT NULL,
"value" DECIMAL(10,2),
"label" TEXT NOT NULL,
"status" "RewardStatus" NOT NULL DEFAULT 'AVAILABLE',
"appliedToAppointmentId" TEXT,
"redeemedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoyaltyReward_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"userId" TEXT,
"appointmentId" TEXT,
"category" "NotificationCategory" NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"status" "NotificationStatus" NOT NULL DEFAULT 'PENDING',
"subject" TEXT,
"body" TEXT NOT NULL,
"sentAt" TIMESTAMP(3),
"failureReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotificationPreference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"category" "NotificationCategory" NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustomerNote" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"authorStaffId" TEXT NOT NULL,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"actorUserId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Business_slug_key" ON "Business"("slug");
-- CreateIndex
CREATE INDEX "BusinessLocation_businessId_idx" ON "BusinessLocation"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "BusinessHours_businessId_dayOfWeek_key" ON "BusinessHours"("businessId", "dayOfWeek");
-- CreateIndex
CREATE UNIQUE INDEX "BusinessSettings_businessId_key" ON "BusinessSettings"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "BookingRule_businessId_key" ON "BookingRule"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "CustomerProfile_userId_key" ON "CustomerProfile"("userId");
-- CreateIndex
CREATE INDEX "CustomerProfile_email_idx" ON "CustomerProfile"("email");
-- CreateIndex
CREATE INDEX "CustomerProfile_phone_idx" ON "CustomerProfile"("phone");
-- CreateIndex
CREATE INDEX "CustomerProfile_preferredBarberId_idx" ON "CustomerProfile"("preferredBarberId");
-- CreateIndex
CREATE UNIQUE INDEX "StaffProfile_userId_key" ON "StaffProfile"("userId");
-- CreateIndex
CREATE INDEX "StaffProfile_businessId_idx" ON "StaffProfile"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "Role_businessId_name_key" ON "Role"("businessId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "RolePermission_roleId_permission_key" ON "RolePermission"("roleId", "permission");
-- CreateIndex
CREATE INDEX "ServiceCategory_businessId_idx" ON "ServiceCategory"("businessId");
-- CreateIndex
CREATE INDEX "Service_businessId_idx" ON "Service"("businessId");
-- CreateIndex
CREATE INDEX "Service_categoryId_idx" ON "Service"("categoryId");
-- CreateIndex
CREATE UNIQUE INDEX "StaffService_staffId_serviceId_key" ON "StaffService"("staffId", "serviceId");
-- CreateIndex
CREATE UNIQUE INDEX "WorkingHours_staffId_dayOfWeek_key" ON "WorkingHours"("staffId", "dayOfWeek");
-- CreateIndex
CREATE UNIQUE INDEX "BreakDay_breakId_dayOfWeek_key" ON "BreakDay"("breakId", "dayOfWeek");
-- CreateIndex
CREATE INDEX "TimeOff_staffId_startDate_endDate_idx" ON "TimeOff"("staffId", "startDate", "endDate");
-- CreateIndex
CREATE INDEX "Appointment_staffId_startAt_idx" ON "Appointment"("staffId", "startAt");
-- CreateIndex
CREATE INDEX "Appointment_businessId_startAt_idx" ON "Appointment"("businessId", "startAt");
-- CreateIndex
CREATE INDEX "Appointment_customerId_idx" ON "Appointment"("customerId");
-- CreateIndex
CREATE INDEX "AppointmentService_appointmentId_idx" ON "AppointmentService"("appointmentId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyProgramme_businessId_key" ON "LoyaltyProgramme"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyEligibleService_loyaltyProgrammeId_serviceId_key" ON "LoyaltyEligibleService"("loyaltyProgrammeId", "serviceId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyEligibleStaff_loyaltyProgrammeId_staffId_key" ON "LoyaltyEligibleStaff"("loyaltyProgrammeId", "staffId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyAccount_customerId_loyaltyProgrammeId_key" ON "LoyaltyAccount"("customerId", "loyaltyProgrammeId");
-- CreateIndex
CREATE INDEX "LoyaltyTransaction_loyaltyAccountId_idx" ON "LoyaltyTransaction"("loyaltyAccountId");
-- CreateIndex
CREATE INDEX "LoyaltyReward_loyaltyAccountId_idx" ON "LoyaltyReward"("loyaltyAccountId");
-- CreateIndex
CREATE INDEX "Notification_userId_idx" ON "Notification"("userId");
-- CreateIndex
CREATE INDEX "Notification_appointmentId_idx" ON "Notification"("appointmentId");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationPreference_userId_category_channel_key" ON "NotificationPreference"("userId", "category", "channel");
-- CreateIndex
CREATE INDEX "CustomerNote_customerId_idx" ON "CustomerNote"("customerId");
-- CreateIndex
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
-- CreateIndex
CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
-- AddForeignKey
ALTER TABLE "BusinessLocation" ADD CONSTRAINT "BusinessLocation_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BusinessHours" ADD CONSTRAINT "BusinessHours_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BusinessSettings" ADD CONSTRAINT "BusinessSettings_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingRule" ADD CONSTRAINT "BookingRule_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerProfile" ADD CONSTRAINT "CustomerProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerProfile" ADD CONSTRAINT "CustomerProfile_preferredBarberId_fkey" FOREIGN KEY ("preferredBarberId") REFERENCES "StaffProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffProfile" ADD CONSTRAINT "StaffProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffProfile" ADD CONSTRAINT "StaffProfile_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffProfile" ADD CONSTRAINT "StaffProfile_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Role" ADD CONSTRAINT "Role_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServiceCategory" ADD CONSTRAINT "ServiceCategory_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ServiceCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffService" ADD CONSTRAINT "StaffService_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffService" ADD CONSTRAINT "StaffService_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkingHours" ADD CONSTRAINT "WorkingHours_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Break" ADD CONSTRAINT "Break_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BreakDay" ADD CONSTRAINT "BreakDay_breakId_fkey" FOREIGN KEY ("breakId") REFERENCES "Break"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TimeOff" ADD CONSTRAINT "TimeOff_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "CustomerProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AppointmentService" ADD CONSTRAINT "AppointmentService_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AppointmentService" ADD CONSTRAINT "AppointmentService_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyProgramme" ADD CONSTRAINT "LoyaltyProgramme_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyEligibleService" ADD CONSTRAINT "LoyaltyEligibleService_loyaltyProgrammeId_fkey" FOREIGN KEY ("loyaltyProgrammeId") REFERENCES "LoyaltyProgramme"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyEligibleService" ADD CONSTRAINT "LoyaltyEligibleService_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyEligibleStaff" ADD CONSTRAINT "LoyaltyEligibleStaff_loyaltyProgrammeId_fkey" FOREIGN KEY ("loyaltyProgrammeId") REFERENCES "LoyaltyProgramme"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "CustomerProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_loyaltyProgrammeId_fkey" FOREIGN KEY ("loyaltyProgrammeId") REFERENCES "LoyaltyProgramme"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyTransaction" ADD CONSTRAINT "LoyaltyTransaction_loyaltyAccountId_fkey" FOREIGN KEY ("loyaltyAccountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyTransaction" ADD CONSTRAINT "LoyaltyTransaction_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyTransaction" ADD CONSTRAINT "LoyaltyTransaction_authoredByStaffId_fkey" FOREIGN KEY ("authoredByStaffId") REFERENCES "StaffProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_loyaltyAccountId_fkey" FOREIGN KEY ("loyaltyAccountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerNote" ADD CONSTRAINT "CustomerNote_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "CustomerProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerNote" ADD CONSTRAINT "CustomerNote_authorStaffId_fkey" FOREIGN KEY ("authorStaffId") REFERENCES "StaffProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+827
View File
@@ -0,0 +1,827 @@
// NextChair — Phase 2 database schema.
//
// Portability: every model deliberately avoids Postgres-only features
// (native arrays, JSONB-specific operators, LISTEN/NOTIFY) so the
// `datasource.provider` below can later change to "mysql" with a
// `prisma migrate diff` instead of a schema rewrite. Concretely:
// - no `String[]` / scalar-list columns anywhere (see BreakDay join table)
// - `Decimal`, `Json`, `@db.Date`, `@db.Text` are used because Prisma maps
// all four to native, equivalent types on both Postgres and MySQL
// - recurring wall-clock times (working hours, breaks) are stored as
// `Int` minutes-from-midnight rather than a DB TIME type, so scheduling
// math lives in application code, not in provider-specific SQL
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
// Connection URL lives in prisma.config.ts (Prisma 7) and is passed to
// PrismaClient at runtime via a driver adapter — see src/lib/db/prisma.ts.
datasource db {
provider = "postgresql"
}
// ─────────────────────────────────────────────────────────────────────────
// Enums
// ─────────────────────────────────────────────────────────────────────────
enum Weekday {
MONDAY
TUESDAY
WEDNESDAY
THURSDAY
FRIDAY
SATURDAY
SUNDAY
}
/// Code-defined permission set, granted to a Role via RolePermission.
/// Kept as an enum (not a user-editable table) — the set of possible
/// actions is defined by the application, not by end users, in v1.
enum Permission {
MANAGE_BUSINESS_PROFILE
MANAGE_BOOKING_RULES
MANAGE_SERVICES
MANAGE_STAFF
MANAGE_LOYALTY_PROGRAMME
MANAGE_NOTIFICATION_SETTINGS
VIEW_ALL_STAFF_APPOINTMENTS
MANAGE_OWN_APPOINTMENTS
CHECK_IN_CUSTOMERS
MANAGE_LOYALTY_STAMPS
MANAGE_OWN_AVAILABILITY
VIEW_CUSTOMER_DIRECTORY
BOOK_ON_BEHALF_OF_CUSTOMER
}
enum StaffStatus {
ACTIVE
ON_BREAK
INACTIVE
}
enum AppointmentStatus {
PENDING_APPROVAL
CONFIRMED
ARRIVED
IN_PROGRESS
COMPLETED
CANCELLED
NO_SHOW
}
enum TimeOffStatus {
PENDING
APPROVED
DECLINED
}
enum RewardType {
PERCENTAGE
FIXED_AMOUNT
FREE_SERVICE
}
enum RewardStatus {
AVAILABLE
REDEEMED
EXPIRED
}
enum LoyaltyTransactionType {
STAMP_ADDED
STAMP_REMOVED
STAMP_EXPIRED
REWARD_REDEEMED
}
enum NotificationCategory {
BOOKING_CONFIRMATION
BOOKING_REMINDER
BOOKING_CHANGED
BOOKING_CANCELLED
LOYALTY_REWARD_EARNED
LOYALTY_REWARD_AVAILABLE
MARKETING
}
enum NotificationChannel {
EMAIL
SMS
PUSH
}
enum NotificationStatus {
PENDING
SENT
FAILED
}
// ─────────────────────────────────────────────────────────────────────────
// Business
// ─────────────────────────────────────────────────────────────────────────
/// The tenant. v1 ships with exactly one row, seeded at setup time, but
/// every other model hangs off `businessId` rather than assuming a
/// singleton so multi-business support later is additive, not a rewrite.
model Business {
id String @id @default(cuid())
slug String @unique
name String
description String? @db.Text
logoUrl String?
phone String?
email String?
website String?
instagramUrl String?
facebookUrl String?
/// IANA timezone, e.g. "Europe/London". All wall-clock scheduling math
/// (working hours, breaks, booking rules) is interpreted in this zone.
timezone String @default("Europe/London")
/// ISO 4217 currency code, e.g. "GBP".
currency String @default("GBP")
locale String @default("en-GB")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
locations BusinessLocation[]
hours BusinessHours[]
settings BusinessSettings?
bookingRule BookingRule?
serviceCategories ServiceCategory[]
services Service[]
staff StaffProfile[]
roles Role[]
appointments Appointment[]
loyaltyProgramme LoyaltyProgramme?
}
/// Split from Business (rather than address fields on Business itself) so
/// a second location is an additive row later, not a schema migration.
/// v1 only ever creates one row per business and treats it as primary.
model BusinessLocation {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
name String @default("Main Location")
addressLine1 String
addressLine2 String?
city String
region String?
postalCode String
country String @default("GB")
latitude Decimal? @db.Decimal(9, 6)
longitude Decimal? @db.Decimal(9, 6)
isPrimary Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([businessId])
}
/// Recurring weekly opening hours for the business as a whole (distinct
/// from a staff member's individual WorkingHours).
model BusinessHours {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
dayOfWeek Weekday
isClosed Boolean @default(false)
/// Minutes since midnight, e.g. 600 = 10:00. Ignored when isClosed.
opensAtMinutes Int?
closesAtMinutes Int?
@@unique([businessId, dayOfWeek])
}
model BusinessSettings {
id String @id @default(cuid())
businessId String @unique
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
cancellationPolicy String? @db.Text
lateArrivalPolicy String? @db.Text
bookingTerms String? @db.Text
privacyPolicyUrl String?
updatedAt DateTime @updatedAt
}
/// Scheduling policy — see docs/implementation-plan.md §6/§16. Every field
/// has a sane default so the row can be created once at business setup and
/// tuned later without a migration.
model BookingRule {
id String @id @default(cuid())
businessId String @unique
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
minNoticeMinutes Int @default(120)
maxAdvanceDays Int @default(60)
cancellationWindowHours Int @default(24)
rescheduleWindowHours Int @default(24)
slotIntervalMinutes Int @default(30)
bufferBeforeMinutes Int @default(0)
bufferAfterMinutes Int @default(5)
/// null = no daily cap.
maxAppointmentsPerDay Int?
customerCanChooseStaff Boolean @default(true)
allowGuestBooking Boolean @default(true)
requireApproval Boolean @default(false)
/// Modelled now, not enforced anywhere yet — deposits are a future
/// payments phase (see docs/implementation-plan.md §6, "later phase").
requireDeposit Boolean @default(false)
updatedAt DateTime @updatedAt
}
// ─────────────────────────────────────────────────────────────────────────
// Auth (Auth.js / NextAuth v5 + Prisma adapter standard shape)
// ─────────────────────────────────────────────────────────────────────────
/// The single identity table for every human in the system — customers,
/// staff, and owners all authenticate as a User; role/permission comes
/// from whichever profile (CustomerProfile / StaffProfile) hangs off it.
/// A staff member who is also an owner is one User with one StaffProfile
/// whose Role carries the extra permissions — never two accounts.
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
/// E.164 format. Nullable + unique now so phone-based login (brief:
/// "design so phone login can easily be added later") is additive.
phone String? @unique
phoneVerified DateTime?
image String?
/// null for OAuth-only users (no password set).
passwordHash String?
/// Platform-level flag, separate from the business Role/Permission
/// model — out of scope to exercise in Phase 2, but the brief's "Future
/// Roles" note calls out a platform administrator explicitly.
isSuperAdmin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
customerProfile CustomerProfile?
staffProfile StaffProfile?
notifications Notification[]
notificationPreferences NotificationPreference[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ─────────────────────────────────────────────────────────────────────────
// People
// ─────────────────────────────────────────────────────────────────────────
/// userId is optional so a guest booking can create a CustomerProfile with
/// no account at all. Deliberately NOT unique-constrained on email/phone —
/// guest duplicate-detection is an explicit open question in
/// docs/implementation-plan.md §15 (Question 4); a hard constraint here
/// would force a premature answer and break repeat guest bookings under
/// a shared household email/phone.
model CustomerProfile {
id String @id @default(cuid())
userId String? @unique
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
firstName String
lastName String
email String
phone String?
isGuest Boolean @default(true)
preferredBarberId String?
preferredBarber StaffProfile? @relation("PreferredBarber", fields: [preferredBarberId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
appointments Appointment[]
loyaltyAccounts LoyaltyAccount[]
notes CustomerNote[]
@@index([email])
@@index([phone])
@@index([preferredBarberId])
}
model StaffProfile {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
roleId String
role Role @relation(fields: [roleId], references: [id])
bio String? @db.Text
status StaffStatus @default(ACTIVE)
isBookableOnline Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
services StaffService[]
workingHours WorkingHours[]
breaks Break[]
timeOff TimeOff[]
appointments Appointment[]
preferredByCustomers CustomerProfile[] @relation("PreferredBarber")
authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor")
authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor")
@@index([businessId])
}
/// businessId-scoped so future multi-business use never leaks one
/// business's custom roles into another's role picker.
model Role {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
name String
/// true for the seeded Barber/Owner roles; false for anything an owner
/// creates later (e.g. Receptionist) — lets the UI protect system roles
/// from deletion without a separate table.
isSystem Boolean @default(true)
createdAt DateTime @default(now())
permissions RolePermission[]
staff StaffProfile[]
@@unique([businessId, name])
}
model RolePermission {
id String @id @default(cuid())
roleId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission
@@unique([roleId, permission])
}
// ─────────────────────────────────────────────────────────────────────────
// Catalog
// ─────────────────────────────────────────────────────────────────────────
model ServiceCategory {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
name String
displayOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
services Service[]
@@index([businessId])
}
model Service {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
categoryId String
category ServiceCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
name String
description String? @db.Text
durationMinutes Int
/// Standard SQL DECIMAL — supported natively on both Postgres and MySQL.
price Decimal @db.Decimal(10, 2)
bufferBeforeMinutes Int @default(0)
bufferAfterMinutes Int @default(0)
isActive Boolean @default(true)
allowOnlineBooking Boolean @default(true)
displayOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
staff StaffService[]
appointmentServices AppointmentService[]
loyaltyEligibleFor LoyaltyEligibleService[]
@@index([businessId])
@@index([categoryId])
}
/// Which staff can perform which service — the brief explicitly requires
/// barber-specific service availability. An optional price/duration
/// override supports a senior barber charging more for the same service.
model StaffService {
id String @id @default(cuid())
staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
serviceId String
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
priceOverride Decimal? @db.Decimal(10, 2)
durationOverrideMinutes Int?
@@unique([staffId, serviceId])
}
// ─────────────────────────────────────────────────────────────────────────
// Scheduling (availability inputs — see docs/implementation-plan.md §14)
// ─────────────────────────────────────────────────────────────────────────
model WorkingHours {
id String @id @default(cuid())
staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
dayOfWeek Weekday
isOff Boolean @default(false)
startMinutes Int?
endMinutes Int?
@@unique([staffId, dayOfWeek])
}
/// A recurring break (e.g. daily lunch) can apply to several days, so the
/// day list is a join table (BreakDay) rather than a scalar array column —
/// MySQL has no native array type, and this schema is meant to survive a
/// provider swap without a redesign.
model Break {
id String @id @default(cuid())
staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
label String
startMinutes Int
endMinutes Int
createdAt DateTime @default(now())
days BreakDay[]
}
model BreakDay {
id String @id @default(cuid())
breakId String
break Break @relation(fields: [breakId], references: [id], onDelete: Cascade)
dayOfWeek Weekday
@@unique([breakId, dayOfWeek])
}
model TimeOff {
id String @id @default(cuid())
staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
startDate DateTime @db.Date
endDate DateTime @db.Date
reason String?
status TimeOffStatus @default(APPROVED)
createdAt DateTime @default(now())
@@index([staffId, startDate, endDate])
}
// ─────────────────────────────────────────────────────────────────────────
// Booking
// ─────────────────────────────────────────────────────────────────────────
model Appointment {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
customerId String
customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade)
staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Restrict)
status AppointmentStatus @default(CONFIRMED)
/// Stored as UTC instants; the business's `timezone` (see Business model)
/// is applied at the presentation/query layer, so DST transitions are a
/// rendering concern, not a storage one.
startAt DateTime
endAt DateTime
notes String? @db.Text
cancellationReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
services AppointmentService[]
loyaltyTransactions LoyaltyTransaction[]
notifications Notification[]
@@index([staffId, startAt])
@@index([businessId, startAt])
@@index([customerId])
}
/// One appointment can carry multiple services (the booking flow's "Add
/// another service" step). price/duration are snapshotted at booking time
/// so a later Service price change never rewrites booking history.
model AppointmentService {
id String @id @default(cuid())
appointmentId String
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
serviceId String
service Service @relation(fields: [serviceId], references: [id], onDelete: Restrict)
priceAtBooking Decimal @db.Decimal(10, 2)
durationAtBookingMinutes Int
@@index([appointmentId])
}
// ─────────────────────────────────────────────────────────────────────────
// Loyalty — ledger-based by design (brief: "never simply store a stamp
// count"; every stamp add/remove/redemption is its own row).
// ─────────────────────────────────────────────────────────────────────────
model LoyaltyProgramme {
id String @id @default(cuid())
businessId String @unique
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
isEnabled Boolean @default(false)
stampsRequired Int @default(10)
rewardType RewardType @default(PERCENTAGE)
/// Percentage points or a fixed-amount value depending on rewardType;
/// null when rewardType is FREE_SERVICE.
rewardValue Decimal? @db.Decimal(10, 2)
rewardLabel String?
maxRewardValue Decimal? @db.Decimal(10, 2)
autoApplyReward Boolean @default(true)
requiresStaffApproval Boolean @default(false)
stampsExpireAfterDays Int?
rewardExpiresAfterDays Int?
earnOnCancelled Boolean @default(false)
earnOnNoShow Boolean @default(false)
cardBackgroundColor String?
cardBackgroundImageUrl String?
stampIcon String?
termsLine String?
updatedAt DateTime @updatedAt
/// An empty eligibleServices/eligibleStaff list means "all services /
/// all staff are eligible" — an explicit row is only needed to *restrict*
/// eligibility, so the common "loyalty applies to everything" case
/// requires zero extra rows.
eligibleServices LoyaltyEligibleService[]
eligibleStaff LoyaltyEligibleStaff[]
accounts LoyaltyAccount[]
}
model LoyaltyEligibleService {
id String @id @default(cuid())
loyaltyProgrammeId String
loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade)
serviceId String
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
@@unique([loyaltyProgrammeId, serviceId])
}
model LoyaltyEligibleStaff {
id String @id @default(cuid())
loyaltyProgrammeId String
loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade)
staffId String
@@unique([loyaltyProgrammeId, staffId])
}
model LoyaltyAccount {
id String @id @default(cuid())
customerId String
customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade)
loyaltyProgrammeId String
loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade)
/// Cached running total for fast reads. Always derived from — and
/// reconciled against — `transactions`; the ledger, not this column, is
/// the source of truth (brief requirement).
currentStamps Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions LoyaltyTransaction[]
rewards LoyaltyReward[]
@@unique([customerId, loyaltyProgrammeId])
}
model LoyaltyTransaction {
id String @id @default(cuid())
loyaltyAccountId String
loyaltyAccount LoyaltyAccount @relation(fields: [loyaltyAccountId], references: [id], onDelete: Cascade)
type LoyaltyTransactionType
/// +1 for a stamp, -1 for a staff correction, etc.
amount Int
reason String?
appointmentId String?
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
authoredByStaffId String?
authoredByStaff StaffProfile? @relation("LoyaltyTransactionAuthor", fields: [authoredByStaffId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([loyaltyAccountId])
}
/// A completed/redeemed reward is recorded here *before* the account's
/// active stamp count resets, per the brief's explicit requirement.
model LoyaltyReward {
id String @id @default(cuid())
loyaltyAccountId String
loyaltyAccount LoyaltyAccount @relation(fields: [loyaltyAccountId], references: [id], onDelete: Cascade)
rewardType RewardType
value Decimal? @db.Decimal(10, 2)
label String
status RewardStatus @default(AVAILABLE)
appliedToAppointmentId String?
redeemedAt DateTime?
expiresAt DateTime?
createdAt DateTime @default(now())
@@index([loyaltyAccountId])
}
// ─────────────────────────────────────────────────────────────────────────
// Notifications — abstracted behind category + channel so an SMS/push
// provider is a new NotificationChannel value and a sender implementation,
// never a schema change.
// ─────────────────────────────────────────────────────────────────────────
model Notification {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
appointmentId String?
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
category NotificationCategory
channel NotificationChannel
status NotificationStatus @default(PENDING)
subject String?
body String @db.Text
sentAt DateTime?
failureReason String?
createdAt DateTime @default(now())
@@index([userId])
@@index([appointmentId])
}
model NotificationPreference {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
category NotificationCategory
channel NotificationChannel
isEnabled Boolean @default(true)
@@unique([userId, category, channel])
}
// ─────────────────────────────────────────────────────────────────────────
// Misc
// ─────────────────────────────────────────────────────────────────────────
model CustomerNote {
id String @id @default(cuid())
customerId String
customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade)
authorStaffId String
author StaffProfile @relation("CustomerNoteAuthor", fields: [authorStaffId], references: [id], onDelete: Restrict)
body String @db.Text
createdAt DateTime @default(now())
@@index([customerId])
}
/// Generic audit trail for staff/owner actions on sensitive records
/// (permission changes, business setting edits). Not written to by
/// anything in Phase 2 — the table exists now so Phase 4/6 features don't
/// need a migration to start using it.
model AuditLog {
id String @id @default(cuid())
actorUserId String?
action String
entityType String
entityId String
/// `Json` maps to native `jsonb`/`json` on both Postgres and MySQL.
metadata Json?
createdAt DateTime @default(now())
@@index([entityType, entityId])
@@index([actorUserId])
}
+251
View File
@@ -0,0 +1,251 @@
/**
* Dev/demo fixtures. Run with `npm run prisma:seed` (requires a reachable
* DATABASE_URL — see .env.example). Intentionally small: enough to exercise
* every relation in the schema once, not a full demo dataset.
*/
import "dotenv/config";
import { PrismaClient, Permission, Weekday, RewardType } from "../generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import bcrypt from "bcryptjs";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
const OWNER_PERMISSIONS: Permission[] = [
Permission.MANAGE_BUSINESS_PROFILE,
Permission.MANAGE_BOOKING_RULES,
Permission.MANAGE_SERVICES,
Permission.MANAGE_STAFF,
Permission.MANAGE_LOYALTY_PROGRAMME,
Permission.MANAGE_NOTIFICATION_SETTINGS,
Permission.VIEW_ALL_STAFF_APPOINTMENTS,
Permission.MANAGE_OWN_APPOINTMENTS,
Permission.CHECK_IN_CUSTOMERS,
Permission.MANAGE_LOYALTY_STAMPS,
Permission.MANAGE_OWN_AVAILABILITY,
Permission.VIEW_CUSTOMER_DIRECTORY,
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
];
const BARBER_PERMISSIONS: Permission[] = [
Permission.MANAGE_OWN_APPOINTMENTS,
Permission.CHECK_IN_CUSTOMERS,
Permission.MANAGE_LOYALTY_STAMPS,
Permission.MANAGE_OWN_AVAILABILITY,
Permission.VIEW_CUSTOMER_DIRECTORY,
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
];
const WEEKDAYS: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
];
async function main() {
const business = await prisma.business.upsert({
where: { slug: "blake-heritage-barbershop" },
update: {},
create: {
slug: "blake-heritage-barbershop",
name: "Blake Heritage Barbershop",
description:
"Blake Heritage has come to Northwood! Check our Instagram @Blake.Heritage.Barbershop for more info.",
phone: "+44 1923 000000",
email: "hello@blakeheritage.co.uk",
timezone: "Europe/London",
currency: "GBP",
locale: "en-GB",
locations: {
create: {
addressLine1: "79 Joel Street",
city: "Northwood",
postalCode: "HA6 1LW",
country: "GB",
},
},
settings: {
create: {
cancellationPolicy: "Free cancellation up to 24 hours before your appointment.",
lateArrivalPolicy: "Arrivals more than 10 minutes late may need to be rescheduled.",
},
},
bookingRule: { create: {} }, // all defaults from the schema
hours: {
create: [
...WEEKDAYS.map((dayOfWeek) => ({
dayOfWeek,
opensAtMinutes: 10 * 60,
closesAtMinutes: 19 * 60,
})),
{ dayOfWeek: Weekday.SUNDAY, isClosed: true },
],
},
loyaltyProgramme: {
create: {
isEnabled: true,
stampsRequired: 10,
rewardType: RewardType.PERCENTAGE,
rewardValue: 50,
rewardLabel: "50% Off Any Service",
termsLine: "Valid for 12 months. One reward per visit.",
},
},
},
});
const ownerRole = await prisma.role.upsert({
where: { businessId_name: { businessId: business.id, name: "Owner" } },
update: {},
create: {
businessId: business.id,
name: "Owner",
isSystem: true,
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
},
});
const barberRole = await prisma.role.upsert({
where: { businessId_name: { businessId: business.id, name: "Barber" } },
update: {},
create: {
businessId: business.id,
name: "Barber",
isSystem: true,
permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) },
},
});
const category = await prisma.serviceCategory.upsert({
where: { id: `${business.id}-senior-barber` },
update: {},
create: {
id: `${business.id}-senior-barber`,
businessId: business.id,
name: "Hair & Beard Services — Senior Barber",
},
});
const [haircut, skinfade] = await Promise.all([
prisma.service.upsert({
where: { id: `${business.id}-haircut-style` },
update: {},
create: {
id: `${business.id}-haircut-style`,
businessId: business.id,
categoryId: category.id,
name: "Haircut & Style",
durationMinutes: 30,
price: 22,
},
}),
prisma.service.upsert({
where: { id: `${business.id}-skinfade-style` },
update: {},
create: {
id: `${business.id}-skinfade-style`,
businessId: business.id,
categoryId: category.id,
name: "Skinfade / Taper & Style",
durationMinutes: 30,
price: 25,
},
}),
]);
const ownerPasswordHash = await bcrypt.hash("password123", 12);
const ownerUser = await prisma.user.upsert({
where: { email: "marcus@blakeheritage.co.uk" },
update: {},
create: {
email: "marcus@blakeheritage.co.uk",
name: "Marcus Reed",
passwordHash: ownerPasswordHash,
emailVerified: new Date(),
staffProfile: {
create: {
businessId: business.id,
roleId: ownerRole.id,
bio: "Master barber, owner.",
workingHours: {
create: WEEKDAYS.map((dayOfWeek) => ({
dayOfWeek,
startMinutes: 10 * 60,
endMinutes: 19 * 60,
})),
},
services: {
create: [{ serviceId: haircut.id }, { serviceId: skinfade.id }],
},
},
},
},
include: { staffProfile: true },
});
const barberPasswordHash = await bcrypt.hash("password123", 12);
await prisma.user.upsert({
where: { email: "leo@blakeheritage.co.uk" },
update: {},
create: {
email: "leo@blakeheritage.co.uk",
name: "Leo Vance",
passwordHash: barberPasswordHash,
emailVerified: new Date(),
staffProfile: {
create: {
businessId: business.id,
roleId: barberRole.id,
bio: "Senior specialist.",
workingHours: {
create: WEEKDAYS.map((dayOfWeek) => ({
dayOfWeek,
startMinutes: 10 * 60,
endMinutes: 19 * 60,
})),
},
services: { create: [{ serviceId: haircut.id }] },
},
},
},
});
const customerPasswordHash = await bcrypt.hash("password123", 12);
await prisma.user.upsert({
where: { email: "alex.chen@example.com" },
update: {},
create: {
email: "alex.chen@example.com",
name: "Alex Chen",
passwordHash: customerPasswordHash,
emailVerified: new Date(),
customerProfile: {
create: {
firstName: "Alex",
lastName: "Chen",
email: "alex.chen@example.com",
isGuest: false,
preferredBarberId: ownerUser.staffProfile?.id,
},
},
},
});
console.log("Seed complete:", {
business: business.name,
roles: [ownerRole.name, barberRole.name],
services: [haircut.name, skinfade.name],
});
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});