PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
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;
}