Post Phase 8 - Fixes 1

Delivered:

Super Administrator — env-var provisioned, no StaffProfile at all (structural exclusion from Team/barber-picker/appointments/staff-totals, not a filter). hasPermission short-circuit + new getActingBusinessContext() helper retrofitted across 16 owner pages so it can actually manage the business.
Media Manager — one upload/library/external-URL modal + field picker, backing every image field app-wide. Local-disk storage + Docker volume, SVG rejected, size-capped.
Welcome backgrounds — desktop/mobile images with CSS breakpoint swap.
Business Branding settings — logo/cover/welcome/favicon/icon/colours all editable post-install; cover image finally rendered (was dead config).
Service descriptions — now surfaced on Home and in the booking flow.
Loyalty card — live preview, ColorPicker/MediaPicker/IconPicker, flip removed for a static premium-styled card, Loyalty added to bottom nav. Fixed a real pre-existing bug where the card's configured colour/image/icon were stored but never actually rendered.
Home Screen Redesign — hero gallery + sticky Services/Portfolio/Reviews/Details tabs, backed by a new unified gallery model/service (no hero/portfolio duplication). Details tab surfaces social links and a map that were never shown before.
Google Reviews — real sync pipeline (24h cron, CRON_SECRET-gated, mirrors the existing notification cron exactly), DB-only customer reads, owner hide/unhide + policy management page.
Architecture cleanup — SettingsPageShell extracted from 6 duplicated page wrappers; fixed a second pre-existing bug where branding image fields could never actually be cleared once set.
Verification: 418 tests passing (236 unit + 182 integration, all against a real MySQL database), full typecheck/lint clean across src, and a real Docker build+run cycle — which caught and fixed a genuine bug (scripts/ wasn't copied into the production image, so Super Admin sync would have crashed on every real deployment).
This commit is contained in:
Stefan Lazic committed 2026-08-04 17:10:30 +01:00
1 parent 77f25c0489
commit 9e39908ea7
79 files changed
+4121 -450

No files matched your search

@@ -9,7 +9,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
@@ -45,15 +45,18 @@ export default async function OwnerAppointmentDetailsPage({
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
// `id` is unused by assertAppointmentInBusiness (only checks businessId)
// — see src/features/staff/authorization.ts. context.userId stands in
// for it since a Super Administrator has no StaffProfile id to offer.
assertAppointmentInBusiness(appointment, { id: context.userId, businessId: context.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
@@ -70,13 +73,13 @@ export default async function OwnerAppointmentDetailsPage({
where: { appointmentId: appointment.id },
orderBy: { createdAt: "desc" },
}),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: owner.businessId }),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: context.businessId }),
appointment.cancelledByUserId
? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } })
: null,
]);
const tz = owner.business.timezone;
const tz = context.businessTimezone;
const totalDuration = appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
+11 -11
View File
@@ -9,7 +9,7 @@ import { AppointmentCard } from "@/components/ui/appointment-card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
@@ -61,19 +61,19 @@ export default async function OwnerCalendarPage({
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
const today = todayIn(owner.business.timezone);
const today = todayIn(context.businessTimezone);
const date: CalendarDate = dateParam ?? today;
const view = viewParam === "agenda" ? "agenda" : "day";
const weekday = weekdayOf(date);
const dateUtcMidnight = calendarDateToUtcMidnight(date);
const businessTimezone = owner.business.timezone;
const businessTimezone = context.businessTimezone;
const allStaff = await prisma.staffProfile.findMany({
where: { businessId: owner.businessId, status: "ACTIVE" },
where: { businessId: context.businessId, status: "ACTIVE" },
include: { user: { select: { id: true, name: true } } },
orderBy: { createdAt: "asc" },
});
@@ -81,22 +81,22 @@ export default async function OwnerCalendarPage({
const [appointments, workingHoursAll, breakDaysAll, timeOffAll] = await Promise.all([
listAppointments(prisma, {
businessId: owner.businessId,
businessId: context.businessId,
staffId: selectedStaff?.id,
fromDate: date,
toDate: date,
businessTimezone,
}),
prisma.workingHours.findMany({
where: { staff: { businessId: owner.businessId }, dayOfWeek: weekday },
where: { staff: { businessId: context.businessId }, dayOfWeek: weekday },
}),
prisma.breakDay.findMany({
where: { dayOfWeek: weekday, break: { staff: { businessId: owner.businessId } } },
where: { dayOfWeek: weekday, break: { staff: { businessId: context.businessId } } },
include: { break: true },
}),
prisma.timeOff.findMany({
where: {
staff: { businessId: owner.businessId },
staff: { businessId: context.businessId },
status: "APPROVED",
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
@@ -105,7 +105,7 @@ export default async function OwnerCalendarPage({
]);
const rewardFlags = await getAvailableRewardFlags(prisma, {
businessId: owner.businessId,
businessId: context.businessId,
customerIds: [...new Set(appointments.map((a) => a.customerId))],
});
+4 -4
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listInAppNotifications } from "@/services/notifications/notification.service";
import { NotificationList } from "@/features/notifications/components/notification-list";
@@ -11,10 +11,10 @@ export const dynamic = "force-dynamic";
/** Access to this route is already gated by (owner)/layout.tsx's MANAGE_STAFF check — this page only fetches this owner's own notifications, same as the staff/customer variants. */
export default async function OwnerNotificationsPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const context = await getActingBusinessContext();
if (!context) redirect("/home");
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
const notifications = await listInAppNotifications(prisma, { userId: context.userId });
return (
<div className="pb-12">
+7 -7
View File
@@ -17,7 +17,7 @@ import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { todayIn } from "@/lib/time/business-time";
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
@@ -44,13 +44,13 @@ export default async function OwnerDashboardPage() {
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
if (!canView) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const today = todayIn(staff.business.timezone);
const today = todayIn(context.businessTimezone);
const summary = await getDashboardSummary(prisma, {
businessId: staff.businessId,
businessTimezone: staff.business.timezone,
businessId: context.businessId,
businessTimezone: context.businessTimezone,
date: today,
});
@@ -58,7 +58,7 @@ export default async function OwnerDashboardPage() {
<div className="space-y-6 px-6 pt-12 pb-12">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted text-sm">{staff.business.name} — today</p>
<p className="text-muted text-sm">{context.businessName} — today</p>
</div>
{summary.warnings.length > 0 && (
+10 -10
View File
@@ -6,7 +6,7 @@ import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { addDays, endOfBusinessDay, startOfBusinessDay, todayIn } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import {
@@ -48,10 +48,10 @@ export default async function OwnerReportsPage({
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
if (!canView) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const tz = staff.business.timezone;
const tz = context.businessTimezone;
const today = todayIn(tz);
const { from: fromParam, to: toParam } = await searchParams;
const toDate: CalendarDate = toParam ?? today;
@@ -61,12 +61,12 @@ export default async function OwnerReportsPage({
const [appointmentReport, serviceReport, staffReport, customerReport, loyaltyReport, notificationReport] =
await Promise.all([
getAppointmentReport(prisma, { businessId: staff.businessId, range }),
getServiceReport(prisma, { businessId: staff.businessId, range }),
getStaffReport(prisma, { businessId: staff.businessId, range }),
getCustomerReport(prisma, { businessId: staff.businessId, range }),
getLoyaltyReport(prisma, { businessId: staff.businessId, range }),
getNotificationReport(prisma, { businessId: staff.businessId, range }),
getAppointmentReport(prisma, { businessId: context.businessId, range }),
getServiceReport(prisma, { businessId: context.businessId, range }),
getStaffReport(prisma, { businessId: context.businessId, range }),
getCustomerReport(prisma, { businessId: context.businessId, range }),
getLoyaltyReport(prisma, { businessId: context.businessId, range }),
getNotificationReport(prisma, { businessId: context.businessId, range }),
]);
return (
+5 -4
View File
@@ -4,7 +4,7 @@ import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listCategories } from "@/services/catalogue/service-catalogue.service";
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
@@ -18,10 +18,10 @@ export default async function OwnerServicesPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const categories = await listCategories(prisma, owner.businessId);
const categories = await listCategories(prisma, context.businessId);
return (
<div className="px-6 pt-12 pb-12 lg:max-w-3xl">
@@ -36,6 +36,7 @@ export default async function OwnerServicesPage() {
categoryId: s.categoryId,
name: s.name,
description: s.description ?? "",
imageUrl: s.imageUrl,
durationMinutes: s.durationMinutes,
price: s.price.toString(),
bufferBeforeMinutes: s.bufferBeforeMinutes,
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
@@ -19,33 +19,30 @@ export default async function OwnerBookingRulesPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const rule = await getBookingRule(prisma, owner.businessId);
const rule = await getBookingRule(prisma, context.businessId);
if (!rule) redirect("/owner");
return (
<div className="pb-12">
<TopNav title="Booking Rules" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</main>
</div>
<SettingsPageShell title="Booking Rules">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</SettingsPageShell>
);
}
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessProfile } from "@/services/business/business.service";
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
@@ -19,23 +19,28 @@ export default async function OwnerBusinessDetailsPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, staff.businessId);
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
const location = business.locations[0];
return (
<div className="pb-12">
<TopNav title="Business Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BusinessDetailsForm
<SettingsPageShell title="Business Details">
<BusinessDetailsForm
initial={{
name: business.name,
description: business.description ?? "",
logoUrl: business.logoUrl ?? "",
logoUrl: business.logoUrl,
coverImageUrl: business.coverImageUrl,
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
faviconUrl: business.faviconUrl,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor,
backgroundColor: business.backgroundColor,
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
@@ -54,9 +59,8 @@ export default async function OwnerBusinessDetailsPage() {
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
bookingTerms: business.settings?.bookingTerms ?? "",
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
}}
/>
</main>
</div>
}}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { GalleryPlacement } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listGalleryImages } from "@/services/gallery/gallery.service";
import { GalleryManager } from "@/features/owner/components/gallery-manager";
export const metadata: Metadata = { title: "Gallery" };
export const dynamic = "force-dynamic";
export default async function OwnerGalleryPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [heroImages, portfolioImages] = await Promise.all([
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.HERO }),
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.PORTFOLIO }),
]);
return (
<SettingsPageShell title="Gallery" mainClassName="space-y-8">
<GalleryManager
placement={GalleryPlacement.HERO}
title="Home Hero Gallery"
description="Shown at the top of the customer Home page. Falls back to your cover image, then your logo, when empty."
showCategory={false}
initialImages={heroImages}
/>
<GalleryManager
placement={GalleryPlacement.PORTFOLIO}
title="Portfolio"
description="Shown in the Portfolio tab on Home. Add a category to each image to power the filter pills there."
showCategory
initialImages={portfolioImages}
/>
</SettingsPageShell>
);
}
+8 -11
View File
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getOpeningHours } from "@/services/business/business.service";
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
@@ -19,17 +19,14 @@ export default async function OwnerOpeningHoursPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const hours = await getOpeningHours(prisma, staff.businessId);
const hours = await getOpeningHours(prisma, context.businessId);
return (
<div className="pb-12">
<TopNav title="Opening Hours" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<OpeningHoursForm initialRows={hours} />
</main>
</div>
<SettingsPageShell title="Opening Hours" mainClassName="lg:max-w-2xl">
<OpeningHoursForm initialRows={hours} />
</SettingsPageShell>
);
}
+17 -19
View File
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
@@ -19,14 +19,14 @@ export default async function OwnerLoyaltyConfigPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [programme, services, staff] = await Promise.all([
getProgrammeConfig(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
getProgrammeConfig(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
prisma.staffProfile.findMany({
where: { businessId: owner.businessId, status: "ACTIVE" },
where: { businessId: context.businessId, status: "ACTIVE" },
include: { user: { select: { name: true } } },
}),
]);
@@ -62,17 +62,15 @@ export default async function OwnerLoyaltyConfigPage() {
};
return (
<div className="pb-12">
<TopNav title="Loyalty Programme" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<LoyaltyConfigForm
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
services={services.map((s) => ({ id: s.id, name: s.name }))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
/>
</main>
</div>
<SettingsPageShell title="Loyalty Programme">
<LoyaltyConfigForm
businessName={context.businessName}
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
services={services.map((s) => ({ id: s.id, name: s.name }))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
/>
</SettingsPageShell>
);
}
+3 -1
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Bell, Building2, CalendarClock, ClipboardList, Crown, ScissorsSquare } from "lucide-react";
import { Bell, Building2, CalendarClock, ClipboardList, Crown, Images, ScissorsSquare, Star } from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { ProfileCard } from "@/components/ui/profile-card";
@@ -15,9 +15,11 @@ export const dynamic = "force-dynamic";
const SETTINGS_LINKS = [
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
{ href: "/owner/settings/reviews", label: "Reviews", icon: Star },
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
] as const;
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessProfile } from "@/services/business/business.service";
import { listReviewsForOwner } from "@/services/reviews/review-management.service";
import { ReviewsManager } from "@/features/owner/components/reviews-manager";
export const metadata: Metadata = { title: "Reviews" };
export const dynamic = "force-dynamic";
export default async function OwnerReviewsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_REVIEWS);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [business, reviews] = await Promise.all([
getBusinessProfile(prisma, context.businessId),
listReviewsForOwner(prisma, context.businessId),
]);
if (!business) redirect("/owner");
return (
<SettingsPageShell title="Reviews">
<ReviewsManager
initialGooglePlaceId={business.googlePlaceId ?? ""}
initialReviewsMinRating={business.settings?.reviewsMinRating ?? null}
initialReviewsExcludeRatingOnly={business.settings?.reviewsExcludeRatingOnly ?? false}
googleRating={business.googleRating ? Number(business.googleRating) : null}
googleRatingCount={business.googleRatingCount}
reviews={reviews.map((r) => ({
id: r.id,
authorName: r.authorName,
rating: r.rating,
text: r.text,
relativeTimeDescription: r.relativeTimeDescription,
isHidden: r.isHidden,
}))}
/>
</SettingsPageShell>
);
}
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listBreaks } from "@/services/staff/staff.service";
import { BreaksList } from "@/features/staff/components/breaks-list";
import { createOwnerBreakActionSet } from "@/features/owner/actions/break-action-set";
@@ -20,12 +20,12 @@ export default async function OwnerStaffBreaksPage({ params }: { params: Promise
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: owner.businessId },
where: { id, businessId: context.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission, Weekday } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getWorkingHours } from "@/services/staff/staff.service";
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
@@ -36,19 +36,19 @@ export default async function OwnerStaffWorkingHoursPage({ params }: { params: P
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: owner.businessId },
where: { id, businessId: context.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
const [workingHours, businessHours] = await Promise.all([
getWorkingHours(prisma, id),
prisma.businessHours.findMany({ where: { businessId: owner.businessId } }),
prisma.businessHours.findMany({ where: { businessId: context.businessId } }),
]);
const businessHoursLabel = Object.fromEntries(
+6 -6
View File
@@ -8,7 +8,7 @@ import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
@@ -22,14 +22,14 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const [target, roles, allServices, calendarConnection] = await Promise.all([
getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }),
listRolesForBusiness(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
getStaffDetailById(prisma, { staffId: id, businessId: context.businessId }),
listRolesForBusiness(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
// Status only — never tokens (see the brief's "Owners must not see
// OAuth tokens, refresh tokens, or unrelated private calendar
// events"). Owners can see whether a barber has connected, not
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listTimeOff } from "@/services/staff/staff.service";
import { TimeOffList } from "@/features/staff/components/time-off-list";
import { createOwnerTimeOffActionSet } from "@/features/owner/actions/time-off-action-set";
@@ -24,12 +24,12 @@ export default async function OwnerStaffTimeOffPage({ params }: { params: Promis
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: owner.businessId },
where: { id, businessId: context.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
+5 -5
View File
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listRolesForBusiness } from "@/services/staff/staff.service";
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
@@ -19,12 +19,12 @@ export default async function OwnerAddStaffPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [roles, services] = await Promise.all([
listRolesForBusiness(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
listRolesForBusiness(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
]);
return (
+4 -4
View File
@@ -6,7 +6,7 @@ import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listStaffForBusiness } from "@/services/staff/staff.service";
import { cn } from "@/lib/utils/cn";
@@ -24,11 +24,11 @@ export default async function OwnerTeamPage({
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { q = "" } = await searchParams;
const staff = await listStaffForBusiness(prisma, { businessId: owner.businessId, q, includeInactive: true });
const staff = await listStaffForBusiness(prisma, { businessId: context.businessId, q, includeInactive: true });
return (
<div className="px-6 pt-12 pb-12 lg:max-w-4xl">