Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -1,175 +0,0 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
import { endOfBusinessDay, startOfBusinessDay } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { fail, ok } from "./types";
|
||||
import type { ValidationResult } from "./types";
|
||||
|
||||
/**
|
||||
* Day-of-operations concerns: retrieving appointments and moving them
|
||||
* through their status lifecycle (check-in, start, complete, no-show).
|
||||
* Deliberately has nothing to do with booking/rescheduling/availability —
|
||||
* see booking.service.ts for that. An appointment already exists by the
|
||||
* time anything in this file touches it.
|
||||
*/
|
||||
|
||||
const TRANSITIONS: Record<AppointmentStatus, AppointmentStatus[]> = {
|
||||
PENDING: [AppointmentStatus.CONFIRMED, AppointmentStatus.CANCELLED],
|
||||
CONFIRMED: [AppointmentStatus.CHECKED_IN, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW],
|
||||
CHECKED_IN: [AppointmentStatus.IN_PROGRESS, AppointmentStatus.CANCELLED],
|
||||
IN_PROGRESS: [AppointmentStatus.COMPLETED],
|
||||
COMPLETED: [],
|
||||
CANCELLED: [],
|
||||
NO_SHOW: [],
|
||||
};
|
||||
|
||||
export function canTransition(from: AppointmentStatus, to: AppointmentStatus): boolean {
|
||||
return TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
export function validateTransition(
|
||||
from: AppointmentStatus,
|
||||
to: AppointmentStatus,
|
||||
): ValidationResult {
|
||||
if (!canTransition(from, to)) {
|
||||
return fail({
|
||||
code: "INVALID_STATUS_TRANSITION",
|
||||
message: `An appointment can't move from ${from} to ${to}.`,
|
||||
});
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
const APPOINTMENT_INCLUDE = {
|
||||
services: { include: { service: true } },
|
||||
staff: { include: { user: true } },
|
||||
customer: true,
|
||||
} as const;
|
||||
|
||||
export async function getAppointmentById(prisma: PrismaClient, id: string) {
|
||||
return prisma.appointment.findUnique({ where: { id }, include: APPOINTMENT_INCLUDE });
|
||||
}
|
||||
|
||||
export interface ListAppointmentsParams {
|
||||
businessId?: string;
|
||||
staffId?: string;
|
||||
customerId?: string;
|
||||
statuses?: AppointmentStatus[];
|
||||
fromDate?: CalendarDate;
|
||||
toDate?: CalendarDate;
|
||||
businessTimezone?: string; // required if fromDate/toDate given
|
||||
}
|
||||
|
||||
export async function listAppointments(prisma: PrismaClient, params: ListAppointmentsParams) {
|
||||
const dateFilter =
|
||||
params.fromDate && params.toDate && params.businessTimezone
|
||||
? {
|
||||
startAt: {
|
||||
gte: startOfBusinessDay(params.fromDate, params.businessTimezone),
|
||||
lt: endOfBusinessDay(params.toDate, params.businessTimezone),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return prisma.appointment.findMany({
|
||||
where: {
|
||||
...(params.businessId ? { businessId: params.businessId } : {}),
|
||||
...(params.staffId ? { staffId: params.staffId } : {}),
|
||||
...(params.customerId ? { customerId: params.customerId } : {}),
|
||||
...(params.statuses ? { status: { in: params.statuses } } : {}),
|
||||
...dateFilter,
|
||||
},
|
||||
include: APPOINTMENT_INCLUDE,
|
||||
orderBy: { startAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export type TransitionResult =
|
||||
| { success: true; appointment: NonNullable<Awaited<ReturnType<typeof getAppointmentById>>> }
|
||||
| { success: false; validation: ValidationResult };
|
||||
|
||||
async function transitionStatus(
|
||||
prisma: PrismaClient,
|
||||
appointmentId: string,
|
||||
to: AppointmentStatus,
|
||||
): Promise<TransitionResult> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const appointment = await tx.appointment.findUnique({ where: { id: appointmentId } });
|
||||
if (!appointment) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
|
||||
};
|
||||
}
|
||||
|
||||
const check = validateTransition(appointment.status, to);
|
||||
if (!check.valid) return { success: false, validation: check };
|
||||
|
||||
const updated = await tx.appointment.update({
|
||||
where: { id: appointmentId },
|
||||
data: { status: to },
|
||||
include: APPOINTMENT_INCLUDE,
|
||||
});
|
||||
return { success: true, appointment: updated };
|
||||
});
|
||||
}
|
||||
|
||||
export function checkIn(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.CHECKED_IN);
|
||||
}
|
||||
|
||||
export function start(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.IN_PROGRESS);
|
||||
}
|
||||
|
||||
export function complete(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.COMPLETED);
|
||||
}
|
||||
|
||||
export function markNoShow(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.NO_SHOW);
|
||||
}
|
||||
|
||||
export function confirm(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.CONFIRMED);
|
||||
}
|
||||
|
||||
const CURRENT_APPOINTMENT_ACTIVE_STATUSES: AppointmentStatus[] = [
|
||||
AppointmentStatus.PENDING,
|
||||
AppointmentStatus.CONFIRMED,
|
||||
AppointmentStatus.CHECKED_IN,
|
||||
AppointmentStatus.IN_PROGRESS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Which of a staff member's appointments is "the current one" for the
|
||||
* Current Appointment screen — a pure function over already-fetched
|
||||
* appointments (the page fetches today's list via listAppointments; this
|
||||
* just picks the most relevant one), so it's cheap to unit test every edge
|
||||
* case without a database. Priority, per the Phase 5 brief:
|
||||
* 1. IN_PROGRESS
|
||||
* 2. CHECKED_IN
|
||||
* 3. Scheduled right now (startAt <= now < endAt) and not yet terminal
|
||||
* 4. The next upcoming one today
|
||||
* 5. null — the page shows its own empty state
|
||||
*/
|
||||
export function selectCurrentAppointment<
|
||||
T extends { status: AppointmentStatus; startAt: Date; endAt: Date },
|
||||
>(appointments: T[], now: Date): T | null {
|
||||
const inProgress = appointments.find((a) => a.status === AppointmentStatus.IN_PROGRESS);
|
||||
if (inProgress) return inProgress;
|
||||
|
||||
const checkedIn = appointments.find((a) => a.status === AppointmentStatus.CHECKED_IN);
|
||||
if (checkedIn) return checkedIn;
|
||||
|
||||
const scheduledNow = appointments.find(
|
||||
(a) =>
|
||||
CURRENT_APPOINTMENT_ACTIVE_STATUSES.includes(a.status) && a.startAt <= now && a.endAt > now,
|
||||
);
|
||||
if (scheduledNow) return scheduledNow;
|
||||
|
||||
const upcoming = appointments
|
||||
.filter((a) => CURRENT_APPOINTMENT_ACTIVE_STATUSES.includes(a.status) && a.startAt > now)
|
||||
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime());
|
||||
return upcoming[0] ?? null;
|
||||
}
|
||||
Reference in new issue
Block a user