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

+69 -56
View File
@@ -1,5 +1,5 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationCategory, NotificationChannel, Weekday } from "@/generated/prisma/enums";
import { Weekday } from "@/generated/prisma/enums";
import {
calendarDateOf,
calendarDateToUtcMidnight,
@@ -152,12 +152,72 @@ export async function getWorkingHours(prisma: PrismaClient, staffId: string) {
return prisma.workingHours.findMany({ where: { staffId }, orderBy: { dayOfWeek: "asc" } });
}
/**
* Future, non-cancelled appointments that would fall outside the proposed
* working hours — either on a day newly marked off, or before/after the
* new start/end window on a day that stays working. Same pattern as
* findBreakConflicts/findTimeOffConflicts/BusinessService's
* findOpeningHoursConflicts (see docs/phase-7-summary.md, "Working-hours
* conflict review"): this never cancels anything itself, only reports what
* the caller should show before asking for acknowledgement.
*/
export async function findWorkingHoursConflicts(
prisma: PrismaClient,
params: { staffId: string; businessTimezone: string; days: WorkingHoursDayInput[] },
) {
const appointments = await prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW"] },
startAt: { gte: new Date() },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
const byDay = new Map(params.days.map((d) => [d.dayOfWeek, d]));
return appointments.filter((a) => {
const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
const proposed = byDay.get(weekday);
if (!proposed) return false;
if (proposed.isOff) return true;
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
return aStart < (proposed.startMinutes ?? 0) || aEnd > (proposed.endMinutes ?? 24 * 60);
});
}
export async function setWorkingHours(
prisma: PrismaClient,
params: { staffId: string; days: WorkingHoursDayInput[] },
): Promise<{ success: boolean; validation: ValidationResult<StaffServiceErrorCode> }> {
params: {
staffId: string;
businessTimezone: string;
days: WorkingHoursDayInput[];
acknowledgeConflicts?: boolean;
},
) {
const validation = validateWorkingHoursInput(params.days);
if (!validation.valid) return { success: false, validation };
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findWorkingHoursConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
days: params.days,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "These hours would leave existing appointments outside your working hours.",
}),
};
}
}
await prisma.$transaction(
params.days.map((day) =>
@@ -179,7 +239,7 @@ export async function setWorkingHours(
),
);
return { success: true, validation: ok() };
return { success: true as const, conflicts: [], validation: ok() };
}
// ── Breaks ──────────────────────────────────────────────────────────────
@@ -582,53 +642,6 @@ export async function deleteTimeOff(
return { success: true };
}
// ── Notification preferences ───────────────────────────────────────────
// UI only this phase — no real delivery. Every (category, channel) pair
// defaults to enabled (matches the schema's `isEnabled @default(true)`),
// so a user with no rows yet still sees a fully-populated, all-on grid.
const NOTIFICATION_CATEGORIES = Object.values(NotificationCategory);
const NOTIFICATION_CHANNELS = Object.values(NotificationChannel);
export interface NotificationPreferenceCell {
category: NotificationCategory;
channel: NotificationChannel;
isEnabled: boolean;
}
export async function getNotificationPreferences(
prisma: PrismaClient,
userId: string,
): Promise<NotificationPreferenceCell[]> {
const rows = await prisma.notificationPreference.findMany({ where: { userId } });
const byKey = new Map(rows.map((r) => [`${r.category}:${r.channel}`, r.isEnabled]));
const cells: NotificationPreferenceCell[] = [];
for (const category of NOTIFICATION_CATEGORIES) {
for (const channel of NOTIFICATION_CHANNELS) {
cells.push({ category, channel, isEnabled: byKey.get(`${category}:${channel}`) ?? true });
}
}
return cells;
}
export async function setNotificationPreferences(
prisma: PrismaClient,
userId: string,
updates: NotificationPreferenceCell[],
): Promise<{ success: true }> {
await prisma.$transaction(
updates.map((u) =>
prisma.notificationPreference.upsert({
where: { userId_category_channel: { userId, category: u.category, channel: u.channel } },
create: { userId, category: u.category, channel: u.channel, isEnabled: u.isEnabled },
update: { isEnabled: u.isEnabled },
}),
),
);
return { success: true };
}
// ── Owner: team management ─────────────────────────────────────────────
// Everything below acts on staff *other than* the caller — the owner's own
// business-membership must already be verified by the caller (see
@@ -643,13 +656,13 @@ export async function listStaffForBusiness(
where: {
businessId: params.businessId,
...(params.includeInactive ? {} : { status: { not: "INACTIVE" } }),
// No `mode: "insensitive"` — that's a Postgres-only Prisma option (removed in the
// Phase 7 MySQL migration). MySQL's default collation (utf8mb4_0900_ai_ci — "ai" =
// accent-insensitive, "ci" = case-insensitive) already makes `contains` case-insensitive.
...(params.q
? {
user: {
OR: [
{ name: { contains: params.q, mode: "insensitive" } },
{ email: { contains: params.q, mode: "insensitive" } },
],
OR: [{ name: { contains: params.q } }, { email: { contains: params.q } }],
},
}
: {}),