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

-322
View File
@@ -1,322 +0,0 @@
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Date-range operational reports for the owner — distinct from
* owner-reporting.service.ts's single-day dashboard snapshot. Every
* function here is read-only and scoped to one businessId + [from, to]
* window; nothing writes anything.
*/
export interface ReportRange {
from: Date;
to: Date;
}
export interface AppointmentReport {
totalAppointments: number;
completed: number;
cancelled: number;
noShow: number;
completionRate: number;
cancellationRate: number;
noShowRate: number;
grossRevenue: string;
discountsGiven: string;
netRevenue: string;
}
export async function getAppointmentReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<AppointmentReport> {
const appointments = await prisma.appointment.findMany({
where: { businessId: params.businessId, startAt: { gte: params.range.from, lte: params.range.to } },
include: { services: true },
});
const completed = appointments.filter((a) => a.status === "COMPLETED");
const cancelled = appointments.filter((a) => a.status === "CANCELLED");
const noShow = appointments.filter((a) => a.status === "NO_SHOW");
const total = appointments.length;
const grossRevenue = completed.reduce(
(sum, a) => sum + a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0),
0,
);
const discountsGiven = completed.reduce((sum, a) => sum + (a.discountAmount ? Number(a.discountAmount) : 0), 0);
return {
totalAppointments: total,
completed: completed.length,
cancelled: cancelled.length,
noShow: noShow.length,
completionRate: total > 0 ? Math.round((completed.length / total) * 100) : 0,
cancellationRate: total > 0 ? Math.round((cancelled.length / total) * 100) : 0,
noShowRate: total > 0 ? Math.round((noShow.length / total) * 100) : 0,
grossRevenue: grossRevenue.toFixed(2),
discountsGiven: discountsGiven.toFixed(2),
netRevenue: (grossRevenue - discountsGiven).toFixed(2),
};
}
export interface ServiceReportRow {
serviceId: string;
name: string;
timesCompleted: number;
revenue: string;
}
/** Only counts COMPLETED appointments — a cancelled booking never earned revenue for a service. */
export async function getServiceReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<ServiceReportRow[]> {
const lines = await prisma.appointmentService.findMany({
where: {
appointment: {
businessId: params.businessId,
status: "COMPLETED",
startAt: { gte: params.range.from, lte: params.range.to },
},
},
include: { service: { select: { id: true, name: true } } },
});
const byService = new Map<string, ServiceReportRow>();
for (const line of lines) {
const existing = byService.get(line.serviceId);
const revenue = Number(line.priceAtBooking);
if (existing) {
existing.timesCompleted += 1;
existing.revenue = (Number(existing.revenue) + revenue).toFixed(2);
} else {
byService.set(line.serviceId, {
serviceId: line.serviceId,
name: line.service.name,
timesCompleted: 1,
revenue: revenue.toFixed(2),
});
}
}
return [...byService.values()].sort((a, b) => Number(b.revenue) - Number(a.revenue));
}
export interface StaffReportRow {
staffId: string;
name: string;
completed: number;
cancelled: number;
noShow: number;
revenue: string;
}
/** Revenue attributed to a staff member is their completed appointments' net total (subtotal minus any redeemed reward), same figure the appointment detail pages show as "Total". */
export async function getStaffReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<StaffReportRow[]> {
const appointments = await prisma.appointment.findMany({
where: { businessId: params.businessId, startAt: { gte: params.range.from, lte: params.range.to } },
include: { services: true, staff: { include: { user: { select: { name: true } } } } },
});
const byStaff = new Map<string, StaffReportRow>();
for (const a of appointments) {
const row = byStaff.get(a.staffId) ?? {
staffId: a.staffId,
name: a.staff.user.name ?? "Staff",
completed: 0,
cancelled: 0,
noShow: 0,
revenue: "0.00",
};
if (a.status === "COMPLETED") {
row.completed += 1;
const subtotal = a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0);
const discount = a.discountAmount ? Number(a.discountAmount) : 0;
row.revenue = (Number(row.revenue) + (subtotal - discount)).toFixed(2);
} else if (a.status === "CANCELLED") {
row.cancelled += 1;
} else if (a.status === "NO_SHOW") {
row.noShow += 1;
}
byStaff.set(a.staffId, row);
}
return [...byStaff.values()].sort((a, b) => Number(b.revenue) - Number(a.revenue));
}
export interface CustomerReport {
newCustomers: number;
returningCustomers: number;
topCustomers: { customerId: string; name: string; visits: number; spend: string }[];
}
/**
* "New" = a customer whose first-ever appointment with this business falls
* inside the range; "returning" = a customer who visited inside the range
* but already had an appointment before the range started. A customer with
* no appointment in the range at all counts as neither.
*/
export async function getCustomerReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<CustomerReport> {
const appointmentsInRange = await prisma.appointment.findMany({
where: {
businessId: params.businessId,
status: { notIn: ["CANCELLED"] },
startAt: { gte: params.range.from, lte: params.range.to },
},
include: { services: true, customer: { select: { firstName: true, lastName: true } } },
});
const customerIds = [...new Set(appointmentsInRange.map((a) => a.customerId))];
if (customerIds.length === 0) {
return { newCustomers: 0, returningCustomers: 0, topCustomers: [] };
}
const priorAppointments = await prisma.appointment.groupBy({
by: ["customerId"],
where: {
businessId: params.businessId,
customerId: { in: customerIds },
status: { notIn: ["CANCELLED"] },
startAt: { lt: params.range.from },
},
_count: true,
});
const hadPriorVisit = new Set(priorAppointments.map((p) => p.customerId));
let newCustomers = 0;
let returningCustomers = 0;
for (const customerId of customerIds) {
if (hadPriorVisit.has(customerId)) returningCustomers += 1;
else newCustomers += 1;
}
const byCustomer = new Map<string, { customerId: string; name: string; visits: number; spend: string }>();
for (const a of appointmentsInRange) {
const row = byCustomer.get(a.customerId) ?? {
customerId: a.customerId,
name: `${a.customer.firstName} ${a.customer.lastName}`,
visits: 0,
spend: "0.00",
};
row.visits += 1;
if (a.status === "COMPLETED") {
const subtotal = a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0);
const discount = a.discountAmount ? Number(a.discountAmount) : 0;
row.spend = (Number(row.spend) + (subtotal - discount)).toFixed(2);
}
byCustomer.set(a.customerId, row);
}
const topCustomers = [...byCustomer.values()].sort((a, b) => Number(b.spend) - Number(a.spend)).slice(0, 10);
return { newCustomers, returningCustomers, topCustomers };
}
export interface LoyaltyReport {
stampsIssued: number;
stampsRemoved: number;
rewardsEarned: number;
rewardsRedeemed: number;
rewardsExpired: number;
}
export async function getLoyaltyReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<LoyaltyReport> {
const accountScope = { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } } };
const inRange = { createdAt: { gte: params.range.from, lte: params.range.to } };
const [stampTransactions, rewardsEarned, rewardsRedeemed, rewardsExpired] = await Promise.all([
prisma.loyaltyTransaction.findMany({
where: { ...accountScope, ...inRange, type: { in: ["STAMP_ADDED", "STAMP_REMOVED"] } },
select: { amount: true },
}),
prisma.loyaltyReward.count({
where: { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } }, ...inRange },
}),
prisma.loyaltyReward.count({
where: {
loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } },
redeemedAt: { gte: params.range.from, lte: params.range.to },
},
}),
// No expiredAt column exists — the periodic sweep only flips status, so
// this approximates "expired in range" by expiresAt falling in range
// for a reward that is now EXPIRED. Good enough for a trend, not exact
// to the second the sweep ran.
prisma.loyaltyReward.count({
where: {
loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } },
status: "EXPIRED",
expiresAt: { gte: params.range.from, lte: params.range.to },
},
}),
]);
return {
stampsIssued: stampTransactions.filter((t) => t.amount > 0).reduce((sum, t) => sum + t.amount, 0),
stampsRemoved: Math.abs(stampTransactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + t.amount, 0)),
rewardsEarned,
rewardsRedeemed,
rewardsExpired,
};
}
export interface NotificationReportRow {
type: string;
sent: number;
failed: number;
}
export interface NotificationReport {
totalSent: number;
totalFailed: number;
byType: NotificationReportRow[];
byChannel: { channel: string; sent: number; failed: number }[];
}
export async function getNotificationReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<NotificationReport> {
const deliveries = await prisma.notificationDelivery.findMany({
where: {
notification: { businessId: params.businessId },
createdAt: { gte: params.range.from, lte: params.range.to },
},
select: { status: true, channel: true, notification: { select: { type: true } } },
});
const byType = new Map<string, NotificationReportRow>();
const byChannel = new Map<string, { channel: string; sent: number; failed: number }>();
let totalSent = 0;
let totalFailed = 0;
for (const d of deliveries) {
const isSent = d.status === "SENT";
const isFailed = d.status === "FAILED";
if (isSent) totalSent += 1;
if (isFailed) totalFailed += 1;
const typeRow = byType.get(d.notification.type) ?? { type: d.notification.type, sent: 0, failed: 0 };
if (isSent) typeRow.sent += 1;
if (isFailed) typeRow.failed += 1;
byType.set(d.notification.type, typeRow);
const channelRow = byChannel.get(d.channel) ?? { channel: d.channel, sent: 0, failed: 0 };
if (isSent) channelRow.sent += 1;
if (isFailed) channelRow.failed += 1;
byChannel.set(d.channel, channelRow);
}
return {
totalSent,
totalFailed,
byType: [...byType.values()].sort((a, b) => b.sent + b.failed - (a.sent + a.failed)),
byChannel: [...byChannel.values()].sort((a, b) => b.sent + b.failed - (a.sent + a.failed)),
};
}