This commit is contained in:
Stefan Lazic committed 2026-08-04 09:57:58 +01:00
1 parent 546613e677
commit 4785510144
82 files changed
+4903 -287

No files matched your search

@@ -0,0 +1,77 @@
-- AlterTable
ALTER TABLE `BookingRule` MODIFY `allowGuestBooking` BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE `Business` ADD COLUMN `appIconUrl` VARCHAR(191) NULL,
ADD COLUMN `backgroundColor` VARCHAR(191) NULL,
ADD COLUMN `coverImageUrl` VARCHAR(191) NULL,
ADD COLUMN `faviconUrl` VARCHAR(191) NULL,
ADD COLUMN `shortName` VARCHAR(191) NULL,
ADD COLUMN `themeColor` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `ApplicationInstallation` (
`id` VARCHAR(191) NOT NULL,
`status` ENUM('NOT_INSTALLED', 'INSTALLING', 'INSTALLED', 'FAILED') NOT NULL DEFAULT 'NOT_INSTALLED',
`installedAt` DATETIME(3) NULL,
`installedVersion` VARCHAR(191) NULL,
`initialAdminUserId` VARCHAR(191) NULL,
`primaryBusinessId` VARCHAR(191) NULL,
`metadata` JSON NULL,
`failureReason` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `StaffCalendarConnection` (
`id` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`provider` ENUM('GOOGLE') NOT NULL DEFAULT 'GOOGLE',
`externalAccountId` VARCHAR(191) NOT NULL,
`accountEmail` VARCHAR(191) NOT NULL,
`encryptedAccessToken` TEXT NULL,
`encryptedRefreshToken` TEXT NOT NULL,
`tokenExpiresAt` DATETIME(3) NULL,
`selectedCalendarId` VARCHAR(191) NOT NULL,
`selectedCalendarName` VARCHAR(191) NOT NULL,
`connectedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`lastSyncAt` DATETIME(3) NULL,
`lastSyncError` TEXT NULL,
`revokedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `StaffCalendarConnection_staffId_key`(`staffId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `AppointmentCalendarEvent` (
`id` VARCHAR(191) NOT NULL,
`appointmentId` VARCHAR(191) NOT NULL,
`connectionId` VARCHAR(191) NOT NULL,
`providerEventId` VARCHAR(191) NOT NULL,
`providerCalendarId` VARCHAR(191) NOT NULL,
`status` ENUM('SYNCED', 'PENDING', 'FAILED', 'CANCELLED') NOT NULL DEFAULT 'SYNCED',
`lastSyncedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`lastSyncError` TEXT NULL,
`sourceVersion` INTEGER NOT NULL DEFAULT 1,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `AppointmentCalendarEvent_connectionId_idx`(`connectionId`),
UNIQUE INDEX `AppointmentCalendarEvent_appointmentId_connectionId_key`(`appointmentId`, `connectionId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- AddForeignKey
ALTER TABLE `StaffCalendarConnection` ADD CONSTRAINT `StaffCalendarConnection_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `AppointmentCalendarEvent` ADD CONSTRAINT `AppointmentCalendarEvent_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `AppointmentCalendarEvent` ADD CONSTRAINT `AppointmentCalendarEvent_connectionId_fkey` FOREIGN KEY (`connectionId`) REFERENCES `StaffCalendarConnection`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+182 -16
View File
@@ -191,6 +191,31 @@ enum NotificationDeliveryStatus {
SKIPPED
}
/// See src/lib/installation/installation-state.ts — the one place this is
/// read. INSTALLING exists purely as an in-transaction lock state (see
/// ApplicationInstallation's own header); no caller ever observes it
/// committed, since a failed install rolls the whole transaction back to
/// no-row-at-all rather than leaving INSTALLING or FAILED sitting around.
enum InstallationStatus {
NOT_INSTALLED
INSTALLING
INSTALLED
FAILED
}
enum CalendarProvider {
GOOGLE
}
/// Per-appointment sync outcome, not the connection's overall health (see
/// StaffCalendarConnection.lastSyncError for that).
enum CalendarSyncStatus {
SYNCED
PENDING
FAILED
CANCELLED
}
// ─────────────────────────────────────────────────────────────────────────
// Business
// ─────────────────────────────────────────────────────────────────────────
@@ -199,11 +224,30 @@ enum NotificationDeliveryStatus {
/// 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?
id String @id @default(cuid())
slug String @unique
name String
/// Short form for tight spaces (PWA home-screen label, browser tab on
/// narrow viewports) — falls back to a truncated `name` when unset, see
/// src/lib/business/branding.ts.
shortName String?
description String? @db.Text
logoUrl String?
/// Wide banner image for the Welcome page / booking pages — distinct
/// from `logoUrl` (square mark) and optional; falls back to a plain
/// color background when unset.
coverImageUrl String?
/// Falls back to the app's neutral favicon when unset — see
/// src/app/icon.png and the branding fallback rules in
/// src/lib/business/branding.ts.
faviconUrl String?
/// Used for the PWA manifest icon set — falls back to the same neutral
/// app icon as the favicon when unset.
appIconUrl String?
/// Hex color, e.g. "#111b15" — PWA `theme_color` / manifest and
/// browser chrome. Falls back to the app's own theme color when unset.
themeColor String?
backgroundColor String?
phone String?
email String?
@@ -317,7 +361,13 @@ model BookingRule {
maxAppointmentsPerStaffPerDay Int?
customerCanChooseStaff Boolean @default(true)
allowGuestBooking Boolean @default(true)
/// Defaults to false as of Phase 8 (see docs/phase-8-summary.md) — a
/// freshly installed business requires sign-in/account creation before
/// a booking confirms until the owner explicitly opts in via
/// /owner/settings/booking-rules. Existing guest appointments and their
/// access-token links are unaffected either way (see
/// GuestAppointmentAccessToken) — this only gates *new* bookings.
allowGuestBooking Boolean @default(false)
requireApproval Boolean @default(false)
/// Modelled now, not enforced anywhere yet — deposits are a future
/// payments phase (see docs/implementation-plan.md §6, "later phase").
@@ -326,6 +376,46 @@ model BookingRule {
updatedAt DateTime @updatedAt
}
// ─────────────────────────────────────────────────────────────────────────
// Installation
// ─────────────────────────────────────────────────────────────────────────
/// Single source of truth for "has this deployment been set up yet" — see
/// src/lib/installation/installation-state.ts, the only place this is
/// read. Deliberately not inferred from "does Business have any rows" —
/// an explicit status column survives a partially-failed install cleanly.
///
/// v1 expects exactly one row ever, enforced by a fixed, well-known `id`
/// rather than a separate lock table: the installer's transaction starts
/// with `create({ id: "singleton", status: INSTALLING })` as its very
/// first statement, so a concurrent second install attempt blocks on
/// that same primary-key insert and then fails with a unique-constraint
/// violation once the first transaction commits — no read-then-write
/// race window. If the winning transaction fails partway through, the
/// whole transaction (including this row) rolls back, so a retry sees
/// "not installed" again rather than a stuck INSTALLING/FAILED row — see
/// src/features/installation/actions/install.ts.
model ApplicationInstallation {
id String @id @default(cuid())
status InstallationStatus @default(NOT_INSTALLED)
installedAt DateTime?
installedVersion String?
initialAdminUserId String?
primaryBusinessId String?
/// Small, non-secret metadata only (e.g. which starter-service option
/// was chosen) — never the installation token, never credentials.
metadata Json?
failureReason String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─────────────────────────────────────────────────────────────────────────
// Auth (Auth.js / NextAuth v5 + Prisma adapter standard shape)
// ─────────────────────────────────────────────────────────────────────────
@@ -438,7 +528,7 @@ model CustomerProfile {
appointments Appointment[]
loyaltyAccounts LoyaltyAccount[]
notes CustomerNote[]
receivedNotifications Notification[] @relation("NotificationRecipientCustomer")
receivedNotifications Notification[] @relation("NotificationRecipientCustomer")
pushSubscriptions PushSubscription[]
@@index([email])
@@ -470,13 +560,16 @@ model StaffProfile {
breaks Break[]
timeOff TimeOff[]
appointments Appointment[]
preferredByCustomers CustomerProfile[] @relation("PreferredBarber")
authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor")
authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor")
redeemedLoyaltyRewards LoyaltyReward[] @relation("LoyaltyRewardRedeemedBy")
preferredByCustomers CustomerProfile[] @relation("PreferredBarber")
authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor")
authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor")
redeemedLoyaltyRewards LoyaltyReward[] @relation("LoyaltyRewardRedeemedBy")
/// Set when this profile was created via the owner "Add Staff" flow
/// rather than seeded directly — see StaffInvitationService.
invitation StaffInvitation?
/// At most one connected Google account per staff member — see
/// StaffCalendarConnection and src/services/calendar.
calendarConnection StaffCalendarConnection?
@@index([businessId])
}
@@ -544,6 +637,78 @@ model StaffInvitation {
@@index([businessId])
}
/// A staff member's own connected Google account for one-way appointment
/// sync (business → Google Calendar) — entirely separate from Google
/// sign-in (Account/next-auth): a barber may authenticate with email and
/// still connect a *different* Google account here, or vice versa. See
/// src/services/calendar/calendar-sync.service.ts.
model StaffCalendarConnection {
id String @id @default(cuid())
staffId String @unique
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
provider CalendarProvider @default(GOOGLE)
externalAccountId String
accountEmail String
/// AES-256-GCM ciphertext (base64: iv + authTag + ciphertext) — see
/// src/lib/security/token-encryption.ts. CALENDAR_TOKEN_ENCRYPTION_KEY
/// is the only key that can decrypt these; never logged, never
/// returned to the client, never readable by the owner (see
/// StaffCalendarConnection's owner-facing projection in
/// calendar-sync.service.ts, which only ever selects the non-token
/// columns).
encryptedAccessToken String? @db.Text
encryptedRefreshToken String @db.Text
tokenExpiresAt DateTime?
selectedCalendarId String
selectedCalendarName String
connectedAt DateTime @default(now())
lastSyncAt DateTime?
lastSyncError String? @db.Text
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events AppointmentCalendarEvent[]
}
/// One row per (appointment, connection) pair the appointment has ever
/// been synced to — the unique constraint below is what makes sync
/// idempotent (an existing row means "update the same event", not
/// "create a new one").
model AppointmentCalendarEvent {
id String @id @default(cuid())
appointmentId String
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
connectionId String
connection StaffCalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
providerEventId String
providerCalendarId String
status CalendarSyncStatus @default(SYNCED)
lastSyncedAt DateTime @default(now())
lastSyncError String? @db.Text
/// Bumped on every appointment mutation that should trigger a re-push
/// (reschedule, service change) — lets the sync worker distinguish
/// "already reflects the latest appointment state" from "needs an
/// update" without re-diffing full event content each time.
sourceVersion Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([appointmentId, connectionId])
@@index([connectionId])
}
// ─────────────────────────────────────────────────────────────────────────
// Catalog
// ─────────────────────────────────────────────────────────────────────────
@@ -752,6 +917,7 @@ model Appointment {
/// as the array Prisma requires for a one-to-many back-relation.
appliedLoyaltyRewards LoyaltyReward[]
guestAccessToken GuestAppointmentAccessToken?
calendarEvents AppointmentCalendarEvent[]
@@index([staffId, startAt])
@@index([businessId, startAt])
@@ -984,12 +1150,12 @@ model Notification {
loyaltyRewardId String?
loyaltyReward LoyaltyReward? @relation(fields: [loyaltyRewardId], references: [id], onDelete: SetNull)
subject String?
subject String?
/// 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
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,
@@ -1036,7 +1202,7 @@ model NotificationDelivery {
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?
failureReason String?
sentAt DateTime?
@@ -1061,7 +1227,7 @@ model NotificationPreference {
type NotificationType
channel NotificationChannel
isEnabled Boolean @default(true)
isEnabled Boolean @default(true)
@@unique([userId, type, channel])
}
@@ -1087,7 +1253,7 @@ model PushSubscription {
/// 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)
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
+35 -50
View File
@@ -4,10 +4,12 @@
* every relation in the schema once, not a full demo dataset.
*/
import "dotenv/config";
import { PrismaClient, Permission, Weekday, RewardType, NotificationType } from "../generated/prisma/client";
import { PrismaClient, Weekday, RewardType, NotificationType, InstallationStatus } 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 { OWNER_PERMISSIONS, BARBER_PERMISSIONS, syncRolePermissions } from "../src/lib/roles/default-permissions";
import { INSTALLATION_SINGLETON_ID } from "../src/lib/installation/installation-state";
import bcrypt from "bcryptjs";
/**
@@ -29,52 +31,6 @@ function minutesFromNow(minutes: number): Date {
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
const prisma = new PrismaClient({ adapter });
/**
* `role.upsert`'s `update: {}` deliberately does nothing on an existing
* role (see the two call sites below) — re-running the seed must still be
* safe to call repeatedly without duplicating anything, but a plain
* nested `permissions: { create: [...] }` only fires on the `create`
* branch, so a permission added to OWNER_PERMISSIONS/BARBER_PERMISSIONS
* after a business was first seeded would silently never reach an
* already-seeded database. This syncs RolePermission rows to exactly
* match the target list every run — found and fixed while adding Phase 6
* permissions to a database seeded back in Phase 2.
*/
async function syncRolePermissions(roleId: string, permissions: Permission[]): Promise<void> {
await prisma.rolePermission.deleteMany({ where: { roleId, permission: { notIn: permissions } } });
await prisma.rolePermission.createMany({
data: permissions.map((permission) => ({ roleId, permission })),
skipDuplicates: true,
});
}
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_ALL_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,
Permission.VIEW_REPORTING,
];
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,
@@ -112,7 +68,13 @@ async function main() {
lateArrivalPolicy: "Arrivals more than 10 minutes late may need to be rescheduled.",
},
},
bookingRule: { create: {} }, // all defaults from the schema
// Guest booking defaults to false as of Phase 8 (see BookingRule in
// prisma/schema.prisma) — the dev/demo fixture explicitly opts back
// in so the guest-booking E2E journey and manual testing keep
// working; a real installed business starts with it off (see
// src/features/installation/installer.service.ts) until the owner
// enables it themselves.
bookingRule: { create: { allowGuestBooking: true } },
hours: {
create: [
...WEEKDAYS.map((dayOfWeek) => ({
@@ -146,7 +108,7 @@ async function main() {
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
},
});
await syncRolePermissions(ownerRole.id, OWNER_PERMISSIONS);
await syncRolePermissions(prisma, ownerRole.id, OWNER_PERMISSIONS);
const barberRole = await prisma.role.upsert({
where: { businessId_name: { businessId: business.id, name: "Barber" } },
@@ -158,7 +120,7 @@ async function main() {
permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) },
},
});
await syncRolePermissions(barberRole.id, BARBER_PERMISSIONS);
await syncRolePermissions(prisma, barberRole.id, BARBER_PERMISSIONS);
const category = await prisma.serviceCategory.upsert({
where: { id: `${business.id}-senior-barber` },
@@ -1013,6 +975,29 @@ async function main() {
});
}
// Dev/demo fixtures bypass the installation wizard entirely (see
// src/features/installation/installer.service.ts for the real,
// production first-run path) — this upsert only exists so a freshly
// seeded dev/CI/E2E database is treated as "installed" by the routing
// gate in src/proxy.ts, exactly as if a real install had completed.
// Never run alongside a real installation: the two are mutually
// exclusive by construction (the installer's own transaction refuses
// to start once this row exists — see ApplicationInstallation's model
// header).
await prisma.applicationInstallation.upsert({
where: { id: INSTALLATION_SINGLETON_ID },
update: {},
create: {
id: INSTALLATION_SINGLETON_ID,
status: InstallationStatus.INSTALLED,
installedAt: new Date(),
installedVersion: "dev-seed",
initialAdminUserId: ownerUser.id,
primaryBusinessId: business.id,
metadata: { source: "prisma/seed.ts" },
},
});
console.log("Seed complete:", {
business: business.name,
roles: [ownerRole.name, barberRole.name],