Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -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 } }],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
Reference in new issue
Block a user