This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

@@ -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,665 @@
-- CreateTable
CREATE TABLE `Business` (
`id` VARCHAR(191) NOT NULL,
`slug` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`logoUrl` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
`website` VARCHAR(191) NULL,
`instagramUrl` VARCHAR(191) NULL,
`facebookUrl` VARCHAR(191) NULL,
`timezone` VARCHAR(191) NOT NULL DEFAULT 'Europe/London',
`currency` VARCHAR(191) NOT NULL DEFAULT 'GBP',
`locale` VARCHAR(191) NOT NULL DEFAULT 'en-GB',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Business_slug_key`(`slug`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BusinessLocation` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL DEFAULT 'Main Location',
`addressLine1` VARCHAR(191) NOT NULL,
`addressLine2` VARCHAR(191) NULL,
`city` VARCHAR(191) NOT NULL,
`region` VARCHAR(191) NULL,
`postalCode` VARCHAR(191) NOT NULL,
`country` VARCHAR(191) NOT NULL DEFAULT 'GB',
`latitude` DECIMAL(9, 6) NULL,
`longitude` DECIMAL(9, 6) NULL,
`isPrimary` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `BusinessLocation_businessId_idx`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BusinessHours` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`dayOfWeek` ENUM('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY') NOT NULL,
`isClosed` BOOLEAN NOT NULL DEFAULT false,
`opensAtMinutes` INTEGER NULL,
`closesAtMinutes` INTEGER NULL,
UNIQUE INDEX `BusinessHours_businessId_dayOfWeek_key`(`businessId`, `dayOfWeek`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BusinessSettings` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`cancellationPolicy` TEXT NULL,
`lateArrivalPolicy` TEXT NULL,
`bookingTerms` TEXT NULL,
`privacyPolicyUrl` VARCHAR(191) NULL,
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `BusinessSettings_businessId_key`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BookingRule` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) 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 NULL,
`maxAppointmentsPerStaffPerDay` INTEGER NULL,
`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` DATETIME(3) NOT NULL,
UNIQUE INDEX `BookingRule_businessId_key`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `User` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
`emailVerified` DATETIME(3) NULL,
`phone` VARCHAR(191) NULL,
`phoneVerified` DATETIME(3) NULL,
`image` VARCHAR(191) NULL,
`passwordHash` VARCHAR(191) NULL,
`isSuperAdmin` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `User_email_key`(`email`),
UNIQUE INDEX `User_phone_key`(`phone`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Account` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`type` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191) NOT NULL,
`providerAccountId` VARCHAR(191) NOT NULL,
`refresh_token` TEXT NULL,
`access_token` TEXT NULL,
`expires_at` INTEGER NULL,
`token_type` VARCHAR(191) NULL,
`scope` VARCHAR(191) NULL,
`id_token` TEXT NULL,
`session_state` VARCHAR(191) NULL,
UNIQUE INDEX `Account_provider_providerAccountId_key`(`provider`, `providerAccountId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Session` (
`id` VARCHAR(191) NOT NULL,
`sessionToken` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `Session_sessionToken_key`(`sessionToken`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `VerificationToken` (
`identifier` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `VerificationToken_token_key`(`token`),
UNIQUE INDEX `VerificationToken_identifier_token_key`(`identifier`, `token`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `CustomerProfile` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NULL,
`firstName` VARCHAR(191) NOT NULL,
`lastName` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`phone` VARCHAR(191) NULL,
`isGuest` BOOLEAN NOT NULL DEFAULT true,
`preferredBarberId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `CustomerProfile_userId_key`(`userId`),
INDEX `CustomerProfile_email_idx`(`email`),
INDEX `CustomerProfile_phone_idx`(`phone`),
INDEX `CustomerProfile_preferredBarberId_idx`(`preferredBarberId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `StaffProfile` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`roleId` VARCHAR(191) NOT NULL,
`bio` TEXT NULL,
`status` ENUM('ACTIVE', 'ON_BREAK', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE',
`isBookableOnline` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `StaffProfile_userId_key`(`userId`),
INDEX `StaffProfile_businessId_idx`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Role` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`isSystem` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `Role_businessId_name_key`(`businessId`, `name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `RolePermission` (
`id` VARCHAR(191) NOT NULL,
`roleId` VARCHAR(191) NOT NULL,
`permission` ENUM('MANAGE_BUSINESS_PROFILE', 'MANAGE_BOOKING_RULES', 'MANAGE_SERVICES', 'MANAGE_STAFF', 'MANAGE_LOYALTY_PROGRAMME', 'MANAGE_NOTIFICATION_SETTINGS', 'VIEW_ALL_STAFF_APPOINTMENTS', 'MANAGE_ALL_APPOINTMENTS', 'MANAGE_OWN_APPOINTMENTS', 'CHECK_IN_CUSTOMERS', 'MANAGE_LOYALTY_STAMPS', 'MANAGE_OWN_AVAILABILITY', 'VIEW_CUSTOMER_DIRECTORY', 'BOOK_ON_BEHALF_OF_CUSTOMER', 'VIEW_REPORTING') NOT NULL,
UNIQUE INDEX `RolePermission_roleId_permission_key`(`roleId`, `permission`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `StaffInvitation` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`invitedByUserId` VARCHAR(191) NULL,
`expiresAt` DATETIME(3) NOT NULL,
`acceptedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `StaffInvitation_staffId_key`(`staffId`),
UNIQUE INDEX `StaffInvitation_token_key`(`token`),
INDEX `StaffInvitation_businessId_idx`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `ServiceCategory` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`displayOrder` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `ServiceCategory_businessId_idx`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Service` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`categoryId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`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` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Service_businessId_idx`(`businessId`),
INDEX `Service_categoryId_idx`(`categoryId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `StaffService` (
`id` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`serviceId` VARCHAR(191) NOT NULL,
`priceOverride` DECIMAL(10, 2) NULL,
`durationOverrideMinutes` INTEGER NULL,
UNIQUE INDEX `StaffService_staffId_serviceId_key`(`staffId`, `serviceId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `WorkingHours` (
`id` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`dayOfWeek` ENUM('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY') NOT NULL,
`isOff` BOOLEAN NOT NULL DEFAULT false,
`startMinutes` INTEGER NULL,
`endMinutes` INTEGER NULL,
UNIQUE INDEX `WorkingHours_staffId_dayOfWeek_key`(`staffId`, `dayOfWeek`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Break` (
`id` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`label` VARCHAR(191) NOT NULL,
`startMinutes` INTEGER NOT NULL,
`endMinutes` INTEGER NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BreakDay` (
`id` VARCHAR(191) NOT NULL,
`breakId` VARCHAR(191) NOT NULL,
`dayOfWeek` ENUM('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY') NOT NULL,
UNIQUE INDEX `BreakDay_breakId_dayOfWeek_key`(`breakId`, `dayOfWeek`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `TimeOff` (
`id` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`startDate` DATE NOT NULL,
`endDate` DATE NOT NULL,
`reason` VARCHAR(191) NULL,
`status` ENUM('PENDING', 'APPROVED', 'DECLINED') NOT NULL DEFAULT 'APPROVED',
`startMinutes` INTEGER NULL,
`endMinutes` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `TimeOff_staffId_startDate_endDate_idx`(`staffId`, `startDate`, `endDate`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Appointment` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`status` ENUM('PENDING', 'CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW') NOT NULL DEFAULT 'CONFIRMED',
`startAt` DATETIME(3) NOT NULL,
`endAt` DATETIME(3) NOT NULL,
`notes` TEXT NULL,
`discountAmount` DECIMAL(10, 2) NULL,
`cancellationReason` VARCHAR(191) NULL,
`cancelledAt` DATETIME(3) NULL,
`cancelledByUserId` VARCHAR(191) NULL,
`createdByUserId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Appointment_staffId_startAt_idx`(`staffId`, `startAt`),
INDEX `Appointment_businessId_startAt_idx`(`businessId`, `startAt`),
INDEX `Appointment_customerId_idx`(`customerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `AppointmentService` (
`id` VARCHAR(191) NOT NULL,
`appointmentId` VARCHAR(191) NOT NULL,
`serviceId` VARCHAR(191) NOT NULL,
`priceAtBooking` DECIMAL(10, 2) NOT NULL,
`durationAtBookingMinutes` INTEGER NOT NULL,
INDEX `AppointmentService_appointmentId_idx`(`appointmentId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyProgramme` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`isEnabled` BOOLEAN NOT NULL DEFAULT false,
`stampsRequired` INTEGER NOT NULL DEFAULT 10,
`rewardType` ENUM('PERCENTAGE', 'FIXED_AMOUNT', 'FREE_SERVICE') NOT NULL DEFAULT 'PERCENTAGE',
`rewardValue` DECIMAL(10, 2) NULL,
`rewardLabel` VARCHAR(191) NULL,
`maxRewardValue` DECIMAL(10, 2) NULL,
`freeServiceId` VARCHAR(191) NULL,
`autoApplyReward` BOOLEAN NOT NULL DEFAULT true,
`requiresStaffApproval` BOOLEAN NOT NULL DEFAULT false,
`stampsExpireAfterDays` INTEGER NULL,
`rewardExpiresAfterDays` INTEGER NULL,
`earnOnCancelled` BOOLEAN NOT NULL DEFAULT false,
`earnOnNoShow` BOOLEAN NOT NULL DEFAULT false,
`cardBackgroundColor` VARCHAR(191) NULL,
`cardBackgroundImageUrl` VARCHAR(191) NULL,
`stampIcon` VARCHAR(191) NULL,
`termsLine` VARCHAR(191) NULL,
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `LoyaltyProgramme_businessId_key`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyEligibleService` (
`id` VARCHAR(191) NOT NULL,
`loyaltyProgrammeId` VARCHAR(191) NOT NULL,
`serviceId` VARCHAR(191) NOT NULL,
UNIQUE INDEX `LoyaltyEligibleService_loyaltyProgrammeId_serviceId_key`(`loyaltyProgrammeId`, `serviceId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyEligibleStaff` (
`id` VARCHAR(191) NOT NULL,
`loyaltyProgrammeId` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
UNIQUE INDEX `LoyaltyEligibleStaff_loyaltyProgrammeId_staffId_key`(`loyaltyProgrammeId`, `staffId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyAccount` (
`id` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`loyaltyProgrammeId` VARCHAR(191) NOT NULL,
`currentStamps` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `LoyaltyAccount_customerId_loyaltyProgrammeId_key`(`customerId`, `loyaltyProgrammeId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyTransaction` (
`id` VARCHAR(191) NOT NULL,
`loyaltyAccountId` VARCHAR(191) NOT NULL,
`type` ENUM('STAMP_ADDED', 'STAMP_REMOVED', 'STAMP_EXPIRED', 'REWARD_REDEEMED') NOT NULL,
`amount` INTEGER NOT NULL,
`reason` VARCHAR(191) NULL,
`appointmentId` VARCHAR(191) NULL,
`authoredByStaffId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `LoyaltyTransaction_loyaltyAccountId_idx`(`loyaltyAccountId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyReward` (
`id` VARCHAR(191) NOT NULL,
`loyaltyAccountId` VARCHAR(191) NOT NULL,
`rewardType` ENUM('PERCENTAGE', 'FIXED_AMOUNT', 'FREE_SERVICE') NOT NULL,
`value` DECIMAL(10, 2) NULL,
`label` VARCHAR(191) NOT NULL,
`status` ENUM('AVAILABLE', 'REDEEMED', 'EXPIRED') NOT NULL DEFAULT 'AVAILABLE',
`appliedToAppointmentId` VARCHAR(191) NULL,
`redeemedAt` DATETIME(3) NULL,
`redeemedByStaffId` VARCHAR(191) NULL,
`expiresAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `LoyaltyReward_loyaltyAccountId_idx`(`loyaltyAccountId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Notification` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NULL,
`appointmentId` VARCHAR(191) NULL,
`category` ENUM('BOOKING_CONFIRMATION', 'BOOKING_REMINDER', 'BOOKING_CHANGED', 'BOOKING_CANCELLED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_AVAILABLE', 'MARKETING') NOT NULL,
`channel` ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
`status` ENUM('PENDING', 'SENT', 'FAILED') NOT NULL DEFAULT 'PENDING',
`subject` VARCHAR(191) NULL,
`body` TEXT NOT NULL,
`sentAt` DATETIME(3) NULL,
`failureReason` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `Notification_userId_idx`(`userId`),
INDEX `Notification_appointmentId_idx`(`appointmentId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `NotificationPreference` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`category` ENUM('BOOKING_CONFIRMATION', 'BOOKING_REMINDER', 'BOOKING_CHANGED', 'BOOKING_CANCELLED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_AVAILABLE', 'MARKETING') NOT NULL,
`channel` ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
`isEnabled` BOOLEAN NOT NULL DEFAULT true,
UNIQUE INDEX `NotificationPreference_userId_category_channel_key`(`userId`, `category`, `channel`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `CustomerNote` (
`id` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`authorStaffId` VARCHAR(191) NOT NULL,
`body` TEXT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CustomerNote_customerId_idx`(`customerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `AuditLog` (
`id` VARCHAR(191) NOT NULL,
`actorUserId` VARCHAR(191) NULL,
`action` VARCHAR(191) NOT NULL,
`entityType` VARCHAR(191) NOT NULL,
`entityId` VARCHAR(191) NOT NULL,
`metadata` JSON NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `AuditLog_entityType_entityId_idx`(`entityType`, `entityId`),
INDEX `AuditLog_actorUserId_idx`(`actorUserId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- 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 `StaffInvitation` ADD CONSTRAINT `StaffInvitation_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StaffInvitation` ADD CONSTRAINT `StaffInvitation_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StaffInvitation` ADD CONSTRAINT `StaffInvitation_invitedByUserId_fkey` FOREIGN KEY (`invitedByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL 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 `Appointment` ADD CONSTRAINT `Appointment_cancelledByUserId_fkey` FOREIGN KEY (`cancelledByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_createdByUserId_fkey` FOREIGN KEY (`createdByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL 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 `LoyaltyProgramme` ADD CONSTRAINT `LoyaltyProgramme_freeServiceId_fkey` FOREIGN KEY (`freeServiceId`) REFERENCES `Service`(`id`) ON DELETE SET NULL 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 `LoyaltyReward` ADD CONSTRAINT `LoyaltyReward_appliedToAppointmentId_fkey` FOREIGN KEY (`appliedToAppointmentId`) REFERENCES `Appointment`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyReward` ADD CONSTRAINT `LoyaltyReward_redeemedByStaffId_fkey` FOREIGN KEY (`redeemedByStaffId`) REFERENCES `StaffProfile`(`id`) ON DELETE SET NULL 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,18 @@
-- CreateTable
CREATE TABLE `GuestAppointmentAccessToken` (
`id` VARCHAR(191) NOT NULL,
`appointmentId` VARCHAR(191) NOT NULL,
`tokenHash` VARCHAR(191) NOT NULL,
`expiresAt` DATETIME(3) NOT NULL,
`revokedAt` DATETIME(3) NULL,
`lastUsedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `GuestAppointmentAccessToken_appointmentId_key`(`appointmentId`),
UNIQUE INDEX `GuestAppointmentAccessToken_tokenHash_key`(`tokenHash`),
INDEX `GuestAppointmentAccessToken_expiresAt_idx`(`expiresAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- AddForeignKey
ALTER TABLE `GuestAppointmentAccessToken` ADD CONSTRAINT `GuestAppointmentAccessToken_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,101 @@
-- Phase 7: redesign Notification/NotificationPreference around the
-- Notification (message) / NotificationDelivery (one attempt per channel)
-- split — see prisma/schema.prisma for the full rationale. Both tables
-- were unused scaffolding from Phase 2 (confirmed via a repo-wide grep for
-- `prisma.notification`/`prisma.notificationPreference` before writing
-- this migration — zero call sites), so these are safe destructive
-- ALTERs, not a real data migration.
-- DropForeignKey
ALTER TABLE `Notification` DROP FOREIGN KEY `Notification_userId_fkey`;
-- DropForeignKey
ALTER TABLE `NotificationPreference` DROP FOREIGN KEY `NotificationPreference_userId_fkey`;
-- DropIndex
DROP INDEX `Notification_userId_idx` ON `Notification`;
-- DropIndex
DROP INDEX `NotificationPreference_userId_category_channel_key` ON `NotificationPreference`;
-- AlterTable
ALTER TABLE `Notification` DROP COLUMN `body`,
DROP COLUMN `category`,
DROP COLUMN `channel`,
DROP COLUMN `failureReason`,
DROP COLUMN `sentAt`,
DROP COLUMN `status`,
DROP COLUMN `userId`,
ADD COLUMN `bodyText` TEXT NOT NULL,
ADD COLUMN `businessId` VARCHAR(191) NOT NULL,
ADD COLUMN `deepLinkPath` VARCHAR(191) NULL,
ADD COLUMN `idempotencyKey` VARCHAR(191) NOT NULL,
ADD COLUMN `loyaltyRewardId` VARCHAR(191) NULL,
ADD COLUMN `readAt` DATETIME(3) NULL,
ADD COLUMN `recipientCustomerId` VARCHAR(191) NULL,
ADD COLUMN `recipientUserId` VARCHAR(191) NULL,
ADD COLUMN `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL;
-- AlterTable
ALTER TABLE `NotificationPreference` DROP COLUMN `category`,
ADD COLUMN `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL,
MODIFY `channel` ENUM('EMAIL', 'PUSH', 'IN_APP') NOT NULL;
-- CreateTable
CREATE TABLE `NotificationDelivery` (
`id` VARCHAR(191) NOT NULL,
`notificationId` VARCHAR(191) NOT NULL,
`channel` ENUM('EMAIL', 'PUSH', 'IN_APP') NOT NULL,
`status` ENUM('PENDING', 'SCHEDULED', 'PROCESSING', 'SENT', 'FAILED', 'CANCELLED', 'SKIPPED') NOT NULL DEFAULT 'PENDING',
`scheduledFor` DATETIME(3) NULL,
`attemptCount` INTEGER NOT NULL DEFAULT 0,
`lastAttemptedAt` DATETIME(3) NULL,
`providerName` VARCHAR(191) NULL,
`providerMessageId` VARCHAR(191) NULL,
`failureReason` VARCHAR(191) NULL,
`sentAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `NotificationDelivery_status_scheduledFor_idx`(`status`, `scheduledFor`),
UNIQUE INDEX `NotificationDelivery_notificationId_channel_key`(`notificationId`, `channel`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateIndex
CREATE UNIQUE INDEX `Notification_idempotencyKey_key` ON `Notification`(`idempotencyKey`);
-- CreateIndex
CREATE INDEX `Notification_businessId_createdAt_idx` ON `Notification`(`businessId`, `createdAt`);
-- CreateIndex
CREATE INDEX `Notification_recipientUserId_readAt_idx` ON `Notification`(`recipientUserId`, `readAt`);
-- CreateIndex
CREATE INDEX `Notification_recipientCustomerId_idx` ON `Notification`(`recipientCustomerId`);
-- CreateIndex
CREATE UNIQUE INDEX `NotificationPreference_userId_type_channel_key` ON `NotificationPreference`(`userId`, `type`, `channel`);
-- AddForeignKey
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_recipientUserId_fkey` FOREIGN KEY (`recipientUserId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_recipientCustomerId_fkey` FOREIGN KEY (`recipientCustomerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_loyaltyRewardId_fkey` FOREIGN KEY (`loyaltyRewardId`) REFERENCES `LoyaltyReward`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `NotificationDelivery` ADD CONSTRAINT `NotificationDelivery_notificationId_fkey` FOREIGN KEY (`notificationId`) REFERENCES `Notification`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-- Re-adds the FK this migration dropped above (to accommodate the
-- category->type column rename) but never restored — caught while writing
-- a later migration and diffing against the live dev DB, which showed
-- NotificationPreference.userId with no FK at all. Fixed here, in the
-- original migration, rather than a separate patch: this migration had
-- only ever been applied to this local dev database, never shared.
ALTER TABLE `NotificationPreference` ADD CONSTRAINT `NotificationPreference_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- Adds STAFF_INVITATION_RECEIVED to NotificationType — the invite email
-- itself, sent to the person being invited (see StaffInvitationService and
-- templates/staff-emails.ts renderStaffInvitationEmail). Distinct from the
-- existing OWNER_STAFF_INVITATION_ACCEPTED, which notifies the owner once
-- accepted.
-- AlterTable
ALTER TABLE `Notification` MODIFY `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'STAFF_INVITATION_RECEIVED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL;
-- AlterTable
ALTER TABLE `NotificationPreference` MODIFY `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'STAFF_INVITATION_RECEIVED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL;
@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE `PushSubscription` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NULL,
`customerId` VARCHAR(191) NULL,
`endpoint` VARCHAR(500) NOT NULL,
`p256dhKey` VARCHAR(191) NOT NULL,
`authKey` VARCHAR(191) NOT NULL,
`deviceLabel` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`lastUsedAt` DATETIME(3) NULL,
`revokedAt` DATETIME(3) NULL,
UNIQUE INDEX `PushSubscription_endpoint_key`(`endpoint`),
INDEX `PushSubscription_userId_idx`(`userId`),
INDEX `PushSubscription_customerId_idx`(`customerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- AddForeignKey
ALTER TABLE `PushSubscription` ADD CONSTRAINT `PushSubscription_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PushSubscription` ADD CONSTRAINT `PushSubscription_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PushSubscription` ADD CONSTRAINT `PushSubscription_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- RenameIndex
ALTER TABLE `Break` RENAME INDEX `Break_staffId_fkey` TO `Break_staffId_idx`;
+1 -1
View File
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "mysql"
+246 -30
View File
@@ -1,15 +1,23 @@
// 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:
// (native arrays, JSONB-specific operators, LISTEN/NOTIFY) — this paid off
// in Phase 7, when the schema moved from PostgreSQL to MySQL with only a
// provider-string change (see docs/phase-7-summary.md §"Database migration"
// for the full record: schema review, index/collation decisions, and the
// serialization-error classifier update). 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
//
// Database: MySQL 8.0 (utf8mb4 / utf8mb4_0900_ai_ci) — see
// src/lib/db/prisma.ts for the driver adapter and docs/phase-7-summary.md
// for why (the production host only supports MySQL; the exact hosted
// version was not available in this repo, so 8.0 is the documented
// development/CI target pending confirmation).
generator client {
provider = "prisma-client"
@@ -19,7 +27,7 @@ generator client {
// 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"
provider = "mysql"
}
// ─────────────────────────────────────────────────────────────────────────
@@ -110,26 +118,77 @@ enum LoyaltyTransactionType {
REWARD_REDEEMED
}
enum NotificationCategory {
BOOKING_CONFIRMATION
/// One value per real, distinct trigger this app actually has (see
/// NotificationService and docs/phase-7-summary.md, "Notification types
/// implemented"). Deliberately does NOT include a type for every event the
/// Phase 7 brief brainstormed — several suggested owner/barber "conflict"
/// or "config warning" notifications are already served live by the
/// existing owner dashboard and the working-hours/breaks conflict-review
/// UI (see StaffService), which have no distinct async trigger separate
/// from rendering that page; duplicating them as stored Notification rows
/// would have no real "when do I fire this" moment. MARKETING/promotional
/// types are also omitted — marketing campaigns are explicitly out of
/// scope for this app.
enum NotificationType {
// Customer-facing
BOOKING_CONFIRMED
BOOKING_PENDING_APPROVAL
BOOKING_APPROVED
BOOKING_REMINDER
BOOKING_CHANGED
BOOKING_RESCHEDULED
/// Carries the late-cancellation flag in its rendered content rather than
/// being a separate type — same event, one boolean modifier, not two
/// distinct triggers.
BOOKING_CANCELLED
BOOKING_BARBER_CHANGED
BOOKING_COMPLETED
LOYALTY_STAMP_ADDED
LOYALTY_REWARD_EARNED
LOYALTY_REWARD_AVAILABLE
MARKETING
LOYALTY_REWARD_REDEEMED
LOYALTY_REWARD_EXPIRING
// Barber-facing
STAFF_NEW_APPOINTMENT
STAFF_APPOINTMENT_RESCHEDULED
STAFF_APPOINTMENT_CANCELLED
STAFF_APPOINTMENT_CHECKED_IN
STAFF_APPOINTMENT_REMINDER
STAFF_SCHEDULE_CHANGED
/// The invite itself, sent to the person being invited — see
/// StaffInvitationService. Distinct from OWNER_STAFF_INVITATION_ACCEPTED
/// below, which notifies the OWNER once they accept.
STAFF_INVITATION_RECEIVED
// Owner-facing
OWNER_NEW_BOOKING
OWNER_BOOKING_CANCELLED
OWNER_NO_SHOW
OWNER_STAFF_INVITATION_ACCEPTED
/// Self-referential — the notification background processor raises this
/// for the owner when it observes repeated delivery failures, rather
/// than a domain workflow triggering it directly. See reminder-worker.ts.
OWNER_REPEATED_DELIVERY_FAILURE
}
enum NotificationChannel {
EMAIL
SMS
PUSH
/// Visible in the in-app notification centre — see src/features/notifications.
IN_APP
}
enum NotificationStatus {
/// Deliberately stops at SENT, not DELIVERED — a provider accepting an
/// email/push send is not delivery confirmation, and this app has no
/// Brevo webhook wired up to ever justify a DELIVERED state (see
/// docs/phase-7-summary.md, "Brevo elements that couldn't be verified").
enum NotificationDeliveryStatus {
PENDING
SCHEDULED
PROCESSING
SENT
FAILED
CANCELLED
SKIPPED
}
// ─────────────────────────────────────────────────────────────────────────
@@ -173,6 +232,8 @@ model Business {
appointments Appointment[]
loyaltyProgramme LoyaltyProgramme?
staffInvitations StaffInvitation[]
notifications Notification[]
pushSubscriptions PushSubscription[]
}
/// Split from Business (rather than address fields on Business itself) so
@@ -298,8 +359,9 @@ model User {
sessions Session[]
customerProfile CustomerProfile?
staffProfile StaffProfile?
notifications Notification[]
receivedNotifications Notification[] @relation("NotificationRecipientUser")
notificationPreferences NotificationPreference[]
pushSubscriptions PushSubscription[]
/// Whoever actually cancelled an appointment — the customer themselves,
/// or the staff member who did it on their behalf.
cancelledAppointments Appointment[] @relation("AppointmentCancelledBy")
@@ -373,9 +435,11 @@ model CustomerProfile {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
appointments Appointment[]
loyaltyAccounts LoyaltyAccount[]
notes CustomerNote[]
appointments Appointment[]
loyaltyAccounts LoyaltyAccount[]
notes CustomerNote[]
receivedNotifications Notification[] @relation("NotificationRecipientCustomer")
pushSubscriptions PushSubscription[]
@@index([email])
@@index([phone])
@@ -587,6 +651,8 @@ model Break {
createdAt DateTime @default(now())
days BreakDay[]
@@index([staffId])
}
model BreakDay {
@@ -685,12 +751,40 @@ model Appointment {
/// appointment (see LoyaltyReward.appliedToAppointmentId) — but modelled
/// as the array Prisma requires for a one-to-many back-relation.
appliedLoyaltyRewards LoyaltyReward[]
guestAccessToken GuestAppointmentAccessToken?
@@index([staffId, startAt])
@@index([businessId, startAt])
@@index([customerId])
}
/// A guest (no linked User — see CustomerProfile.userId) proves they own an
/// appointment by presenting the raw token embedded in their booking
/// confirmation link, not by knowing the appointment's id alone (a cuid is
/// unguessable but was never actually a secret — Phase 4 documented this as
/// a deliberate, temporary gap; closed here — see
/// src/services/booking/guest-access.service.ts and docs/phase-7-summary.md).
/// Only the SHA-256 hash is ever persisted; the raw token exists just long
/// enough to embed in a link (confirmation page, and later, email) and is
/// never logged. One row per appointment — (re-)issuing overwrites the
/// previous hash, so an old copy of the raw token stops working.
model GuestAppointmentAccessToken {
id String @id @default(cuid())
appointmentId String @unique
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
revokedAt DateTime?
lastUsedAt DateTime?
createdAt DateTime @default(now())
@@index([expiresAt])
}
/// 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.
@@ -849,51 +943,173 @@ model LoyaltyReward {
createdAt DateTime @default(now())
notifications Notification[]
@@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.
// Notifications (Phase 7) — a Notification is one message to one
// recipient (the "what happened, to whom"); a NotificationDelivery is one
// attempt to get that message to them over one channel (email/push/
// in-app). Split for the same reason AppointmentService snapshots price
// separately from Service — a single message commonly needs more than one
// delivery attempt/channel with independent status, without duplicating
// the rendered content. See src/services/notifications/notification.service.ts.
// ─────────────────────────────────────────────────────────────────────────
model Notification {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
/// Set for staff/owner/registered-customer recipients — resolves
/// NotificationPreference and powers the in-app notification centre.
/// Null for a guest customer (no User to attach to).
recipientUserId String?
recipientUser User? @relation("NotificationRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade)
/// Set whenever the recipient IS a customer (guest or registered) —
/// lets a guest-directed notification render/deep-link correctly even
/// with no recipientUserId.
recipientCustomerId String?
recipientCustomer CustomerProfile? @relation("NotificationRecipientCustomer", fields: [recipientCustomerId], references: [id], onDelete: Cascade)
type NotificationType
appointmentId String?
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
category NotificationCategory
channel NotificationChannel
status NotificationStatus @default(PENDING)
loyaltyRewardId String?
loyaltyReward LoyaltyReward? @relation(fields: [loyaltyRewardId], references: [id], onDelete: SetNull)
subject String?
body String @db.Text
/// Canonical rendered plain-text content — the in-app notification body
/// and the email plain-text fallback both read from here; the HTML email
/// template (see src/services/notifications/templates) renders its own
/// markup from the same underlying data, not from this string.
bodyText String @db.Text
/// Relative path (e.g. "/appointments/xyz?token=..." for a guest) —
/// never an absolute URL, so it works whether the notification is opened
/// from an email (base URL prepended at send time), the installed PWA,
/// or the in-app centre.
deepLinkPath String?
sentAt DateTime?
failureReason String?
/// Prevents duplicate creation from a retried/duplicate domain call —
/// e.g. "booking-confirmed:{appointmentId}" or
/// "reminder-24h:{appointmentId}". A second attempt to create the same
/// logical notification is a no-op (see NotificationService.notify).
idempotencyKey String @unique
/// Only meaningful for a recipientUserId notification with an IN_APP
/// delivery — the in-app centre's read/unread state.
readAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
deliveries NotificationDelivery[]
@@index([businessId, createdAt])
@@index([recipientUserId, readAt])
@@index([recipientCustomerId])
@@index([appointmentId])
}
model NotificationDelivery {
id String @id @default(cuid())
notificationId String
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
channel NotificationChannel
status NotificationDeliveryStatus @default(PENDING)
/// Null = process as soon as the background worker picks it up; set for
/// a scheduled appointment reminder.
scheduledFor DateTime?
attemptCount Int @default(0)
lastAttemptedAt DateTime?
providerName String?
providerMessageId String?
/// A short, safe-to-display summary only — never the provider's raw
/// error payload, which can include request details worth not logging.
failureReason String?
sentAt DateTime?
createdAt DateTime @default(now())
@@unique([notificationId, channel])
@@index([status, scheduledFor])
}
/// Only the few NotificationTypes that are genuinely optional (currently:
/// the two reminder types) ever consult this — required transactional
/// types always send regardless of any row here, by design (see
/// NotificationService, PREFERENCE_CONTROLLED_TYPES). No stored
/// "category" column: which types are preference-controlled is an
/// application-layer policy, not a schema fact, so it can change without
/// a migration.
model NotificationPreference {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
category NotificationCategory
type NotificationType
channel NotificationChannel
isEnabled Boolean @default(true)
isEnabled Boolean @default(true)
@@unique([userId, category, channel])
@@unique([userId, type, channel])
}
/// One browser/device's Web Push subscription — a user can have several
/// (phone + laptop). Scoped to a business (not just a user) because a
/// guest customer, who has no User at all, can still subscribe from
/// whatever device they're on right after booking; recipientUserId is
/// null for that case, matching the same "user OR customer" recipient
/// shape as Notification itself.
model PushSubscription {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
customerId String?
customer CustomerProfile? @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// The push service's own subscription URL — long (some real-world push
/// services exceed 191 chars), hence the explicit length instead of
/// Prisma's default VARCHAR(191) for a unique-constrained String.
endpoint String @unique @db.VarChar(500)
/// The subscription's own public key/auth secret (from
/// PushSubscription.toJSON().keys in the browser) — needed to encrypt
/// each push payload; not a credential of ours to protect the way
/// EMAIL_SMTP_PASSWORD is, but still never logged (see reminder-worker.ts).
p256dhKey String
authKey String
/// User-facing label so Settings can show "iPhone — Safari" instead of a
/// raw endpoint — best-effort, derived from the User-Agent at subscribe
/// time, not re-verified later.
deviceLabel String?
createdAt DateTime @default(now())
lastUsedAt DateTime?
/// Set when the push service itself reports the subscription is gone
/// (410/404 — see reminder-worker.ts) or the user explicitly
/// unsubscribes; never deleted outright, so "why did this stop
/// receiving pushes" stays answerable.
revokedAt DateTime?
@@index([userId])
@@index([customerId])
}
// ─────────────────────────────────────────────────────────────────────────
+64 -3
View File
@@ -4,8 +4,10 @@
* 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 { PrismaClient, Permission, Weekday, RewardType, NotificationType } from "../generated/prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
import { notifySafely } from "../src/services/notifications/notification.service";
import bcrypt from "bcryptjs";
/**
@@ -24,7 +26,7 @@ function minutesFromNow(minutes: number): Date {
return new Date(Date.now() + minutes * 60_000);
}
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
const prisma = new PrismaClient({ adapter });
/**
@@ -952,6 +954,65 @@ async function main() {
}
}
// ── Phase 7: notification centre fixtures ──────────────────────────────
// Routed through the real notifySafely() rather than raw prisma.create()
// calls, so these get the exact same channel-resolution/idempotency
// behaviour a live booking would — a fresh checkout of this repo should
// show a populated notification bell for each role without needing to
// drive a real action first. Uses notifySafely (not notify) so a rerun
// of the seed never throws on the idempotency-key collision.
if (ownerUser.staffProfile && barberUser.staffProfile && registeredCustomer) {
await notifySafely(prisma, {
businessId: business.id,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: registeredCustomerUser.id,
recipientCustomerId: registeredCustomer.id,
appointmentId: `${business.id}-appt-today-completed`,
subject: "Booking confirmed",
bodyText: `Your ${haircut.name} with ${ownerUser.name} is confirmed.`,
deepLinkPath: "/appointments",
idempotencyKey: `notify:booking-confirmed:${business.id}-appt-today-completed`,
});
await notifySafely(prisma, {
businessId: business.id,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientUserId: registeredCustomerUser.id,
recipientCustomerId: registeredCustomer.id,
subject: "You've earned a loyalty stamp",
bodyText: "You've earned a loyalty stamp. Keep it up!",
deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-stamp-added:seed-${registeredCustomer.id}`,
});
// Marked read immediately below — gives the notification centre a mix
// of read/unread state out of the box, matching real usage instead of
// "everything unread forever."
await prisma.notification.updateMany({
where: { idempotencyKey: `notify:loyalty-stamp-added:seed-${registeredCustomer.id}` },
data: { readAt: daysFromNow(-1, 9) },
});
await notifySafely(prisma, {
businessId: business.id,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: barberUser.id,
appointmentId: `${business.id}-appt-today-checkedin`,
subject: "New appointment",
bodyText: `${secondCustomerUser.name ?? "A customer"} booked ${haircut.name}.`,
deepLinkPath: "/staff/current",
idempotencyKey: `notify:staff-new-appointment:${business.id}-appt-today-checkedin`,
});
await notifySafely(prisma, {
businessId: business.id,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: ownerUser.id,
appointmentId: `${business.id}-appt-today-checkedin`,
subject: "New booking",
bodyText: `${secondCustomerUser.name ?? "A customer"} booked ${haircut.name} with ${barberUser.name}.`,
deepLinkPath: "/owner/calendar",
idempotencyKey: `notify:owner-new-booking:${business.id}-appt-today-checkedin`,
});
}
console.log("Seed complete:", {
business: business.name,
roles: [ownerRole.name, barberRole.name],