Phase 2
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:
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;
|
||||
@@ -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"
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user