Phase 8
This commit is contained in:
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
@@ -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
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user