added availability
This commit is contained in:
316
src/lib/availability.ts
Normal file
316
src/lib/availability.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
// Pure availability algorithm. No DB dependency — caller passes resource state in.
|
||||
// See /Users/noise/Documents/obsidian/Massage/Initial.md §5 for design notes.
|
||||
//
|
||||
// All Date inputs are UTC-instant moments; timezone-sensitive logic happens only
|
||||
// inside checkWorkingHours, where we convert to the practice's wall clock to
|
||||
// compare against minutes-from-midnight working-hours rows.
|
||||
|
||||
import { addMinutes, isBefore } from "date-fns";
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export type ServiceLite = {
|
||||
id: string;
|
||||
durationMin: number;
|
||||
bufferAfterMin: number;
|
||||
requiredTherapistTags: string[];
|
||||
requiredRoomTags: string[];
|
||||
};
|
||||
|
||||
export type WorkingHoursEntry = {
|
||||
weekday: number; // 0=Sun .. 6=Sat (matches Date.getDay())
|
||||
startMin: number; // minutes from midnight, practice-local wall clock
|
||||
endMin: number; // exclusive upper bound
|
||||
effectiveFrom?: Date | null; // UTC; inclusive
|
||||
effectiveTo?: Date | null; // UTC; exclusive
|
||||
};
|
||||
|
||||
export type AvailabilityOverrideEntry = {
|
||||
startsAt: Date; // UTC
|
||||
endsAt: Date; // UTC
|
||||
kind: "BLOCK" | "EXTRA_HOURS";
|
||||
};
|
||||
|
||||
export type BookingInterval = {
|
||||
startsAt: Date; // UTC
|
||||
endsAt: Date; // UTC; for room bookings include buffer (= roomReleasedAt)
|
||||
};
|
||||
|
||||
export type TherapistState = {
|
||||
id: string;
|
||||
active: boolean;
|
||||
tags: ReadonlySet<string>;
|
||||
serviceIds: ReadonlySet<string>; // ServiceTherapist allowlist
|
||||
workingHours: WorkingHoursEntry[];
|
||||
overrides: AvailabilityOverrideEntry[];
|
||||
bookings: BookingInterval[]; // only HOLD/CONFIRMED rows
|
||||
};
|
||||
|
||||
export type RoomState = {
|
||||
id: string;
|
||||
active: boolean;
|
||||
tags: ReadonlySet<string>;
|
||||
blocks: BookingInterval[]; // RoomBlock rows
|
||||
bookings: BookingInterval[]; // bookings extended to roomReleasedAt
|
||||
};
|
||||
|
||||
export type FindSlotsOptions = {
|
||||
service: ServiceLite;
|
||||
from: Date; // UTC; inclusive lower bound
|
||||
to: Date; // UTC; exclusive upper bound
|
||||
therapists: TherapistState[];
|
||||
rooms: RoomState[];
|
||||
practiceTz: string; // IANA TZ, e.g. "America/New_York"
|
||||
slotGranularityMin?: number; // default 15
|
||||
preferredTherapistId?: string; // if set, only this therapist is considered
|
||||
};
|
||||
|
||||
export type Slot = {
|
||||
startsAt: Date; // UTC
|
||||
endsAt: Date; // UTC
|
||||
candidateTherapistIds: string[];
|
||||
candidateRoomIds: string[];
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Public entry point
|
||||
// ============================================================
|
||||
|
||||
export function findSlots(opts: FindSlotsOptions): Slot[] {
|
||||
const granularityMin = opts.slotGranularityMin ?? 15;
|
||||
|
||||
if (granularityMin <= 0 || !Number.isInteger(granularityMin)) {
|
||||
throw new Error("slotGranularityMin must be a positive integer");
|
||||
}
|
||||
if (!isBefore(opts.from, opts.to)) return [];
|
||||
|
||||
// 1. Pre-filter resources by tag/eligibility (cheap, set-based)
|
||||
const eligibleTherapists = opts.therapists.filter((t) => {
|
||||
if (!t.active) return false;
|
||||
if (!t.serviceIds.has(opts.service.id)) return false;
|
||||
if (!hasAllTags(t.tags, opts.service.requiredTherapistTags)) return false;
|
||||
if (opts.preferredTherapistId && t.id !== opts.preferredTherapistId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const eligibleRooms = opts.rooms.filter((r) => {
|
||||
if (!r.active) return false;
|
||||
if (!hasAllTags(r.tags, opts.service.requiredRoomTags)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (eligibleTherapists.length === 0 || eligibleRooms.length === 0) return [];
|
||||
|
||||
// 2. Walk the slot grid in UTC. Step by granularity; emit when at least
|
||||
// one therapist and one room are available for the candidate slot.
|
||||
const slots: Slot[] = [];
|
||||
const start = roundUpToGranularity(opts.from, granularityMin);
|
||||
|
||||
for (
|
||||
let s = start;
|
||||
isBefore(addMinutes(s, opts.service.durationMin - 1), opts.to);
|
||||
s = addMinutes(s, granularityMin)
|
||||
) {
|
||||
const e = addMinutes(s, opts.service.durationMin);
|
||||
const roomReleased = addMinutes(e, opts.service.bufferAfterMin);
|
||||
|
||||
const candidateTherapistIds: string[] = [];
|
||||
for (const t of eligibleTherapists) {
|
||||
if (therapistAvailable(t, s, e, opts.practiceTz)) {
|
||||
candidateTherapistIds.push(t.id);
|
||||
}
|
||||
}
|
||||
if (candidateTherapistIds.length === 0) continue;
|
||||
|
||||
const candidateRoomIds: string[] = [];
|
||||
for (const r of eligibleRooms) {
|
||||
if (roomAvailable(r, s, roomReleased)) {
|
||||
candidateRoomIds.push(r.id);
|
||||
}
|
||||
}
|
||||
if (candidateRoomIds.length === 0) continue;
|
||||
|
||||
slots.push({ startsAt: s, endsAt: e, candidateTherapistIds, candidateRoomIds });
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Per-resource predicates (exported for direct testing)
|
||||
// ============================================================
|
||||
|
||||
export function therapistAvailable(
|
||||
t: TherapistState,
|
||||
startsAt: Date,
|
||||
endsAt: Date,
|
||||
practiceTz: string,
|
||||
): boolean {
|
||||
// Bookings — any overlap kills the slot.
|
||||
for (const b of t.bookings) {
|
||||
if (intervalsOverlap(b.startsAt, b.endsAt, startsAt, endsAt)) return false;
|
||||
}
|
||||
// BLOCK overrides — any overlap kills the slot.
|
||||
for (const ov of t.overrides) {
|
||||
if (
|
||||
ov.kind === "BLOCK" &&
|
||||
intervalsOverlap(ov.startsAt, ov.endsAt, startsAt, endsAt)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Slot must be either inside regular working hours OR inside an EXTRA_HOURS window.
|
||||
const inExtraHours = t.overrides.some(
|
||||
(ov) =>
|
||||
ov.kind === "EXTRA_HOURS" &&
|
||||
intervalContains(ov.startsAt, ov.endsAt, startsAt, endsAt),
|
||||
);
|
||||
if (inExtraHours) return true;
|
||||
return inWorkingHours(t.workingHours, startsAt, endsAt, practiceTz);
|
||||
}
|
||||
|
||||
export function roomAvailable(
|
||||
r: RoomState,
|
||||
startsAt: Date,
|
||||
endsAtIncludingBuffer: Date,
|
||||
): boolean {
|
||||
for (const b of r.blocks) {
|
||||
if (intervalsOverlap(b.startsAt, b.endsAt, startsAt, endsAtIncludingBuffer)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const b of r.bookings) {
|
||||
if (intervalsOverlap(b.startsAt, b.endsAt, startsAt, endsAtIncludingBuffer)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Working-hours check (timezone-aware)
|
||||
// ============================================================
|
||||
|
||||
export function inWorkingHours(
|
||||
entries: WorkingHoursEntry[],
|
||||
startsAt: Date,
|
||||
endsAt: Date,
|
||||
practiceTz: string,
|
||||
): boolean {
|
||||
const local = zonedParts(startsAt, practiceTz);
|
||||
const localEnd = zonedParts(endsAt, practiceTz);
|
||||
|
||||
// Cross-midnight slots are not allowed — reject.
|
||||
if (
|
||||
local.year !== localEnd.year ||
|
||||
local.month !== localEnd.month ||
|
||||
local.day !== localEnd.day
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const weekday = local.weekday;
|
||||
const startMin = local.hour * 60 + local.minute;
|
||||
const endMin = localEnd.hour * 60 + localEnd.minute;
|
||||
|
||||
for (const w of entries) {
|
||||
if (w.weekday !== weekday) continue;
|
||||
if (w.startMin > startMin) continue;
|
||||
if (endMin > w.endMin) continue;
|
||||
if (w.effectiveFrom && startsAt < w.effectiveFrom) continue;
|
||||
if (w.effectiveTo && startsAt >= w.effectiveTo) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
function hasAllTags(have: ReadonlySet<string>, need: readonly string[]): boolean {
|
||||
for (const tag of need) if (!have.has(tag)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function intervalsOverlap(
|
||||
aStart: Date,
|
||||
aEnd: Date,
|
||||
bStart: Date,
|
||||
bEnd: Date,
|
||||
): boolean {
|
||||
// Half-open [) semantics, matching the booking exclusion constraints.
|
||||
return aStart < bEnd && bStart < aEnd;
|
||||
}
|
||||
|
||||
function intervalContains(
|
||||
outerStart: Date,
|
||||
outerEnd: Date,
|
||||
innerStart: Date,
|
||||
innerEnd: Date,
|
||||
): boolean {
|
||||
return outerStart <= innerStart && innerEnd <= outerEnd;
|
||||
}
|
||||
|
||||
function roundUpToGranularity(d: Date, granularityMin: number): Date {
|
||||
const ms = d.getTime();
|
||||
const stepMs = granularityMin * 60_000;
|
||||
const remainder = ms % stepMs;
|
||||
if (remainder === 0) return d;
|
||||
return new Date(ms + (stepMs - remainder));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Timezone — system-TZ-independent extraction via Intl
|
||||
// ============================================================
|
||||
|
||||
const WEEKDAY_INDEX: Readonly<Record<string, number>> = {
|
||||
Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
|
||||
};
|
||||
|
||||
const zonedFormatterCache = new Map<string, Intl.DateTimeFormat>();
|
||||
function getZonedFormatter(tz: string): Intl.DateTimeFormat {
|
||||
let f = zonedFormatterCache.get(tz);
|
||||
if (!f) {
|
||||
f = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
zonedFormatterCache.set(tz, f);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
type ZonedParts = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
weekday: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
};
|
||||
|
||||
function zonedParts(date: Date, tz: string): ZonedParts {
|
||||
const parts = getZonedFormatter(tz).formatToParts(date);
|
||||
const m: Record<string, string> = {};
|
||||
for (const p of parts) m[p.type] = p.value;
|
||||
// hour12:false in en-US returns "24" for midnight; normalize.
|
||||
let hour = Number(m.hour);
|
||||
if (hour === 24) hour = 0;
|
||||
return {
|
||||
year: Number(m.year),
|
||||
month: Number(m.month),
|
||||
day: Number(m.day),
|
||||
weekday: WEEKDAY_INDEX[m.weekday] ?? 0,
|
||||
hour,
|
||||
minute: Number(m.minute),
|
||||
};
|
||||
}
|
||||
19
src/lib/db.ts
Normal file
19
src/lib/db.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
declare global {
|
||||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
function makeClient(): PrismaClient {
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
export const db = globalThis.__prisma ?? makeClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__prisma = db;
|
||||
}
|
||||
270
src/lib/seed.ts
Normal file
270
src/lib/seed.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
// Tag vocabulary (informal — no controlled-vocab table v1).
|
||||
export const TAGS = {
|
||||
// Therapist qualifications
|
||||
swedish: "swedish",
|
||||
deepTissue: "deep-tissue",
|
||||
prenatalCert: "prenatal-cert",
|
||||
hotStoneCert: "hot-stone-cert",
|
||||
lymphatic: "lymphatic",
|
||||
// Room capabilities
|
||||
prenatalTable: "prenatal-table",
|
||||
hotStoneEquipped: "hot-stone-equipped",
|
||||
couples: "couples",
|
||||
wetRoom: "wet-room",
|
||||
} as const;
|
||||
|
||||
type TherapistSeed = {
|
||||
email: string;
|
||||
name: string;
|
||||
bio?: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
const THERAPISTS: TherapistSeed[] = [
|
||||
{ email: "mei@touchbase.local", name: "Mei Tanaka", tags: [TAGS.swedish, TAGS.deepTissue, TAGS.lymphatic] },
|
||||
{ email: "carlos@touchbase.local", name: "Carlos Rivera", tags: [TAGS.swedish, TAGS.deepTissue] },
|
||||
{ email: "priya@touchbase.local", name: "Priya Patel", tags: [TAGS.swedish, TAGS.prenatalCert] },
|
||||
{ email: "jordan@touchbase.local", name: "Jordan Lee", tags: [TAGS.swedish, TAGS.hotStoneCert] },
|
||||
{ email: "aisha@touchbase.local", name: "Aisha Johnson", tags: [TAGS.swedish, TAGS.lymphatic, TAGS.prenatalCert] },
|
||||
{ email: "rin@touchbase.local", name: "Rin Park", tags: [TAGS.swedish, TAGS.deepTissue, TAGS.hotStoneCert] },
|
||||
{ email: "daniel@touchbase.local", name: "Daniel Costa", tags: [TAGS.swedish] },
|
||||
{ email: "sara@touchbase.local", name: "Sara Goldberg", tags: [TAGS.swedish, TAGS.prenatalCert, TAGS.lymphatic] },
|
||||
{ email: "emeka@touchbase.local", name: "Emeka Obi", tags: [TAGS.swedish, TAGS.deepTissue, TAGS.hotStoneCert] },
|
||||
{ email: "marisol@touchbase.local", name: "Marisol Cruz", tags: [TAGS.swedish, TAGS.lymphatic] },
|
||||
];
|
||||
|
||||
type RoomSeed = { name: string; tags: string[] };
|
||||
|
||||
const ROOMS: RoomSeed[] = [
|
||||
{ name: "Room 1 — Sunset", tags: [] },
|
||||
{ name: "Room 2 — Ocean", tags: [] },
|
||||
{ name: "Room 3 — Cedar", tags: [TAGS.hotStoneEquipped] },
|
||||
{ name: "Room 4 — Bamboo", tags: [TAGS.hotStoneEquipped] },
|
||||
{ name: "Room 5 — Lotus", tags: [TAGS.prenatalTable] },
|
||||
{ name: "Room 6 — Sage", tags: [TAGS.prenatalTable, TAGS.hotStoneEquipped] },
|
||||
{ name: "Room 7 — Maple", tags: [TAGS.couples] },
|
||||
{ name: "Room 8 — Willow", tags: [] },
|
||||
{ name: "Room 9 — Birch", tags: [TAGS.wetRoom] },
|
||||
{ name: "Room 10 — Quiet", tags: [] },
|
||||
];
|
||||
|
||||
type ServiceSeed = {
|
||||
name: string;
|
||||
description: string;
|
||||
durationMin: number;
|
||||
bufferAfterMin: number;
|
||||
priceCents: number;
|
||||
depositCents: number;
|
||||
requiredTherapistTags: string[];
|
||||
requiredRoomTags: string[];
|
||||
};
|
||||
|
||||
const SERVICES: ServiceSeed[] = [
|
||||
{
|
||||
name: "60-minute Swedish",
|
||||
description: "Classic relaxation massage.",
|
||||
durationMin: 60,
|
||||
bufferAfterMin: 15,
|
||||
priceCents: 9000,
|
||||
depositCents: 2000,
|
||||
requiredTherapistTags: [TAGS.swedish],
|
||||
requiredRoomTags: [],
|
||||
},
|
||||
{
|
||||
name: "90-minute Deep Tissue",
|
||||
description: "Targeted work on chronic tension; firm pressure.",
|
||||
durationMin: 90,
|
||||
bufferAfterMin: 15,
|
||||
priceCents: 13000,
|
||||
depositCents: 3000,
|
||||
requiredTherapistTags: [TAGS.deepTissue],
|
||||
requiredRoomTags: [],
|
||||
},
|
||||
{
|
||||
name: "75-minute Prenatal",
|
||||
description: "Side-lying massage for expectant mothers; certified therapists only.",
|
||||
durationMin: 75,
|
||||
bufferAfterMin: 20,
|
||||
priceCents: 11500,
|
||||
depositCents: 2500,
|
||||
requiredTherapistTags: [TAGS.prenatalCert],
|
||||
requiredRoomTags: [TAGS.prenatalTable],
|
||||
},
|
||||
{
|
||||
name: "90-minute Hot Stone",
|
||||
description: "Heated basalt stones; equipment-dependent.",
|
||||
durationMin: 90,
|
||||
bufferAfterMin: 30,
|
||||
priceCents: 13500,
|
||||
depositCents: 3000,
|
||||
requiredTherapistTags: [TAGS.hotStoneCert],
|
||||
requiredRoomTags: [TAGS.hotStoneEquipped],
|
||||
},
|
||||
{
|
||||
name: "60-minute Lymphatic Drainage",
|
||||
description: "Light, rhythmic strokes to support lymphatic flow.",
|
||||
durationMin: 60,
|
||||
bufferAfterMin: 15,
|
||||
priceCents: 10000,
|
||||
depositCents: 2000,
|
||||
requiredTherapistTags: [TAGS.lymphatic],
|
||||
requiredRoomTags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const CUSTOMERS = [
|
||||
{ email: "alex@example.com", name: "Alex Park" },
|
||||
{ email: "robin@example.com", name: "Robin Halloran" },
|
||||
{ email: "sam@example.com", name: "Sam Beaumont" },
|
||||
];
|
||||
|
||||
const ADMIN = { email: "admin@touchbase.local", name: "Admin User" };
|
||||
|
||||
// 0=Sun .. 6=Sat. Tue=2, Wed=3, ... Sat=6. Default schedule: Tue-Sat 10:00-19:00 local.
|
||||
const DEFAULT_WORKDAYS = [2, 3, 4, 5, 6];
|
||||
const DEFAULT_START_MIN = 10 * 60; // 10:00
|
||||
const DEFAULT_END_MIN = 19 * 60; // 19:00
|
||||
|
||||
const TABLES_TO_WIPE = [
|
||||
"AuditLog",
|
||||
"Notification",
|
||||
"Payment",
|
||||
"Booking",
|
||||
"AvailabilityOverride",
|
||||
"WorkingHours",
|
||||
"ServiceTherapist",
|
||||
"Service",
|
||||
"RoomBlock",
|
||||
"RoomTag",
|
||||
"Room",
|
||||
"TherapistTag",
|
||||
"Therapist",
|
||||
"Customer",
|
||||
"User",
|
||||
];
|
||||
|
||||
export type SeedResult = {
|
||||
admin: { id: string };
|
||||
therapists: { id: string; name: string; tags: string[] }[];
|
||||
rooms: { id: string; name: string; tags: string[] }[];
|
||||
services: {
|
||||
id: string;
|
||||
name: string;
|
||||
durationMin: number;
|
||||
bufferAfterMin: number;
|
||||
requiredTherapistTags: string[];
|
||||
requiredRoomTags: string[];
|
||||
}[];
|
||||
customers: { id: string; name: string; email: string }[];
|
||||
};
|
||||
|
||||
export async function wipe(db: PrismaClient): Promise<void> {
|
||||
// Single statement; CASCADE not needed because we're listing all FK-referenced tables.
|
||||
// RESTART IDENTITY is a no-op since we use cuid PKs but harmless to include.
|
||||
const list = TABLES_TO_WIPE.map((t) => `"${t}"`).join(", ");
|
||||
await db.$executeRawUnsafe(
|
||||
`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function seed(db: PrismaClient): Promise<SeedResult> {
|
||||
await wipe(db);
|
||||
|
||||
// Admin
|
||||
const admin = await db.user.create({
|
||||
data: { email: ADMIN.email, name: ADMIN.name, role: "ADMIN" },
|
||||
});
|
||||
|
||||
// Therapists
|
||||
const therapistRows: SeedResult["therapists"] = [];
|
||||
for (const t of THERAPISTS) {
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email: t.email,
|
||||
name: t.name,
|
||||
role: "THERAPIST",
|
||||
therapist: {
|
||||
create: {
|
||||
bio: t.bio,
|
||||
tags: { create: t.tags.map((tag) => ({ tag })) },
|
||||
workingHours: {
|
||||
create: DEFAULT_WORKDAYS.map((weekday) => ({
|
||||
weekday,
|
||||
startMin: DEFAULT_START_MIN,
|
||||
endMin: DEFAULT_END_MIN,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
therapistRows.push({ id: user.id, name: t.name, tags: t.tags });
|
||||
}
|
||||
|
||||
// Rooms
|
||||
const roomRows: SeedResult["rooms"] = [];
|
||||
for (const r of ROOMS) {
|
||||
const room = await db.room.create({
|
||||
data: {
|
||||
name: r.name,
|
||||
tags: { create: r.tags.map((tag) => ({ tag })) },
|
||||
},
|
||||
});
|
||||
roomRows.push({ id: room.id, name: r.name, tags: r.tags });
|
||||
}
|
||||
|
||||
// Services + ServiceTherapist allowlist (all qualified therapists are allowed)
|
||||
const serviceRows: SeedResult["services"] = [];
|
||||
for (const s of SERVICES) {
|
||||
const eligible = therapistRows.filter((t) =>
|
||||
s.requiredTherapistTags.every((tag) => t.tags.includes(tag)),
|
||||
);
|
||||
const service = await db.service.create({
|
||||
data: {
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
durationMin: s.durationMin,
|
||||
bufferAfterMin: s.bufferAfterMin,
|
||||
priceCents: s.priceCents,
|
||||
depositCents: s.depositCents,
|
||||
requiredTherapistTags: s.requiredTherapistTags,
|
||||
requiredRoomTags: s.requiredRoomTags,
|
||||
therapists: {
|
||||
create: eligible.map((t) => ({ therapistId: t.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
serviceRows.push({
|
||||
id: service.id,
|
||||
name: s.name,
|
||||
durationMin: s.durationMin,
|
||||
bufferAfterMin: s.bufferAfterMin,
|
||||
requiredTherapistTags: s.requiredTherapistTags,
|
||||
requiredRoomTags: s.requiredRoomTags,
|
||||
});
|
||||
}
|
||||
|
||||
// Customers
|
||||
const customerRows: SeedResult["customers"] = [];
|
||||
for (const c of CUSTOMERS) {
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email: c.email,
|
||||
name: c.name,
|
||||
role: "CUSTOMER",
|
||||
customer: { create: {} },
|
||||
},
|
||||
});
|
||||
customerRows.push({ id: user.id, name: c.name, email: c.email });
|
||||
}
|
||||
|
||||
return {
|
||||
admin: { id: admin.id },
|
||||
therapists: therapistRows,
|
||||
rooms: roomRows,
|
||||
services: serviceRows,
|
||||
customers: customerRows,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user