Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
@@ -1,11 +1,36 @@
|
||||
import { Scissors } from "lucide-react";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Every auth page (sign-in, register, forgot-password, verify) shares
|
||||
* this shell, so the business logo is shown once here rather than on
|
||||
* each page — see the brief's "Authentication Branding" requirement.
|
||||
* Unreachable pre-install: src/proxy.ts redirects auth routes to
|
||||
* /install until a business exists, so `getBranding()` never falls back
|
||||
* to neutral wording here in practice.
|
||||
*
|
||||
* Forced dynamic: without this, a production build can statically
|
||||
* prerender /sign-in and /register with whatever branding (or the
|
||||
* neutral fallback) happened to be in the database at *build* time,
|
||||
* baked into static HTML served forever after — a real business's name
|
||||
* would never actually reach these pages in that deployment. See
|
||||
* docs/phase-8-summary.md, "Known limitations" for how this was found
|
||||
* (a Docker build with no live database at build time).
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
const branding = await getBranding();
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-stone p-6">
|
||||
<div id="main-content" className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center">
|
||||
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-[1.25rem] bg-ink text-3xl text-white shadow-xl shadow-ink/20">
|
||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||
{branding.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL.
|
||||
<img src={branding.logoUrl} alt="" className="h-full w-full rounded-[1.25rem] object-cover" />
|
||||
) : (
|
||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { RegisterForm } from "@/features/auth/components/register-form";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Account",
|
||||
};
|
||||
|
||||
export default function RegisterPage() {
|
||||
export default async function RegisterPage() {
|
||||
const branding = await getBranding();
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-2 text-3xl font-bold tracking-tight">Create Account</h1>
|
||||
<p className="mb-10 text-muted">Join to track your appointments and loyalty.</p>
|
||||
<RegisterForm />
|
||||
<h1 className="mb-2 text-3xl font-bold tracking-tight">Join {branding.name}</h1>
|
||||
<p className="mb-10 text-muted">Create an account to track your appointments and loyalty.</p>
|
||||
{/* useSearchParams in RegisterForm requires a Suspense boundary. */}
|
||||
<Suspense>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { SignInForm } from "@/features/auth/components/sign-in-form";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign In",
|
||||
};
|
||||
|
||||
export default function SignInPage() {
|
||||
export default async function SignInPage() {
|
||||
const branding = await getBranding();
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-2 text-4xl font-bold tracking-tight">Welcome back</h1>
|
||||
<h1 className="mb-2 text-4xl font-bold tracking-tight">Welcome back to {branding.name}</h1>
|
||||
<p className="mb-12 leading-relaxed text-muted">
|
||||
Please enter your credentials to access your appointments.
|
||||
</p>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { ProfileCard } from "@/components/ui/profile-card";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||
|
||||
@@ -33,9 +34,10 @@ export default async function CustomerSettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const customerProfile = await prisma.customerProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
const [customerProfile, branding] = await Promise.all([
|
||||
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
|
||||
getBranding(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-12">
|
||||
@@ -65,7 +67,7 @@ export default async function CustomerSettingsPage() {
|
||||
</div>
|
||||
<div className="border-line rounded-jumbo border bg-white p-5">
|
||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||
<InstallAppButton />
|
||||
<InstallAppButton businessName={branding.name} />
|
||||
</div>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ProfileCard } from "@/components/ui/profile-card";
|
||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||
|
||||
export const metadata: Metadata = { title: "Owner Settings" };
|
||||
@@ -26,6 +27,7 @@ export default async function OwnerSettingsPage() {
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
const branding = await getBranding();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||
@@ -53,7 +55,7 @@ export default async function OwnerSettingsPage() {
|
||||
|
||||
<div className="rounded-jumbo border-line border bg-white p-5">
|
||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||
<InstallAppButton />
|
||||
<InstallAppButton businessName={branding.name} />
|
||||
</div>
|
||||
|
||||
<SignOutButton />
|
||||
|
||||
@@ -26,10 +26,15 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const [target, roles, allServices] = await Promise.all([
|
||||
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" } }),
|
||||
// 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
|
||||
// connect on their behalf.
|
||||
prisma.staffCalendarConnection.findUnique({ where: { staffId: id }, select: { revokedAt: true, lastSyncError: true } }),
|
||||
]);
|
||||
if (!target) notFound();
|
||||
|
||||
@@ -72,6 +77,20 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">Google Calendar</p>
|
||||
<p className="text-sm font-bold">
|
||||
{!calendarConnection || calendarConnection.revokedAt
|
||||
? "Not connected"
|
||||
: calendarConnection.lastSyncError
|
||||
? "Sync error"
|
||||
: "Connected"}
|
||||
</p>
|
||||
{calendarConnection?.lastSyncError && !calendarConnection.revokedAt && (
|
||||
<p className="text-danger mt-1 text-xs">{calendarConnection.lastSyncError}</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<StaffDetailPanel
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
import { CalendarConnectionPanel } from "@/features/calendar/components/calendar-connection-panel";
|
||||
|
||||
export const metadata: Metadata = { title: "Google Calendar" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StaffCalendarSettingsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/sign-in?callbackUrl=/staff/settings/calendar");
|
||||
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staffProfile) redirect("/home");
|
||||
|
||||
const [connection, branding] = await Promise.all([
|
||||
prisma.staffCalendarConnection.findUnique({ where: { staffId: staffProfile.id } }),
|
||||
getBranding(),
|
||||
]);
|
||||
const connected = !!connection && !connection.revokedAt;
|
||||
const configured = !!process.env.GOOGLE_CALENDAR_CLIENT_ID && !!process.env.GOOGLE_CALENDAR_CLIENT_SECRET;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||
<TopNav title="Google Calendar" onBack sticky className="bg-stone" />
|
||||
<p className="text-muted text-sm">
|
||||
One-way sync: {branding.name} stays the source of truth, and your confirmed appointments are pushed to
|
||||
Google Calendar. Nothing is ever imported back from Google.
|
||||
</p>
|
||||
<CalendarConnectionPanel
|
||||
configured={configured}
|
||||
initial={
|
||||
connected
|
||||
? {
|
||||
connected: true,
|
||||
accountEmail: connection.accountEmail,
|
||||
selectedCalendarName: connection.selectedCalendarName,
|
||||
lastSyncAt: connection.lastSyncAt?.toISOString(),
|
||||
lastSyncError: connection.lastSyncError,
|
||||
}
|
||||
: { connected: false }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Bell, CalendarClock, CalendarOff, Clock, ShieldCheck, UserCircle } from "lucide-react";
|
||||
import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, ShieldCheck, UserCircle } from "lucide-react";
|
||||
import { auth } from "@/auth";
|
||||
import { ProfileCard } from "@/components/ui/profile-card";
|
||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||
|
||||
const SETTINGS_LINKS = [
|
||||
@@ -14,6 +15,7 @@ const SETTINGS_LINKS = [
|
||||
{ href: "/staff/settings/time-off", label: "Time Off", icon: CalendarOff },
|
||||
{ href: "/staff/settings/profile", label: "Profile", icon: UserCircle },
|
||||
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
|
||||
{ href: "/staff/settings/calendar", label: "Google Calendar", icon: CalendarSync },
|
||||
] as const;
|
||||
|
||||
export const metadata: Metadata = { title: "Staff Settings" };
|
||||
@@ -25,7 +27,10 @@ export default async function StaffSettingsPage() {
|
||||
// Real permission check, not the templates' unconditional mock "Switch to
|
||||
// Admin View" button (see docs/implementation-plan.md §3) — only staff
|
||||
// whose Role actually grants MANAGE_STAFF see a way into the owner area.
|
||||
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
const [canManageStaff, branding] = await Promise.all([
|
||||
hasPermission(session.user.id, Permission.MANAGE_STAFF),
|
||||
getBranding(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6 pt-12">
|
||||
@@ -55,7 +60,7 @@ export default async function StaffSettingsPage() {
|
||||
|
||||
<div className="rounded-jumbo border border-line bg-white p-5">
|
||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||
<InstallAppButton />
|
||||
<InstallAppButton businessName={branding.name} />
|
||||
</div>
|
||||
|
||||
<SignOutButton />
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
const ics = generateAppointmentIcs({
|
||||
appointmentId: appointment.id,
|
||||
businessName: business?.name ?? "NextChair",
|
||||
businessName: business?.name ?? "the business",
|
||||
businessAddress: business?.locations[0]
|
||||
? `${business.locations[0].addressLine1}, ${business.locations[0].city} ${business.locations[0].postalCode}`
|
||||
: undefined,
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { isInstalledForRequest } from "@/lib/installation/installation-state";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
import { expireOverdueRewards } from "@/services/loyalty/loyalty.service";
|
||||
import { getEmailProvider, getPushProvider } from "@/services/notifications/providers";
|
||||
import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker";
|
||||
|
||||
/** Same constant-time convention as src/lib/booking-draft/sign.ts and guest-access.service.ts — a plain `!==` on a secret leaks its length/prefix via timing. */
|
||||
function secretsMatch(provided: string, expected: string): boolean {
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (providedBuffer.length !== expectedBuffer.length) return false;
|
||||
return timingSafeEqual(providedBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* The external trigger point for the background notification processor —
|
||||
* see reminder-worker.ts for why this exists instead of an in-process
|
||||
@@ -47,6 +40,14 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
|
||||
}
|
||||
|
||||
// No business data exists to process before the installer has run — see
|
||||
// src/lib/installation/installation-state.ts. A 200 with a no-op result
|
||||
// (not an error) so an external scheduler hitting this on a fixed
|
||||
// interval before day-one setup doesn't page anyone.
|
||||
if (!(await isInstalledForRequest())) {
|
||||
return NextResponse.json({ skipped: "not_installed" });
|
||||
}
|
||||
|
||||
const result = await processDueNotificationDeliveries(prisma, {
|
||||
emailProvider: getEmailProvider(),
|
||||
pushProvider: getPushProvider(),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getInstallationState } from "@/lib/installation/installation-state";
|
||||
|
||||
/**
|
||||
* Docker/orchestrator health check — deliberately never requires
|
||||
* installation to have completed: a fresh deployment with a reachable,
|
||||
* migrated database but no business yet is healthy (it should correctly
|
||||
* show the installer), not unhealthy. Only a database connectivity
|
||||
* failure returns a non-200 status. Never returns secrets or a stack
|
||||
* trace — just enough for a health probe to act on.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch {
|
||||
return NextResponse.json({ status: "unhealthy", database: "unreachable" }, { status: 503 });
|
||||
}
|
||||
|
||||
const { installed } = await getInstallationState().catch(() => ({ installed: false }));
|
||||
|
||||
return NextResponse.json({ status: "healthy", database: "reachable", installed });
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { createOAuthState } from "@/lib/security/oauth-state";
|
||||
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
|
||||
|
||||
function getRedirectUri(): string {
|
||||
const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI;
|
||||
if (explicit) return explicit;
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||||
return `${appUrl}/api/staff/calendar/google/callback`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Google Calendar connect flow for the signed-in staff
|
||||
* member's own account — deliberately separate from Google sign-in (see
|
||||
* docs/phase-8-summary.md): a barber may connect a different Google
|
||||
* account here than the one they authenticate with. `state` is a signed,
|
||||
* short-lived, staff-scoped token (see oauth-state.ts) set as an httpOnly
|
||||
* cookie and re-verified in the callback — CSRF/replay protection without
|
||||
* server-side session storage.
|
||||
*/
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
|
||||
}
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
|
||||
if (!staffProfile) {
|
||||
return NextResponse.json({ error: "Only staff members can connect a calendar." }, { status: 403 });
|
||||
}
|
||||
|
||||
// Fails closed with a friendly redirect, not a 500 — see the brief's
|
||||
// "show disabled states rather than crashing" for every optional
|
||||
// integration. The Settings page itself also hides the Connect button
|
||||
// when unconfigured (see calendar-connection-panel.tsx), so reaching
|
||||
// this route unconfigured means a stale link or a direct hit.
|
||||
if (!process.env.GOOGLE_CALENDAR_CLIENT_ID || !process.env.GOOGLE_CALENDAR_CLIENT_SECRET) {
|
||||
const url = new URL("/staff/settings/calendar", process.env.NEXT_PUBLIC_APP_URL);
|
||||
url.searchParams.set("error", "1");
|
||||
url.searchParams.set("detail", "not_configured");
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
const state = createOAuthState(staffProfile.id);
|
||||
const provider = new GoogleCalendarProvider();
|
||||
const authorizationUrl = provider.buildAuthorizationUrl({ redirectUri: getRedirectUri(), state });
|
||||
|
||||
const response = NextResponse.redirect(authorizationUrl);
|
||||
response.cookies.set("gcal_oauth_state", state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { encryptToken } from "@/lib/security/token-encryption";
|
||||
import { verifyOAuthState } from "@/lib/security/oauth-state";
|
||||
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
|
||||
import { CalendarProviderError } from "@/services/calendar/types";
|
||||
import { runInitialSync } from "@/services/calendar/calendar-sync.service";
|
||||
|
||||
function getRedirectUri(): string {
|
||||
const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI;
|
||||
if (explicit) return explicit;
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||||
return `${appUrl}/api/staff/calendar/google/callback`;
|
||||
}
|
||||
|
||||
function settingsRedirect(status: "connected" | "error", detail?: string): NextResponse {
|
||||
const url = new URL("/staff/settings/calendar", process.env.NEXT_PUBLIC_APP_URL);
|
||||
url.searchParams.set(status, "1");
|
||||
if (detail) url.searchParams.set("detail", detail);
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.delete("gcal_oauth_state");
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consent denial, an expired/tampered state, or a state issued for a
|
||||
* different staff member than the one currently signed in are all
|
||||
* treated the same way: bounce back to Settings with a generic error —
|
||||
* never revealing which check failed (same principle as the installation
|
||||
* token check, see installer.service.ts).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const returnedState = url.searchParams.get("state");
|
||||
const consentDenied = url.searchParams.get("error");
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
|
||||
}
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
|
||||
if (!staffProfile) {
|
||||
return settingsRedirect("error", "not_staff");
|
||||
}
|
||||
|
||||
if (consentDenied) {
|
||||
return settingsRedirect("error", "consent_denied");
|
||||
}
|
||||
|
||||
const cookieState = request.headers.get("cookie")?.match(/gcal_oauth_state=([^;]+)/)?.[1];
|
||||
if (!code || !returnedState || !cookieState || returnedState !== cookieState || !verifyOAuthState(cookieState, staffProfile.id)) {
|
||||
return settingsRedirect("error", "invalid_state");
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = new GoogleCalendarProvider();
|
||||
const tokens = await provider.exchangeCodeForTokens({ code, redirectUri: getRedirectUri() });
|
||||
const calendars = await provider.listWritableCalendars(tokens.accessToken);
|
||||
const primaryCalendar = calendars.find((c) => c.primary) ?? calendars[0];
|
||||
if (!primaryCalendar) {
|
||||
return settingsRedirect("error", "no_writable_calendar");
|
||||
}
|
||||
|
||||
const connection = await prisma.staffCalendarConnection.upsert({
|
||||
where: { staffId: staffProfile.id },
|
||||
create: {
|
||||
staffId: staffProfile.id,
|
||||
externalAccountId: tokens.externalAccountId,
|
||||
accountEmail: tokens.accountEmail,
|
||||
encryptedAccessToken: encryptToken(tokens.accessToken),
|
||||
encryptedRefreshToken: encryptToken(tokens.refreshToken),
|
||||
tokenExpiresAt: tokens.expiresAt,
|
||||
selectedCalendarId: primaryCalendar.id,
|
||||
selectedCalendarName: primaryCalendar.name,
|
||||
},
|
||||
update: {
|
||||
externalAccountId: tokens.externalAccountId,
|
||||
accountEmail: tokens.accountEmail,
|
||||
encryptedAccessToken: encryptToken(tokens.accessToken),
|
||||
encryptedRefreshToken: encryptToken(tokens.refreshToken),
|
||||
tokenExpiresAt: tokens.expiresAt,
|
||||
selectedCalendarId: primaryCalendar.id,
|
||||
selectedCalendarName: primaryCalendar.name,
|
||||
revokedAt: null,
|
||||
lastSyncError: null,
|
||||
},
|
||||
});
|
||||
|
||||
await runInitialSync(prisma, connection.staffId);
|
||||
|
||||
return settingsRedirect("connected");
|
||||
} catch (error) {
|
||||
const detail = error instanceof CalendarProviderError ? error.code.toLowerCase() : "unknown";
|
||||
return settingsRedirect("error", detail);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LogIn } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { readDraft } from "@/lib/booking-draft/cookie";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
@@ -10,6 +14,8 @@ import { DetailsForm } from "@/features/booking/components/details-form";
|
||||
export const metadata: Metadata = { title: "Your Details" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CALLBACK_URL = "/book/details";
|
||||
|
||||
export default async function BookDetailsPage() {
|
||||
const business = await getPrimaryBusiness();
|
||||
const draft = await readDraft(business.id);
|
||||
@@ -18,6 +24,36 @@ export default async function BookDetailsPage() {
|
||||
if (!draft.startAt) redirect("/book/barber-time");
|
||||
|
||||
const session = await auth();
|
||||
|
||||
// Booking progress up to this point (services, barber, time) is stored
|
||||
// in the signed draft cookie, which survives navigation to /sign-in or
|
||||
// /register and back — see src/lib/booking-draft/cookie.ts. Only the
|
||||
// *details* step itself (and everything after it) requires an account
|
||||
// when guest booking is disabled; browsing and slot selection never do.
|
||||
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
|
||||
if (!session?.user && !guestBookingEnabled) {
|
||||
return (
|
||||
<div className="pb-6">
|
||||
<TopNav title="Your Details" onBack={true} sticky className="bg-stone" />
|
||||
<EmptyState
|
||||
icon={LogIn}
|
||||
title="Sign in to complete your booking"
|
||||
description="Create an account or sign in to confirm your appointment, manage your bookings and keep track of your loyalty rewards."
|
||||
action={
|
||||
<div className="flex w-full gap-3">
|
||||
<Button asChild variant="secondary" className="flex-1">
|
||||
<Link href={`/sign-in?callbackUrl=${encodeURIComponent(CALLBACK_URL)}`}>Sign In</Link>
|
||||
</Button>
|
||||
<Button asChild className="flex-1">
|
||||
<Link href={`/register?callbackUrl=${encodeURIComponent(CALLBACK_URL)}`}>Create Account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let prefill: { firstName: string; lastName: string; email: string; phone: string } | undefined;
|
||||
|
||||
if (session?.user?.id) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Deliberately not the (auth)/(customer)/(staff)/(owner) shells — this
|
||||
* route renders before any business exists, so there is nothing to brand
|
||||
* with yet (see the brief's "Offline and Error Branding" section: use
|
||||
* neutral installation wording before install, business branding after).
|
||||
* A plain neutral mark, not the app's own logo/name, keeps that boundary
|
||||
* honest rather than showing product branding on the one screen where it
|
||||
* explicitly shouldn't appear.
|
||||
*/
|
||||
export default function InstallLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-stone p-6">
|
||||
<div id="main-content" className="mx-auto w-full max-w-lg flex-1 py-10">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getInstallationState } from "@/lib/installation/installation-state";
|
||||
import { InstallWizard } from "@/features/installation/components/install-wizard";
|
||||
|
||||
export const metadata = {
|
||||
title: "Set Up Your Business",
|
||||
};
|
||||
// Must never be statically prerendered — the installation-state check on
|
||||
// every request is the entire point of this page (see the brief's "do
|
||||
// not rely on the form being hidden"). Without this, Next could bake a
|
||||
// single build-time snapshot of `installed` into static HTML and serve
|
||||
// that forever, regardless of the database's real state afterward.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function InstallPage() {
|
||||
const { installed } = await getInstallationState();
|
||||
// Direct navigation after a completed install — src/proxy.ts already
|
||||
// redirects here for most cases, but this page enforces the same rule
|
||||
// itself rather than trusting the proxy alone (see the brief's "do not
|
||||
// rely on the form being hidden" requirement).
|
||||
if (installed) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const tokenConfigured = !!process.env.INSTALLATION_TOKEN;
|
||||
|
||||
if (!tokenConfigured) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Installation is not configured</h1>
|
||||
<p className="text-muted text-sm">
|
||||
This deployment has no <code className="rounded bg-white px-1.5 py-0.5 text-xs">INSTALLATION_TOKEN</code>{" "}
|
||||
environment variable set, so setup can't proceed. An administrator needs to set one and restart the
|
||||
application before installation can begin.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <InstallWizard />;
|
||||
}
|
||||
+31
-21
@@ -1,6 +1,7 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -9,28 +10,37 @@ const inter = Inter({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "NextChair",
|
||||
template: "%s — NextChair",
|
||||
},
|
||||
description:
|
||||
"Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "NextChair",
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Dynamic, not a static `export const metadata` — every page's title,
|
||||
* description, and app-name metadata reflect the installed business, not
|
||||
* the internal product name (see src/lib/business/branding.ts). Before
|
||||
* installation completes, `getBranding()` returns neutral wording rather
|
||||
* than "NextChair" (see that file's own fallback-order comment).
|
||||
*/
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const branding = await getBranding();
|
||||
return {
|
||||
title: { default: branding.name, template: `%s | ${branding.name}` },
|
||||
description: branding.description || "Book your next appointment in seconds.",
|
||||
appleWebApp: { capable: true, statusBarStyle: "default", title: branding.shortName },
|
||||
icons:
|
||||
branding.faviconUrl || branding.appIconUrl
|
||||
? { icon: branding.faviconUrl || undefined, apple: branding.appIconUrl || undefined }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#111b15",
|
||||
};
|
||||
export async function generateViewport(): Promise<Viewport> {
|
||||
const branding = await getBranding();
|
||||
return {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: "cover",
|
||||
themeColor: branding.themeColor,
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
|
||||
+20
-10
@@ -1,22 +1,32 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
|
||||
/**
|
||||
* Next.js's manifest file convention — auto-served at /manifest.webmanifest
|
||||
* with the <link rel="manifest"> tag injected automatically, so nothing in
|
||||
* layout.tsx needs to reference this directly. Single business per
|
||||
* deployment (see docs/implementation-plan.md), so the name/colors are
|
||||
* static rather than read from the Business row.
|
||||
* with the <link rel="manifest"> tag injected automatically. Dynamic as of
|
||||
* Phase 8: name/description/colors reflect the installed business (see
|
||||
* src/lib/business/branding.ts) rather than the internal product name.
|
||||
*
|
||||
* `icons` intentionally stays pointed at the app's own pre-generated,
|
||||
* correctly-sized/maskable PWA icon set rather than a business's single
|
||||
* `appIconUrl` — there's no image-processing pipeline in this app to
|
||||
* derive the required 192/512/maskable variants from one arbitrary
|
||||
* uploaded URL (see docs/phase-8-summary.md, "Known limitations"), so
|
||||
* this is fallback rule #2 ("business name + a neutral generated icon"),
|
||||
* applied deliberately rather than risking a broken/non-square install
|
||||
* icon.
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||
const branding = await getBranding();
|
||||
return {
|
||||
name: "NextChair — Barber Booking",
|
||||
short_name: "NextChair",
|
||||
description: "Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
|
||||
name: branding.name,
|
||||
short_name: branding.shortName,
|
||||
description: branding.description || "Book your next appointment in seconds.",
|
||||
start_url: "/home",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#f4f6f4",
|
||||
theme_color: "#111b15",
|
||||
background_color: branding.backgroundColor,
|
||||
theme_color: branding.themeColor,
|
||||
orientation: "portrait-primary",
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { WifiOff } from "lucide-react";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
|
||||
export const metadata: Metadata = { title: "You're offline" };
|
||||
// Forced dynamic so every time the service worker actually (re-)fetches
|
||||
// this page (at install/activate, while online — see this file's own
|
||||
// comment below), it gets a fresh render reflecting current branding.
|
||||
// Without this, a production build could statically bake in whatever
|
||||
// branding existed at *build* time and serve that forever, even after
|
||||
// the business's branding changes.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* The service worker's navigation fallback (see public/sw.js) — served
|
||||
* from cache when a page request fails with no network. Deliberately
|
||||
* static: no auth, no data fetching, nothing that could itself require a
|
||||
* network round-trip, since the whole point is that it still renders with
|
||||
* zero connectivity. Precached at service-worker install time.
|
||||
* from cache when a page request fails with no network. No auth, no
|
||||
* per-request data fetching once it's *serving* offline (the whole point
|
||||
* is that it still renders with zero connectivity) — but it's still a
|
||||
* normal server-rendered page whenever the service worker actually
|
||||
* (re-)precaches it, which only ever happens while online (install/
|
||||
* activate, see public/sw.js). So it reads business branding like any
|
||||
* other page; a device that goes offline simply keeps showing whatever
|
||||
* branding was current the last time it had a connection.
|
||||
*/
|
||||
export default function OfflinePage() {
|
||||
export default async function OfflinePage() {
|
||||
const branding = await getBranding();
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-stone px-6 text-center">
|
||||
<div className="bg-ink mb-6 flex h-16 w-16 items-center justify-center rounded-[1.25rem] text-white shadow-xl">
|
||||
@@ -18,7 +31,7 @@ export default function OfflinePage() {
|
||||
</div>
|
||||
<h1 className="mb-2 text-xl font-bold tracking-tight">You're offline</h1>
|
||||
<p className="text-muted max-w-xs text-sm">
|
||||
NextChair needs a connection for booking, schedules, and account details. Reconnect and try again.
|
||||
{branding.name} needs a connection for booking, schedules, and account details. Reconnect and try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
+47
-10
@@ -3,21 +3,49 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-card";
|
||||
|
||||
/**
|
||||
* The unauthenticated entry point — always shown at `/` before
|
||||
* authentication, per the brief's "Welcome routing" requirements.
|
||||
* Authenticated visitors never see it: they're routed straight to their
|
||||
* role's home (customer → /home, barber → /staff, owner → /owner) below.
|
||||
* Unreachable at all before installation completes — src/proxy.ts
|
||||
* redirects every non-exempt route (including this one) to /install
|
||||
* until ApplicationInstallation flips to INSTALLED.
|
||||
*/
|
||||
export default async function WelcomePage() {
|
||||
const session = await auth();
|
||||
if (session?.user) {
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (staffProfile) {
|
||||
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
redirect(canManageStaff ? "/owner" : "/staff");
|
||||
}
|
||||
redirect("/home");
|
||||
}
|
||||
|
||||
const [branding, business] = await Promise.all([getBranding(), getPrimaryBusiness()]);
|
||||
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
|
||||
|
||||
return (
|
||||
<div id="main-content" className="flex min-h-screen flex-col justify-end bg-ink px-8 pb-16 text-white">
|
||||
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-ink">
|
||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||
{branding.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL; not a build-time-known local asset for next/image.
|
||||
<img src={branding.logoUrl} alt="" className="h-full w-full rounded-2xl object-cover" />
|
||||
) : (
|
||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mb-4 text-5xl font-bold tracking-tight">NextChair</h1>
|
||||
<h1 className="mb-4 text-5xl font-bold tracking-tight">{branding.name}</h1>
|
||||
<p className="mb-12 max-w-xs text-lg text-white/70">
|
||||
Precision grooming at your fingertips. Book your next visit in seconds.
|
||||
Book your next visit in seconds — services, staff and loyalty rewards in one place.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -32,15 +60,24 @@ export default async function WelcomePage() {
|
||||
>
|
||||
<Link href="/sign-in">Sign In</Link>
|
||||
</Button>
|
||||
{guestBookingEnabled && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="w-full text-white/70 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Link href="/book">Continue as Guest</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="ghost" size="lg" className="w-full text-white/70 hover:bg-white/10 hover:text-white">
|
||||
<Link href="/home">Browse Services</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The templates' "Continue as Guest" goes straight to the business
|
||||
home/booking flow (template 20 → 15). That flow isn't built yet
|
||||
(Phase 1: Customer Booking), so this button isn't shown rather than
|
||||
linking somewhere that doesn't work yet — see
|
||||
docs/implementation-plan.md §16 for the phase it belongs to.
|
||||
*/}
|
||||
<div className="mt-6">
|
||||
<WelcomeInstallCard businessName={branding.name} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NotificationBell } from "@/features/notifications/components/notification-bell";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { StaffSidebarNav } from "./staff-sidebar-nav";
|
||||
import type { BottomNavItem } from "./bottom-nav";
|
||||
@@ -10,8 +11,12 @@ import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
||||
* defaults to the staff nav; (owner) passes OWNER_NAV_ITEMS instead (see
|
||||
* staff-nav-items.ts) — same shell, same sidebar/bottom-nav components,
|
||||
* different item set, per the Phase 6 brief's own nav requirement.
|
||||
*
|
||||
* Reads business branding itself (async Server Component) rather than
|
||||
* requiring every (staff)/(owner) layout to fetch and thread it through —
|
||||
* one call site covers both.
|
||||
*/
|
||||
export function StaffShell({
|
||||
export async function StaffShell({
|
||||
children,
|
||||
items = STAFF_NAV_ITEMS,
|
||||
footerLink,
|
||||
@@ -24,9 +29,10 @@ export function StaffShell({
|
||||
notificationsHref?: string;
|
||||
unreadNotificationCount?: number;
|
||||
}) {
|
||||
const branding = await getBranding();
|
||||
return (
|
||||
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
|
||||
<StaffSidebarNav items={items} footerLink={footerLink} />
|
||||
<StaffSidebarNav items={items} footerLink={footerLink} businessName={branding.name} logoUrl={branding.logoUrl} />
|
||||
<NotificationBell href={notificationsHref} unreadCount={unreadNotificationCount} />
|
||||
<div id="main-content">{children}</div>
|
||||
<BottomNav items={items} variant="staff" />
|
||||
|
||||
@@ -18,10 +18,14 @@ import type { BottomNavItem } from "./bottom-nav";
|
||||
export function StaffSidebarNav({
|
||||
items,
|
||||
footerLink,
|
||||
businessName,
|
||||
logoUrl,
|
||||
}: {
|
||||
items: BottomNavItem[];
|
||||
/** Owner sidebar's explicit "back to barber tools" — see the brief's "clear access back to barber tools". Omitted on the staff sidebar itself. */
|
||||
footerLink?: { href: string; label: string };
|
||||
businessName: string;
|
||||
logoUrl: string | null;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -31,8 +35,13 @@ export function StaffSidebarNav({
|
||||
className="fixed inset-y-0 left-0 z-50 hidden w-56 flex-col border-r border-white/5 bg-[#0c0c0c] px-4 py-6 lg:flex"
|
||||
>
|
||||
<div className="mb-8 flex items-center gap-2 px-2 text-white">
|
||||
<Scissors className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-sm font-bold tracking-widest uppercase">NextChair</span>
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL.
|
||||
<img src={logoUrl} alt="" className="h-5 w-5 rounded object-cover" />
|
||||
) : (
|
||||
<Scissors className="h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
<span className="truncate text-sm font-bold tracking-widest uppercase">{businessName}</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
{items.map(({ href, label, icon }) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db/prisma";
|
||||
import { registerSchema } from "@/features/auth/schemas";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { getRequestIp } from "@/lib/security/request-ip";
|
||||
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||
|
||||
export interface RegisterResult {
|
||||
success: boolean;
|
||||
@@ -75,7 +76,8 @@ export async function registerAction(formData: FormData): Promise<RegisterResult
|
||||
},
|
||||
});
|
||||
|
||||
await signIn("credentials", { email, password, redirectTo: "/home" });
|
||||
const redirectTo = safeRedirectPath(formData.get("callbackUrl")?.toString(), "/home");
|
||||
await signIn("credentials", { email, password, redirectTo });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -39,8 +39,8 @@ export function AcceptInvitationForm({ token, businessName }: { token: string; b
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
You've been invited to join <span className="text-ink font-bold">{businessName}</span> on NextChair. Set
|
||||
a password to finish setting up your account.
|
||||
You've been invited to join <span className="text-ink font-bold">{businessName}</span>. Set a password
|
||||
to finish setting up your account.
|
||||
</p>
|
||||
<Input
|
||||
label="Password"
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { registerAction } from "@/features/auth/actions/register";
|
||||
import { registerSchema, type RegisterInput } from "@/features/auth/schemas";
|
||||
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||
|
||||
export function RegisterForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
@@ -25,6 +29,7 @@ export function RegisterForm() {
|
||||
setFormError(null);
|
||||
const formData = new FormData();
|
||||
Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? ""));
|
||||
formData.set("callbackUrl", callbackUrl);
|
||||
|
||||
const result = await registerAction(formData);
|
||||
|
||||
@@ -111,7 +116,7 @@ export function RegisterForm() {
|
||||
|
||||
<p className="text-muted mt-10 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link href="/sign-in" className="border-ink text-ink border-b font-bold">
|
||||
<Link href={`/sign-in?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b font-bold">
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -10,10 +10,11 @@ import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { signInSchema, type SignInInput } from "@/features/auth/schemas";
|
||||
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||
|
||||
export function SignInForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/home";
|
||||
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
@@ -91,7 +92,7 @@ export function SignInForm() {
|
||||
|
||||
<p className="text-muted mt-10 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/register" className="border-ink text-ink border-b-2 font-bold">
|
||||
<Link href={`/register?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b-2 font-bold">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { getRequestIp } from "@/lib/security/request-ip";
|
||||
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
|
||||
import { createBooking } from "@/services/scheduling/booking.service";
|
||||
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||
import { getFirstFriendlyMessage } from "../error-messages";
|
||||
|
||||
export interface ConfirmBookingResult {
|
||||
@@ -44,6 +45,16 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
|
||||
if (!draft.guest) {
|
||||
return { success: false, error: "Please provide your contact details first." };
|
||||
}
|
||||
// Authoritative guest-booking gate — the UI already hides the
|
||||
// guest path when disabled (see /book/details), but this is the
|
||||
// one place that actually creates an Appointment row, so it's
|
||||
// re-checked here regardless of how the request arrived (a direct
|
||||
// Server Action call, a stale draft cookie started before the
|
||||
// owner disabled guest booking mid-flow, etc.).
|
||||
const bookingRule = await prisma.bookingRule.findUnique({ where: { businessId }, select: { allowGuestBooking: true } });
|
||||
if (!bookingRule?.allowGuestBooking) {
|
||||
return { success: false, error: "Guest booking is currently unavailable. Please sign in or create an account to continue." };
|
||||
}
|
||||
// Registered customers already have a rate-limited signin gate on
|
||||
// their account; a guest has none, so this is the one place spam
|
||||
// bookings can otherwise be generated with no account at all.
|
||||
@@ -72,6 +83,11 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
// A no-op when the appointment is PENDING (requires approval) — see
|
||||
// calendar-sync.service.ts's own policy comment. Never blocks or fails
|
||||
// this action either way.
|
||||
await syncAppointmentSafely(prisma, result.appointment.id);
|
||||
|
||||
await writeLastBookingReference({
|
||||
appointmentId: result.appointment.id,
|
||||
guestAccessToken: result.guestAccessToken,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
|
||||
import { getFirstFriendlyMessage } from "../error-messages";
|
||||
import { customerCancelSchema, customerRescheduleSchema } from "../schemas";
|
||||
@@ -50,6 +51,8 @@ export async function cancelCustomerAppointmentAction(input: unknown): Promise<C
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
|
||||
|
||||
// Deliberately not revalidating here (unlike reschedule) — every affected
|
||||
// page is already `dynamic = "force-dynamic"`, so revalidatePath buys no
|
||||
// extra freshness, and doing it immediately would drop the cancelled
|
||||
@@ -87,6 +90,8 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
|
||||
|
||||
revalidatePath("/appointments");
|
||||
revalidatePath(`/appointments/${parsed.data.appointmentId}`);
|
||||
return { success: true };
|
||||
|
||||
@@ -96,6 +96,16 @@ export async function saveDetailsStepAction(input: unknown): Promise<StepActionR
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
// Same authoritative check confirmBookingAction re-runs at the final
|
||||
// commit — rejected here too so a direct call to this action (not
|
||||
// just the confirm step) can't bypass a disabled guest-booking rule.
|
||||
const bookingRule = await prisma.bookingRule.findUnique({
|
||||
where: { businessId: parsed.data.businessId },
|
||||
select: { allowGuestBooking: true },
|
||||
});
|
||||
if (!bookingRule?.allowGuestBooking) {
|
||||
return { success: false, error: "Please sign in or create an account to continue." };
|
||||
}
|
||||
const guestParsed = guestDetailsSchema.safeParse({
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { decryptToken, encryptToken } from "@/lib/security/token-encryption";
|
||||
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
|
||||
import { CalendarProviderError } from "@/services/calendar/types";
|
||||
import type { RemoteCalendarInfo } from "@/services/calendar/types";
|
||||
import { runInitialSync } from "@/services/calendar/calendar-sync.service";
|
||||
|
||||
async function requireStaffId(): Promise<string> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Not signed in.");
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
|
||||
if (!staffProfile) throw new Error("Not a staff member.");
|
||||
return staffProfile.id;
|
||||
}
|
||||
|
||||
export interface CalendarActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function disconnectCalendarAction(): Promise<CalendarActionResult> {
|
||||
const staffId = await requireStaffId();
|
||||
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||
if (!connection) return { success: true };
|
||||
|
||||
// Best-effort revoke, then always clear locally regardless of whether
|
||||
// Google's revoke call succeeded — see the brief's "Revoke credentials
|
||||
// where possible" (not "must succeed before disconnecting").
|
||||
const provider = new GoogleCalendarProvider();
|
||||
await provider.revoke(decryptToken(connection.encryptedRefreshToken)).catch(() => {});
|
||||
|
||||
// Existing AppointmentCalendarEvent rows and their remote events are
|
||||
// deliberately left alone — see the brief's "Leave existing events,
|
||||
// stop managing them." Only the connection itself is removed, so future
|
||||
// appointment changes have nothing left to sync against.
|
||||
await prisma.staffCalendarConnection.update({
|
||||
where: { staffId },
|
||||
data: { revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function syncNowAction(): Promise<CalendarActionResult> {
|
||||
const staffId = await requireStaffId();
|
||||
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||
if (!connection || connection.revokedAt) {
|
||||
return { success: false, error: "Connect Google Calendar first." };
|
||||
}
|
||||
await runInitialSync(prisma, staffId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface ListCalendarsResult {
|
||||
success: boolean;
|
||||
calendars?: RemoteCalendarInfo[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function listAvailableCalendarsAction(): Promise<ListCalendarsResult> {
|
||||
const staffId = await requireStaffId();
|
||||
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||
if (!connection || connection.revokedAt) {
|
||||
return { success: false, error: "Connect Google Calendar first." };
|
||||
}
|
||||
|
||||
const provider = new GoogleCalendarProvider();
|
||||
try {
|
||||
let accessToken = connection.encryptedAccessToken ? decryptToken(connection.encryptedAccessToken) : null;
|
||||
const expired = !connection.tokenExpiresAt || connection.tokenExpiresAt.getTime() - Date.now() < 60_000;
|
||||
if (!accessToken || expired) {
|
||||
const refreshed = await provider.refreshAccessToken(decryptToken(connection.encryptedRefreshToken));
|
||||
accessToken = refreshed.accessToken;
|
||||
await prisma.staffCalendarConnection.update({
|
||||
where: { staffId },
|
||||
data: { encryptedAccessToken: encryptToken(refreshed.accessToken), tokenExpiresAt: refreshed.expiresAt },
|
||||
});
|
||||
}
|
||||
const calendars = await provider.listWritableCalendars(accessToken);
|
||||
return { success: true, calendars };
|
||||
} catch (error) {
|
||||
const message = error instanceof CalendarProviderError ? error.message : "Couldn't load calendars.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectCalendarAction(calendarId: string, calendarName: string): Promise<CalendarActionResult> {
|
||||
const staffId = await requireStaffId();
|
||||
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||
if (!connection || connection.revokedAt) {
|
||||
return { success: false, error: "Connect Google Calendar first." };
|
||||
}
|
||||
await prisma.staffCalendarConnection.update({
|
||||
where: { staffId },
|
||||
data: { selectedCalendarId: calendarId, selectedCalendarName: calendarName },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
disconnectCalendarAction,
|
||||
listAvailableCalendarsAction,
|
||||
selectCalendarAction,
|
||||
syncNowAction,
|
||||
} from "../actions/calendar";
|
||||
import type { RemoteCalendarInfo } from "@/services/calendar/types";
|
||||
|
||||
export interface ConnectionState {
|
||||
connected: boolean;
|
||||
accountEmail?: string;
|
||||
selectedCalendarName?: string;
|
||||
lastSyncAt?: string;
|
||||
lastSyncError?: string | null;
|
||||
}
|
||||
|
||||
export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) {
|
||||
const [state, setState] = useState(initial);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [calendars, setCalendars] = useState<RemoteCalendarInfo[] | null>(null);
|
||||
|
||||
const sync = () => {
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
const result = await syncNowAction();
|
||||
setMessage(result.success ? "Sync complete." : (result.error ?? "Sync failed."));
|
||||
});
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
await disconnectCalendarAction();
|
||||
setState({ connected: false });
|
||||
setMessage("Disconnected. Existing calendar events were left as they are.");
|
||||
});
|
||||
};
|
||||
|
||||
const loadCalendars = () => {
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
const result = await listAvailableCalendarsAction();
|
||||
if (!result.success) {
|
||||
setMessage(result.error ?? "Couldn't load calendars.");
|
||||
return;
|
||||
}
|
||||
setCalendars(result.calendars ?? []);
|
||||
});
|
||||
};
|
||||
|
||||
const chooseCalendar = (calendar: RemoteCalendarInfo) => {
|
||||
startTransition(async () => {
|
||||
const result = await selectCalendarAction(calendar.id, calendar.name);
|
||||
if (result.success) {
|
||||
setState((prev) => ({ ...prev, selectedCalendarName: calendar.name }));
|
||||
setCalendars(null);
|
||||
setMessage(`Now syncing to "${calendar.name}".`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!state.connected) {
|
||||
return (
|
||||
<div className="border-line rounded-jumbo border bg-white p-5">
|
||||
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
||||
<p className="text-muted mb-4 text-xs">
|
||||
Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately
|
||||
from sign-in — you can use a different one if you like.
|
||||
</p>
|
||||
{message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>}
|
||||
{configured ? (
|
||||
<Button asChild size="sm">
|
||||
<a href="/api/staff/calendar/google/authorize">Connect Google Calendar</a>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-muted text-xs">Google Calendar sync is not configured on this deployment yet.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-line rounded-jumbo border bg-white p-5">
|
||||
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
||||
<div className="text-muted mb-4 space-y-1 text-xs">
|
||||
<p>Connected as {state.accountEmail}</p>
|
||||
<p>Syncing to “{state.selectedCalendarName}”</p>
|
||||
{state.lastSyncAt && <p>Last synced {new Date(state.lastSyncAt).toLocaleString()}</p>}
|
||||
{state.lastSyncError && <p className="text-danger">Last sync error: {state.lastSyncError}</p>}
|
||||
</div>
|
||||
|
||||
{message && <p className="text-muted mb-3 text-xs font-medium">{message}</p>}
|
||||
|
||||
{calendars ? (
|
||||
<ul className="mb-4 space-y-2">
|
||||
{calendars.map((calendar) => (
|
||||
<li key={calendar.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chooseCalendar(calendar)}
|
||||
className="border-line w-full rounded-lg border px-3 py-2 text-left text-xs font-bold hover:bg-stone"
|
||||
>
|
||||
{calendar.name} {calendar.primary && "(primary)"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" isLoading={isPending} onClick={sync}>
|
||||
Sync Now
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" isLoading={isPending} onClick={loadCalendars}>
|
||||
Change Calendar
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" isLoading={isPending} onClick={disconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { InstallationStatus } from "@/generated/prisma/enums";
|
||||
import { installApplication } from "../installer.service";
|
||||
import type { InstallInput } from "../schema";
|
||||
|
||||
/**
|
||||
* Exercises the real transactional installer against a real MySQL
|
||||
* database — see installer.service.ts's own header for why the
|
||||
* ApplicationInstallation singleton row is the concurrency lock. These
|
||||
* tests own that row exclusively: any pre-existing installation state in
|
||||
* this database is removed in `beforeEach` and never restored, since a
|
||||
* test database is expected to be disposable (see
|
||||
* docs/phase-8-summary.md's testing section) — do not point this at a
|
||||
* real deployment's database.
|
||||
*/
|
||||
|
||||
const INSTALLATION_TOKEN = "integration-test-install-token";
|
||||
|
||||
function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
|
||||
return {
|
||||
installationToken: INSTALLATION_TOKEN,
|
||||
administrator: {
|
||||
firstName: "Test",
|
||||
lastName: "Owner",
|
||||
email: `install-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`,
|
||||
phone: "",
|
||||
password: "correct-horse-battery",
|
||||
confirmPassword: "correct-horse-battery",
|
||||
},
|
||||
business: {
|
||||
name: "Integration Test Barbershop",
|
||||
shortName: "",
|
||||
description: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
website: "",
|
||||
},
|
||||
location: {
|
||||
addressLine1: "1 Test Street",
|
||||
addressLine2: "",
|
||||
city: "Testville",
|
||||
region: "",
|
||||
postalCode: "T1 1TT",
|
||||
country: "GB",
|
||||
timezone: "Europe/London",
|
||||
currency: "GBP",
|
||||
locale: "en-GB",
|
||||
},
|
||||
hours: (["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const).map((dayOfWeek) => ({
|
||||
dayOfWeek,
|
||||
isClosed: dayOfWeek === "SUNDAY",
|
||||
opensAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 540,
|
||||
closesAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 1080,
|
||||
})),
|
||||
bookingRules: {
|
||||
allowGuestBooking: false,
|
||||
minNoticeMinutes: 120,
|
||||
maxAdvanceDays: 60,
|
||||
cancellationWindowHours: 24,
|
||||
rescheduleWindowHours: 24,
|
||||
slotIntervalMinutes: 30,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 5,
|
||||
maxAppointmentsPerDay: null,
|
||||
maxAppointmentsPerStaffPerDay: null,
|
||||
customerCanChooseStaff: true,
|
||||
requireApproval: false,
|
||||
},
|
||||
branding: { logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" },
|
||||
starterServices: [
|
||||
{ categoryName: "Haircuts", name: "Classic Cut", description: "", price: "20.00", durationMinutes: 30, active: true, allowOnlineBooking: true },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function resetInstallationSingleton(): Promise<void> {
|
||||
await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } });
|
||||
}
|
||||
|
||||
async function cleanupInstalledBusiness(businessId: string | undefined): Promise<void> {
|
||||
if (!businessId) return;
|
||||
const staff = await prisma.staffProfile.findMany({ where: { businessId }, select: { id: true, userId: true } });
|
||||
const userIds = staff.map((s) => s.userId);
|
||||
await prisma.staffService.deleteMany({ where: { staffId: { in: staff.map((s) => s.id) } } });
|
||||
await prisma.staffProfile.deleteMany({ where: { businessId } });
|
||||
await prisma.service.deleteMany({ where: { businessId } });
|
||||
await prisma.serviceCategory.deleteMany({ where: { businessId } });
|
||||
await prisma.rolePermission.deleteMany({ where: { role: { businessId } } });
|
||||
await prisma.role.deleteMany({ where: { businessId } });
|
||||
await prisma.auditLog.deleteMany({ where: { entityId: businessId } });
|
||||
await prisma.loyaltyProgramme.deleteMany({ where: { businessId } });
|
||||
await prisma.bookingRule.deleteMany({ where: { businessId } });
|
||||
await prisma.businessSettings.deleteMany({ where: { businessId } });
|
||||
await prisma.businessHours.deleteMany({ where: { businessId } });
|
||||
await prisma.businessLocation.deleteMany({ where: { businessId } });
|
||||
await prisma.business.deleteMany({ where: { id: businessId } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await resetInstallationSingleton();
|
||||
}
|
||||
|
||||
describe("installApplication (integration)", () => {
|
||||
let createdBusinessId: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetInstallationSingleton();
|
||||
createdBusinessId = undefined;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupInstalledBusiness(createdBusinessId);
|
||||
});
|
||||
|
||||
it("rejects an incorrect installation token without creating anything", async () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const result = await installApplication(prisma, {
|
||||
input: buildInput({ installationToken: "wrong-token" }),
|
||||
installedVersion: "test",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.code).toBe("INVALID_TOKEN");
|
||||
|
||||
const business = await prisma.business.findFirst({ where: { name: "Integration Test Barbershop" } });
|
||||
expect(business).toBeNull();
|
||||
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
|
||||
expect(installation).toBeNull();
|
||||
} finally {
|
||||
process.env.INSTALLATION_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the business, administrator, permissions, booking rules, and starter services transactionally", async () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const result = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
createdBusinessId = result.businessId;
|
||||
|
||||
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
|
||||
expect(installation?.status).toBe(InstallationStatus.INSTALLED);
|
||||
expect(installation?.primaryBusinessId).toBe(result.businessId);
|
||||
expect(installation?.initialAdminUserId).toBe(result.userId);
|
||||
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: result.businessId },
|
||||
include: { bookingRule: true, locations: true, hours: true, loyaltyProgramme: true },
|
||||
});
|
||||
expect(business?.name).toBe("Integration Test Barbershop");
|
||||
// Guest booking off by default, per the brief — confirmed end to end here.
|
||||
expect(business?.bookingRule?.allowGuestBooking).toBe(false);
|
||||
expect(business?.locations).toHaveLength(1);
|
||||
expect(business?.hours).toHaveLength(7);
|
||||
expect(business?.loyaltyProgramme?.isEnabled).toBe(false);
|
||||
|
||||
const roles = await prisma.role.findMany({ where: { businessId: result.businessId }, include: { permissions: true } });
|
||||
const ownerRole = roles.find((r) => r.name === "Owner");
|
||||
const barberRole = roles.find((r) => r.name === "Barber");
|
||||
expect(ownerRole).toBeDefined();
|
||||
expect(barberRole).toBeDefined();
|
||||
expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length);
|
||||
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } });
|
||||
expect(staffProfile?.roleId).toBe(ownerRole!.id);
|
||||
// The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible."
|
||||
expect(staffProfile?.services).toHaveLength(1);
|
||||
|
||||
const services = await prisma.service.findMany({ where: { businessId: result.businessId } });
|
||||
expect(services).toHaveLength(1);
|
||||
expect(services[0]?.name).toBe("Classic Cut");
|
||||
|
||||
const auditLog = await prisma.auditLog.findFirst({ where: { entityId: result.businessId, action: "APPLICATION_INSTALLED" } });
|
||||
expect(auditLog).toBeTruthy();
|
||||
} finally {
|
||||
process.env.INSTALLATION_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to install a second time once already installed", async () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const first = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
||||
expect(first.success).toBe(true);
|
||||
if (first.success) createdBusinessId = first.businessId;
|
||||
|
||||
const second = await installApplication(prisma, {
|
||||
input: buildInput({
|
||||
administrator: { ...buildInput().administrator, email: `second-${Date.now()}@example.com` },
|
||||
}),
|
||||
installedVersion: "test",
|
||||
});
|
||||
expect(second.success).toBe(false);
|
||||
if (!second.success) expect(second.code).toBe("ALREADY_INSTALLED");
|
||||
} finally {
|
||||
process.env.INSTALLATION_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("prevents two concurrent installation attempts from both succeeding", async () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const inputA = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-a-${Date.now()}@example.com` } });
|
||||
const inputB = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-b-${Date.now()}@example.com` } });
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
installApplication(prisma, { input: inputA, installedVersion: "test" }),
|
||||
installApplication(prisma, { input: inputB, installedVersion: "test" }),
|
||||
]);
|
||||
|
||||
const successes = [resultA, resultB].filter((r) => r.success);
|
||||
expect(successes).toHaveLength(1);
|
||||
const winner = successes[0];
|
||||
if (winner?.success) createdBusinessId = winner.businessId;
|
||||
|
||||
const businessCount = await prisma.business.count({
|
||||
where: { name: "Integration Test Barbershop" },
|
||||
});
|
||||
expect(businessCount).toBe(1);
|
||||
} finally {
|
||||
process.env.INSTALLATION_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getRequestIp } from "@/lib/security/request-ip";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
import { installApplication } from "../installer.service";
|
||||
import { installSchema } from "../schema";
|
||||
import type { InstallInput } from "../schema";
|
||||
|
||||
const packageVersion = process.env.npm_package_version ?? "1.0.0";
|
||||
|
||||
export interface VerifyTokenResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cheap, read-only check the wizard's first step calls for immediate
|
||||
* feedback — never reveals *why* a token is wrong, and never on its own
|
||||
* unlocks anything server-side: the real gate is `submitInstallationAction`
|
||||
* re-checking the same token before it writes anything (see
|
||||
* installer.service.ts's own header for why it's checked twice).
|
||||
*/
|
||||
export async function verifyInstallationTokenAction(token: string): Promise<VerifyTokenResult> {
|
||||
const ip = await getRequestIp();
|
||||
const { allowed } = checkRateLimit(`install-token:${ip}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 });
|
||||
if (!allowed) {
|
||||
return { valid: false, error: "Too many attempts. Please try again later." };
|
||||
}
|
||||
|
||||
const expected = process.env.INSTALLATION_TOKEN;
|
||||
if (!expected) {
|
||||
return { valid: false, error: "Installation is not available on this deployment." };
|
||||
}
|
||||
if (!token || !secretsMatch(token, expected)) {
|
||||
return { valid: false, error: "Incorrect installation token." };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export interface SubmitInstallationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
export async function submitInstallationAction(input: InstallInput): Promise<SubmitInstallationResult> {
|
||||
const ip = await getRequestIp();
|
||||
const { allowed } = checkRateLimit(`install-submit:${ip}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 });
|
||||
if (!allowed) {
|
||||
return { success: false, error: "Too many attempts. Please try again later." };
|
||||
}
|
||||
|
||||
const parsed = installSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const result = await installApplication(prisma, { input: parsed.data, installedVersion: packageVersion });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.message };
|
||||
}
|
||||
|
||||
await signIn("credentials", {
|
||||
email: parsed.data.administrator.email,
|
||||
password: parsed.data.administrator.password,
|
||||
redirectTo: "/owner?installed=1",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { submitInstallationAction, verifyInstallationTokenAction } from "../actions/install";
|
||||
import type { InstallInput } from "../schema";
|
||||
|
||||
/**
|
||||
* Holds every wizard step's data as plain React state, not a signed
|
||||
* server-side draft — nothing is validated or written to the database
|
||||
* until the single final `submitInstallationAction` call on Step 9's
|
||||
* confirmation (see installer.service.ts's own header). That means a
|
||||
* page refresh loses progress, a deliberate simplification: this page is
|
||||
* only ever visited once, by the person physically standing up the
|
||||
* deployment, so "refresh loses your place" is a fair trade for not
|
||||
* needing a signed-cookie draft model and its own cleanup story.
|
||||
*/
|
||||
|
||||
const WEEKDAYS = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const;
|
||||
type WeekdayCode = (typeof WEEKDAYS)[number];
|
||||
const DAY_LABEL: Record<WeekdayCode, string> = {
|
||||
MONDAY: "Monday",
|
||||
TUESDAY: "Tuesday",
|
||||
WEDNESDAY: "Wednesday",
|
||||
THURSDAY: "Thursday",
|
||||
FRIDAY: "Friday",
|
||||
SATURDAY: "Saturday",
|
||||
SUNDAY: "Sunday",
|
||||
};
|
||||
|
||||
interface HoursDayState {
|
||||
dayOfWeek: WeekdayCode;
|
||||
isClosed: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
interface StarterServiceState {
|
||||
key: string;
|
||||
categoryName: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
durationMinutes: number;
|
||||
active: boolean;
|
||||
allowOnlineBooking: boolean;
|
||||
}
|
||||
|
||||
function toMinutes(time: string): number {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
"Welcome",
|
||||
"Administrator",
|
||||
"Business Details",
|
||||
"Location & Contact",
|
||||
"Opening Hours",
|
||||
"Booking Settings",
|
||||
"Branding",
|
||||
"Starter Services",
|
||||
"Review",
|
||||
] as const;
|
||||
|
||||
let starterServiceKeySeq = 0;
|
||||
function newStarterServiceKey(): string {
|
||||
starterServiceKeySeq += 1;
|
||||
return `starter-${starterServiceKeySeq}`;
|
||||
}
|
||||
|
||||
const DEFAULT_STARTER_SERVICES: StarterServiceState[] = [
|
||||
{
|
||||
key: newStarterServiceKey(),
|
||||
categoryName: "Haircuts",
|
||||
name: "Classic Haircut",
|
||||
description: "",
|
||||
price: "25.00",
|
||||
durationMinutes: 30,
|
||||
active: true,
|
||||
allowOnlineBooking: true,
|
||||
},
|
||||
{
|
||||
key: newStarterServiceKey(),
|
||||
categoryName: "Beard Services",
|
||||
name: "Beard Trim",
|
||||
description: "",
|
||||
price: "15.00",
|
||||
durationMinutes: 20,
|
||||
active: true,
|
||||
allowOnlineBooking: true,
|
||||
},
|
||||
{
|
||||
key: newStarterServiceKey(),
|
||||
categoryName: "Packages",
|
||||
name: "Haircut & Beard Combo",
|
||||
description: "",
|
||||
price: "35.00",
|
||||
durationMinutes: 45,
|
||||
active: true,
|
||||
allowOnlineBooking: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function InstallWizard() {
|
||||
const [step, setStep] = useState(1);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// Step 1
|
||||
const [token, setToken] = useState("");
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
|
||||
// Step 2
|
||||
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" });
|
||||
|
||||
// Step 3
|
||||
const [business, setBusiness] = useState({ name: "", shortName: "", description: "", email: "", phone: "", website: "" });
|
||||
|
||||
// Step 4
|
||||
const [location, setLocation] = useState({
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
region: "",
|
||||
postalCode: "",
|
||||
country: "GB",
|
||||
timezone: "Europe/London",
|
||||
currency: "GBP",
|
||||
locale: "en-GB",
|
||||
});
|
||||
|
||||
// Step 5
|
||||
const [hours, setHours] = useState<HoursDayState[]>(
|
||||
WEEKDAYS.map((dayOfWeek) => ({
|
||||
dayOfWeek,
|
||||
isClosed: dayOfWeek === "SUNDAY",
|
||||
startTime: "09:00",
|
||||
endTime: "18:00",
|
||||
})),
|
||||
);
|
||||
|
||||
// Step 6 — guest booking defaults OFF, per the brief.
|
||||
const [bookingRules, setBookingRules] = useState({
|
||||
allowGuestBooking: false,
|
||||
minNoticeMinutes: 120,
|
||||
maxAdvanceDays: 60,
|
||||
cancellationWindowHours: 24,
|
||||
rescheduleWindowHours: 24,
|
||||
slotIntervalMinutes: 30,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 5,
|
||||
maxAppointmentsPerDay: null as number | null,
|
||||
maxAppointmentsPerStaffPerDay: null as number | null,
|
||||
customerCanChooseStaff: true,
|
||||
requireApproval: false,
|
||||
});
|
||||
|
||||
// Step 7
|
||||
const [branding, setBranding] = useState({ logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" });
|
||||
|
||||
// Step 8
|
||||
const [starterServices, setStarterServices] = useState<StarterServiceState[]>(DEFAULT_STARTER_SERVICES);
|
||||
|
||||
const verifyToken = () => {
|
||||
setTokenError(null);
|
||||
startTransition(async () => {
|
||||
const result = await verifyInstallationTokenAction(token);
|
||||
if (!result.valid) {
|
||||
setTokenError(result.error ?? "Incorrect installation token.");
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
});
|
||||
};
|
||||
|
||||
const updateDay = (dayOfWeek: WeekdayCode, patch: Partial<HoursDayState>) => {
|
||||
setHours((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
|
||||
};
|
||||
const copyMondayToWeekdays = () => {
|
||||
const monday = hours.find((d) => d.dayOfWeek === "MONDAY");
|
||||
if (!monday) return;
|
||||
setHours((prev) =>
|
||||
prev.map((d) =>
|
||||
d.dayOfWeek === "SATURDAY" || d.dayOfWeek === "SUNDAY" || d.dayOfWeek === "MONDAY"
|
||||
? d
|
||||
: { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime },
|
||||
),
|
||||
);
|
||||
};
|
||||
const copyMondayToAll = () => {
|
||||
const monday = hours.find((d) => d.dayOfWeek === "MONDAY");
|
||||
if (!monday) return;
|
||||
setHours((prev) =>
|
||||
prev.map((d) => (d.dayOfWeek === "MONDAY" ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })),
|
||||
);
|
||||
};
|
||||
|
||||
const updateStarter = (key: string, patch: Partial<StarterServiceState>) => {
|
||||
setStarterServices((prev) => prev.map((s) => (s.key === key ? { ...s, ...patch } : s)));
|
||||
};
|
||||
const removeStarter = (key: string) => {
|
||||
setStarterServices((prev) => prev.filter((s) => s.key !== key));
|
||||
};
|
||||
const addStarter = () => {
|
||||
setStarterServices((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: newStarterServiceKey(),
|
||||
categoryName: "Haircuts",
|
||||
name: "",
|
||||
description: "",
|
||||
price: "0.00",
|
||||
durationMinutes: 30,
|
||||
active: true,
|
||||
allowOnlineBooking: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setSubmitError(null);
|
||||
const input: InstallInput = {
|
||||
installationToken: token,
|
||||
administrator: admin,
|
||||
business,
|
||||
location,
|
||||
hours: hours.map((d) => ({
|
||||
dayOfWeek: d.dayOfWeek,
|
||||
isClosed: d.isClosed,
|
||||
...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }),
|
||||
})),
|
||||
bookingRules,
|
||||
branding,
|
||||
starterServices: starterServices
|
||||
.filter((s) => s.name.trim())
|
||||
.map((s) => ({
|
||||
categoryName: s.categoryName,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
price: s.price,
|
||||
durationMinutes: s.durationMinutes,
|
||||
active: s.active,
|
||||
allowOnlineBooking: s.allowOnlineBooking,
|
||||
})),
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await submitInstallationAction(input);
|
||||
if (!result.success) {
|
||||
setSubmitError(result.error ?? "Installation failed. Please try again.");
|
||||
}
|
||||
// On success, submitInstallationAction signs the administrator in
|
||||
// and redirects server-side — nothing else to do here.
|
||||
});
|
||||
};
|
||||
|
||||
const canProceedFromStep = (s: number): boolean => {
|
||||
switch (s) {
|
||||
case 2:
|
||||
return (
|
||||
!!admin.firstName.trim() &&
|
||||
!!admin.lastName.trim() &&
|
||||
!!admin.email.trim() &&
|
||||
admin.password.length >= 8 &&
|
||||
admin.password === admin.confirmPassword
|
||||
);
|
||||
case 3:
|
||||
return !!business.name.trim();
|
||||
case 4:
|
||||
return (
|
||||
!!location.addressLine1.trim() &&
|
||||
!!location.city.trim() &&
|
||||
!!location.postalCode.trim() &&
|
||||
!!location.timezone.trim() &&
|
||||
location.currency.trim().length === 3
|
||||
);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
Step {step} of {STEPS.length} — {STEPS[step - 1]}
|
||||
</p>
|
||||
<div className="bg-line mt-2 h-1.5 w-full overflow-hidden rounded-full">
|
||||
<div className="bg-ink h-full rounded-full transition-all" style={{ width: `${(step / STEPS.length) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Set up your business</h1>
|
||||
<p className="text-muted text-sm">
|
||||
This wizard creates the first administrator account and configures your business — opening hours,
|
||||
booking rules, and branding. It only needs to run once.
|
||||
</p>
|
||||
<Input
|
||||
label="Installation Token"
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
error={tokenError ?? undefined}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button className="w-full" isLoading={isPending} disabled={!token} onClick={verifyToken}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Administrator account</h2>
|
||||
<p className="text-muted text-sm">This becomes the first owner — full permissions, protected from removal.</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
|
||||
<Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
|
||||
</div>
|
||||
<Input label="Email Address" type="email" value={admin.email} onChange={(e) => setAdmin({ ...admin, email: e.target.value })} required />
|
||||
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={admin.password}
|
||||
onChange={(e) => setAdmin({ ...admin, password: e.target.value })}
|
||||
hint="At least 8 characters."
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={admin.confirmPassword}
|
||||
onChange={(e) => setAdmin({ ...admin, confirmPassword: e.target.value })}
|
||||
error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined}
|
||||
required
|
||||
/>
|
||||
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Business details</h2>
|
||||
<Input label="Business Name" value={business.name} onChange={(e) => setBusiness({ ...business, name: e.target.value })} required />
|
||||
<Input
|
||||
label="Short Name (optional)"
|
||||
hint="Used where space is tight, e.g. the home-screen icon label."
|
||||
value={business.shortName}
|
||||
onChange={(e) => setBusiness({ ...business, shortName: e.target.value })}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description (optional)"
|
||||
value={business.description}
|
||||
onChange={(e) => setBusiness({ ...business, description: e.target.value })}
|
||||
/>
|
||||
<Input label="Business Email (optional)" type="email" value={business.email} onChange={(e) => setBusiness({ ...business, email: e.target.value })} />
|
||||
<Input label="Phone Number (optional)" type="tel" value={business.phone} onChange={(e) => setBusiness({ ...business, phone: e.target.value })} />
|
||||
<Input label="Website (optional)" value={business.website} onChange={(e) => setBusiness({ ...business, website: e.target.value })} />
|
||||
<StepNav onBack={() => setStep(2)} onNext={() => setStep(4)} nextDisabled={!canProceedFromStep(3)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Location & contact</h2>
|
||||
<Input label="Address Line 1" value={location.addressLine1} onChange={(e) => setLocation({ ...location, addressLine1: e.target.value })} required />
|
||||
<Input label="Address Line 2 (optional)" value={location.addressLine2} onChange={(e) => setLocation({ ...location, addressLine2: e.target.value })} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Town / City" value={location.city} onChange={(e) => setLocation({ ...location, city: e.target.value })} required />
|
||||
<Input label="County (optional)" value={location.region} onChange={(e) => setLocation({ ...location, region: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Postcode" value={location.postalCode} onChange={(e) => setLocation({ ...location, postalCode: e.target.value })} required />
|
||||
<Input label="Country" value={location.country} onChange={(e) => setLocation({ ...location, country: e.target.value })} required />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Input label="Timezone" value={location.timezone} onChange={(e) => setLocation({ ...location, timezone: e.target.value })} required />
|
||||
<Input label="Currency" value={location.currency} onChange={(e) => setLocation({ ...location, currency: e.target.value.toUpperCase() })} maxLength={3} required />
|
||||
<Input label="Locale" value={location.locale} onChange={(e) => setLocation({ ...location, locale: e.target.value })} required />
|
||||
</div>
|
||||
<StepNav onBack={() => setStep(3)} onNext={() => setStep(5)} nextDisabled={!canProceedFromStep(4)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Opening hours</h2>
|
||||
<div className="flex gap-4">
|
||||
<button type="button" onClick={copyMondayToWeekdays} className="text-accent text-xs font-bold tracking-widest uppercase underline">
|
||||
Copy Monday to weekdays
|
||||
</button>
|
||||
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
|
||||
Copy Monday to all days
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
||||
{hours.map((day) => (
|
||||
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={!day.isClosed} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isClosed: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} open`} />
|
||||
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
|
||||
</div>
|
||||
{day.isClosed ? (
|
||||
<span className="text-muted text-xs">Closed</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="time" value={day.startTime} onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })} className="border-line rounded-lg border px-2 py-1.5 text-xs" aria-label={`${DAY_LABEL[day.dayOfWeek]} opening time`} />
|
||||
<span className="text-muted text-xs">to</span>
|
||||
<input type="time" value={day.endTime} onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })} className="border-line rounded-lg border px-2 py-1.5 text-xs" aria-label={`${DAY_LABEL[day.dayOfWeek]} closing time`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<StepNav onBack={() => setStep(4)} onNext={() => setStep(6)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Booking settings</h2>
|
||||
<div className="rounded-jumbo border-line border bg-white p-5">
|
||||
<Switch
|
||||
checked={bookingRules.allowGuestBooking}
|
||||
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, allowGuestBooking: checked })}
|
||||
label="Allow customers to book as guests"
|
||||
description="When enabled, customers can book without creating an account. When disabled, customers must sign in or create an account before confirming their booking. Off is recommended — you can turn this on later in Booking Settings."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Minimum Notice (minutes)"
|
||||
type="number"
|
||||
value={bookingRules.minNoticeMinutes}
|
||||
onChange={(e) => setBookingRules({ ...bookingRules, minNoticeMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="Max Advance Booking (days)"
|
||||
type="number"
|
||||
value={bookingRules.maxAdvanceDays}
|
||||
onChange={(e) => setBookingRules({ ...bookingRules, maxAdvanceDays: Number(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="Cancellation Window (hours)"
|
||||
type="number"
|
||||
value={bookingRules.cancellationWindowHours}
|
||||
onChange={(e) => setBookingRules({ ...bookingRules, cancellationWindowHours: Number(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="Reschedule Window (hours)"
|
||||
type="number"
|
||||
value={bookingRules.rescheduleWindowHours}
|
||||
onChange={(e) => setBookingRules({ ...bookingRules, rescheduleWindowHours: Number(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="Slot Interval (minutes)"
|
||||
type="number"
|
||||
value={bookingRules.slotIntervalMinutes}
|
||||
onChange={(e) => setBookingRules({ ...bookingRules, slotIntervalMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-jumbo border-line space-y-3 border bg-white p-5">
|
||||
<Switch
|
||||
checked={bookingRules.customerCanChooseStaff}
|
||||
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, customerCanChooseStaff: checked })}
|
||||
label="Customers can choose their barber"
|
||||
/>
|
||||
<Switch
|
||||
checked={bookingRules.requireApproval}
|
||||
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, requireApproval: checked })}
|
||||
label="New bookings require staff approval"
|
||||
/>
|
||||
</div>
|
||||
<StepNav onBack={() => setStep(5)} onNext={() => setStep(7)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 7 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Branding</h2>
|
||||
<p className="text-muted text-sm">
|
||||
Optional — every screen in the app uses your business name and these images/colours instead of the
|
||||
product name. You can add or change all of this later in Business Settings.
|
||||
</p>
|
||||
<Input label="Logo URL (optional)" placeholder="https://…" value={branding.logoUrl} onChange={(e) => setBranding({ ...branding, logoUrl: e.target.value })} />
|
||||
<Input label="Cover Image URL (optional)" placeholder="https://…" value={branding.coverImageUrl} onChange={(e) => setBranding({ ...branding, coverImageUrl: e.target.value })} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Theme Colour (optional)" placeholder="#111b15" value={branding.themeColor} onChange={(e) => setBranding({ ...branding, themeColor: e.target.value })} />
|
||||
<Input label="Background Colour (optional)" placeholder="#f4f6f4" value={branding.backgroundColor} onChange={(e) => setBranding({ ...branding, backgroundColor: e.target.value })} />
|
||||
</div>
|
||||
<StepNav onBack={() => setStep(6)} onNext={() => setStep(8)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 8 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Starter services</h2>
|
||||
<p className="text-muted text-sm">
|
||||
A small editable starting point so your booking page isn't empty on day one. Edit, remove, or add
|
||||
your own before finishing setup.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{starterServices.map((s) => (
|
||||
<div key={s.key} className="rounded-jumbo border-line space-y-3 border bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Service</p>
|
||||
<button type="button" onClick={() => removeStarter(s.key)} className="text-danger text-xs font-bold uppercase">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Category" value={s.categoryName} onChange={(e) => updateStarter(s.key, { categoryName: e.target.value })} />
|
||||
<Input label="Service Name" value={s.name} onChange={(e) => updateStarter(s.key, { name: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Price" value={s.price} onChange={(e) => updateStarter(s.key, { price: e.target.value })} />
|
||||
<Input label="Duration (minutes)" type="number" value={s.durationMinutes} onChange={(e) => updateStarter(s.key, { durationMinutes: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="secondary" className="w-full" onClick={addStarter}>
|
||||
Add Another Service
|
||||
</Button>
|
||||
<StepNav onBack={() => setStep(7)} onNext={() => setStep(9)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 9 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Review</h2>
|
||||
<ReviewSection title="Administrator">
|
||||
{admin.firstName} {admin.lastName} · {admin.email}
|
||||
</ReviewSection>
|
||||
<ReviewSection title="Business">{business.name}</ReviewSection>
|
||||
<ReviewSection title="Location">
|
||||
{location.addressLine1}, {location.city}, {location.postalCode} · {location.timezone} · {location.currency}
|
||||
</ReviewSection>
|
||||
<ReviewSection title="Opening Hours">
|
||||
{hours.filter((d) => !d.isClosed).length} day{hours.filter((d) => !d.isClosed).length === 1 ? "" : "s"} open
|
||||
</ReviewSection>
|
||||
<ReviewSection title="Booking Settings">
|
||||
Guest booking {bookingRules.allowGuestBooking ? "enabled" : "disabled"} · {bookingRules.requireApproval ? "requires approval" : "auto-confirms"}
|
||||
</ReviewSection>
|
||||
<ReviewSection title="Branding">{branding.logoUrl ? "Logo set" : "No logo yet — add one later in Business Settings"}</ReviewSection>
|
||||
<ReviewSection title="Starter Services">
|
||||
{starterServices.filter((s) => s.name.trim()).length} service{starterServices.filter((s) => s.name.trim()).length === 1 ? "" : "s"}
|
||||
</ReviewSection>
|
||||
|
||||
{submitError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setStep(8)} disabled={isPending}>
|
||||
Back
|
||||
</Button>
|
||||
<Button className="flex-1" isLoading={isPending} onClick={submit}>
|
||||
Confirm & Install
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepNav({ onBack, onNext, nextDisabled }: { onBack: () => void; onNext: () => void; nextDisabled?: boolean }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={onNext} disabled={nextDisabled}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-jumbo border-line border bg-white p-4">
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">{title}</p>
|
||||
<p className="mt-1 text-sm">{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { PrismaClient, Prisma } from "@/generated/prisma/client";
|
||||
import { InstallationStatus, RewardType } from "@/generated/prisma/enums";
|
||||
import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
|
||||
import { OWNER_PERMISSIONS, BARBER_PERMISSIONS } from "@/lib/roles/default-permissions";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
import { validateBusinessProfileInput, validateOpeningHoursInput } from "@/services/business/business.service";
|
||||
import { validateBookingRuleInput } from "@/services/scheduling/booking-rule.service";
|
||||
import { validateCategoryInput, validateServiceInput } from "@/services/catalogue/service-catalogue.service";
|
||||
import { fail, ok } from "@/lib/validation-result";
|
||||
import type { ValidationResult } from "@/lib/validation-result";
|
||||
import type { InstallInput } from "./schema";
|
||||
|
||||
export type InstallErrorCode =
|
||||
| "INVALID_TOKEN"
|
||||
| "ALREADY_INSTALLED"
|
||||
| "VALIDATION_FAILED"
|
||||
| "EMAIL_ALREADY_IN_USE"
|
||||
| "INSTALL_FAILED";
|
||||
|
||||
export interface InstallSuccess {
|
||||
success: true;
|
||||
userId: string;
|
||||
businessId: string;
|
||||
}
|
||||
|
||||
export interface InstallFailure {
|
||||
success: false;
|
||||
code: InstallErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type InstallResult = InstallSuccess | InstallFailure;
|
||||
|
||||
function slugify(name: string): string {
|
||||
const base = name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
// Appended so a second install attempt with the same business name (a
|
||||
// retried/failed install, or genuinely two identically-named
|
||||
// businesses in dev/test) never collides on Business.slug's unique
|
||||
// constraint.
|
||||
return `${base || "business"}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function validateAllInput(input: InstallInput): ValidationResult<InstallErrorCode> {
|
||||
const businessValidation = validateBusinessProfileInput({
|
||||
name: input.business.name,
|
||||
description: input.business.description || undefined,
|
||||
phone: input.business.phone || undefined,
|
||||
email: input.business.email || undefined,
|
||||
website: input.business.website || undefined,
|
||||
timezone: input.location.timezone,
|
||||
currency: input.location.currency,
|
||||
locale: input.location.locale,
|
||||
});
|
||||
if (!businessValidation.valid) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: businessValidation.issues[0]!.message });
|
||||
}
|
||||
|
||||
if (!input.location.addressLine1.trim() || !input.location.city.trim() || !input.location.postalCode.trim()) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: "Address line 1, city, and postcode are required." });
|
||||
}
|
||||
|
||||
const hoursValidation = validateOpeningHoursInput(input.hours);
|
||||
if (!hoursValidation.valid) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: hoursValidation.issues[0]!.message });
|
||||
}
|
||||
|
||||
const bookingRuleValidation = validateBookingRuleInput(input.bookingRules);
|
||||
if (!bookingRuleValidation.valid) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: bookingRuleValidation.issues[0]!.message });
|
||||
}
|
||||
|
||||
for (const service of input.starterServices) {
|
||||
const categoryValidation = validateCategoryInput({ name: service.categoryName });
|
||||
if (!categoryValidation.valid) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: categoryValidation.issues[0]!.message });
|
||||
}
|
||||
const serviceValidation = validateServiceInput({
|
||||
categoryId: "placeholder", // category is created inside the transaction, before we know its id
|
||||
name: service.name,
|
||||
description: service.description || undefined,
|
||||
durationMinutes: service.durationMinutes,
|
||||
price: service.price,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 0,
|
||||
allowOnlineBooking: service.allowOnlineBooking,
|
||||
});
|
||||
if (!serviceValidation.valid) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: serviceValidation.issues[0]!.message });
|
||||
}
|
||||
}
|
||||
|
||||
return ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a fresh deployment goes from "empty database" to "a real,
|
||||
* usable business" — see docs/phase-8-summary.md for the full narrative.
|
||||
* Deliberately separate from prisma/seed.ts (dev/demo fixtures only, never
|
||||
* run against a real deployment) and from every existing owner-facing
|
||||
* service (BusinessService, LoyaltyService, etc., which all assume a
|
||||
* business already exists) — this is the one codepath that creates one.
|
||||
*
|
||||
* Token verification happens twice by design: once cheaply in the
|
||||
* `verifyInstallationTokenAction` Server Action (so the wizard's first
|
||||
* step can give immediate feedback), and again here, as the very first
|
||||
* thing this function does, before any database write — a client that
|
||||
* skips straight to final submission without ever calling the first
|
||||
* check must still be rejected. Neither check ever reveals *why* a token
|
||||
* was wrong.
|
||||
*/
|
||||
export async function installApplication(
|
||||
prisma: PrismaClient,
|
||||
params: { input: InstallInput; installedVersion: string },
|
||||
): Promise<InstallResult> {
|
||||
const expectedToken = process.env.INSTALLATION_TOKEN;
|
||||
if (!expectedToken) {
|
||||
return { success: false, code: "INVALID_TOKEN", message: "Installation is not available." };
|
||||
}
|
||||
if (!secretsMatch(params.input.installationToken, expectedToken)) {
|
||||
return { success: false, code: "INVALID_TOKEN", message: "Installation is not available." };
|
||||
}
|
||||
|
||||
const validation = validateAllInput(params.input);
|
||||
if (!validation.valid) {
|
||||
return { success: false, code: "VALIDATION_FAILED", message: validation.issues[0]!.message };
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: params.input.administrator.email.toLowerCase() } });
|
||||
if (existingUser) {
|
||||
return { success: false, code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// First statement, and the concurrency lock — see
|
||||
// ApplicationInstallation's own header in prisma/schema.prisma. A
|
||||
// concurrent second install attempt blocks on this same
|
||||
// primary-key insert and then fails with a unique-constraint
|
||||
// violation once this transaction commits (or proceeds cleanly if
|
||||
// this transaction instead rolls back on a later failure).
|
||||
await tx.applicationInstallation.create({
|
||||
data: { id: INSTALLATION_SINGLETON_ID, status: InstallationStatus.INSTALLING },
|
||||
});
|
||||
|
||||
const { administrator, business: businessInput, location, hours, bookingRules, branding, starterServices } =
|
||||
params.input;
|
||||
|
||||
const business = await tx.business.create({
|
||||
data: {
|
||||
slug: slugify(businessInput.name),
|
||||
name: businessInput.name.trim(),
|
||||
shortName: businessInput.shortName?.trim() || null,
|
||||
description: businessInput.description?.trim() || null,
|
||||
logoUrl: branding.logoUrl?.trim() || null,
|
||||
coverImageUrl: branding.coverImageUrl?.trim() || null,
|
||||
themeColor: branding.themeColor?.trim() || null,
|
||||
backgroundColor: branding.backgroundColor?.trim() || null,
|
||||
phone: businessInput.phone?.trim() || null,
|
||||
email: businessInput.email?.trim() || null,
|
||||
website: businessInput.website?.trim() || null,
|
||||
timezone: location.timezone,
|
||||
currency: location.currency.toUpperCase(),
|
||||
locale: location.locale,
|
||||
locations: {
|
||||
create: {
|
||||
addressLine1: location.addressLine1.trim(),
|
||||
addressLine2: location.addressLine2?.trim() || null,
|
||||
city: location.city.trim(),
|
||||
region: location.region?.trim() || null,
|
||||
postalCode: location.postalCode.trim(),
|
||||
country: location.country.trim(),
|
||||
isPrimary: true,
|
||||
},
|
||||
},
|
||||
settings: { create: {} },
|
||||
hours: {
|
||||
create: hours.map((day) => ({
|
||||
dayOfWeek: day.dayOfWeek,
|
||||
isClosed: day.isClosed,
|
||||
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
|
||||
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
|
||||
})),
|
||||
},
|
||||
bookingRule: { create: { ...bookingRules } },
|
||||
// Disabled by default — the brief is explicit that installation
|
||||
// must not silently turn loyalty on; the owner opts in later via
|
||||
// /owner/settings/loyalty, same as every other post-install
|
||||
// configuration choice.
|
||||
loyaltyProgramme: {
|
||||
create: { isEnabled: false, stampsRequired: 10, rewardType: RewardType.PERCENTAGE },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [ownerRole] = await Promise.all([
|
||||
tx.role.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
name: "Owner",
|
||||
isSystem: true,
|
||||
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
|
||||
},
|
||||
}),
|
||||
tx.role.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
name: "Barber",
|
||||
isSystem: true,
|
||||
permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const passwordHash = await bcrypt.hash(administrator.password, 12);
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email: administrator.email.toLowerCase(),
|
||||
name: `${administrator.firstName.trim()} ${administrator.lastName.trim()}`,
|
||||
phone: administrator.phone?.trim() || null,
|
||||
passwordHash,
|
||||
emailVerified: new Date(),
|
||||
staffProfile: {
|
||||
create: {
|
||||
businessId: business.id,
|
||||
roleId: ownerRole.id,
|
||||
isBookableOnline: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { staffProfile: true },
|
||||
});
|
||||
const staffId = user.staffProfile!.id;
|
||||
|
||||
// Starter services: one ServiceCategory per distinct categoryName,
|
||||
// created in submission order, then a Service row per entry —
|
||||
// never silently invented data, only what the installer explicitly
|
||||
// reviewed and approved in Step 8 (see install-wizard.tsx).
|
||||
const categoryIdByName = new Map<string, string>();
|
||||
for (const starter of starterServices) {
|
||||
if (!categoryIdByName.has(starter.categoryName)) {
|
||||
const category = await tx.serviceCategory.create({
|
||||
data: { businessId: business.id, name: starter.categoryName },
|
||||
});
|
||||
categoryIdByName.set(starter.categoryName, category.id);
|
||||
}
|
||||
}
|
||||
const createdServiceIds: string[] = [];
|
||||
for (const starter of starterServices) {
|
||||
const categoryId = categoryIdByName.get(starter.categoryName)!;
|
||||
const service = await tx.service.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
categoryId,
|
||||
name: starter.name.trim(),
|
||||
description: starter.description?.trim() || null,
|
||||
durationMinutes: starter.durationMinutes,
|
||||
price: starter.price,
|
||||
isActive: starter.active,
|
||||
allowOnlineBooking: starter.allowOnlineBooking,
|
||||
},
|
||||
});
|
||||
createdServiceIds.push(service.id);
|
||||
}
|
||||
if (createdServiceIds.length > 0) {
|
||||
await tx.staffService.createMany({
|
||||
data: createdServiceIds.map((serviceId) => ({ staffId, serviceId })),
|
||||
});
|
||||
}
|
||||
|
||||
await tx.applicationInstallation.update({
|
||||
where: { id: INSTALLATION_SINGLETON_ID },
|
||||
data: {
|
||||
status: InstallationStatus.INSTALLED,
|
||||
installedAt: new Date(),
|
||||
installedVersion: params.installedVersion,
|
||||
initialAdminUserId: user.id,
|
||||
primaryBusinessId: business.id,
|
||||
metadata: { starterServiceCount: createdServiceIds.length } satisfies Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: user.id,
|
||||
action: "APPLICATION_INSTALLED",
|
||||
entityType: "Business",
|
||||
entityId: business.id,
|
||||
metadata: { businessName: business.name } satisfies Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { userId: user.id, businessId: business.id };
|
||||
});
|
||||
|
||||
return { success: true, userId: result.userId, businessId: result.businessId };
|
||||
} catch (error) {
|
||||
// A concurrent installer won the create() race — see the header
|
||||
// comment above. Anything else is an unexpected failure; either way,
|
||||
// the transaction rolled back and left no partial data (see
|
||||
// ApplicationInstallation's own header for why that's true even for
|
||||
// the lock row itself).
|
||||
const isDuplicateSingleton =
|
||||
typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "P2002";
|
||||
if (isDuplicateSingleton) {
|
||||
return { success: false, code: "ALREADY_INSTALLED", message: "This application has already been installed." };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code: "INSTALL_FAILED",
|
||||
message: "Installation failed. No changes were saved — you can safely try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Single source of truth for the installation wizard's shape — shared
|
||||
* between the client wizard component and the final Server Action (see
|
||||
* actions/install.ts). Deliberately one flat schema, not one per wizard
|
||||
* step: the wizard itself is client-side-only state (see
|
||||
* install-wizard.tsx's own header for why) and nothing is validated or
|
||||
* written server-side until the single final submission, so there is only
|
||||
* ever one real validation boundary to define.
|
||||
*/
|
||||
|
||||
export const installAdministratorSchema = z
|
||||
.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
email: z.email("Enter a valid email address"),
|
||||
phone: z.string().trim().optional().or(z.literal("")),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export const installBusinessSchema = z.object({
|
||||
name: z.string().trim().min(1, "Business name is required"),
|
||||
shortName: z.string().trim().max(40).optional().or(z.literal("")),
|
||||
description: z.string().trim().optional().or(z.literal("")),
|
||||
email: z.email("Enter a valid email address").optional().or(z.literal("")),
|
||||
phone: z.string().trim().optional().or(z.literal("")),
|
||||
website: z.string().trim().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
export const installLocationSchema = z.object({
|
||||
addressLine1: z.string().trim().min(1, "Address line 1 is required"),
|
||||
addressLine2: z.string().trim().optional().or(z.literal("")),
|
||||
city: z.string().trim().min(1, "Town or city is required"),
|
||||
region: z.string().trim().optional().or(z.literal("")),
|
||||
postalCode: z.string().trim().min(1, "Postcode is required"),
|
||||
country: z.string().trim().min(1, "Country is required"),
|
||||
timezone: z.string().trim().min(1, "Timezone is required"),
|
||||
currency: z.string().trim().length(3, "Currency should be a 3-letter code, e.g. GBP"),
|
||||
locale: z.string().trim().min(1, "Locale is required"),
|
||||
});
|
||||
|
||||
const weekdaySchema = z.enum(["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]);
|
||||
|
||||
export const installHoursDaySchema = z.object({
|
||||
dayOfWeek: weekdaySchema,
|
||||
isClosed: z.boolean(),
|
||||
opensAtMinutes: z.number().int().min(0).max(1439).optional(),
|
||||
closesAtMinutes: z.number().int().min(0).max(1439).optional(),
|
||||
});
|
||||
|
||||
export const installBookingRulesSchema = z.object({
|
||||
allowGuestBooking: z.boolean(),
|
||||
minNoticeMinutes: z.number().int().min(0),
|
||||
maxAdvanceDays: z.number().int().min(1),
|
||||
cancellationWindowHours: z.number().int().min(0),
|
||||
rescheduleWindowHours: z.number().int().min(0),
|
||||
slotIntervalMinutes: z.number().int().min(5).max(120),
|
||||
bufferBeforeMinutes: z.number().int().min(0),
|
||||
bufferAfterMinutes: z.number().int().min(0),
|
||||
maxAppointmentsPerDay: z.number().int().min(1).nullable(),
|
||||
maxAppointmentsPerStaffPerDay: z.number().int().min(1).nullable(),
|
||||
customerCanChooseStaff: z.boolean(),
|
||||
requireApproval: z.boolean(),
|
||||
});
|
||||
|
||||
export const installBrandingSchema = z.object({
|
||||
logoUrl: z.string().trim().optional().or(z.literal("")),
|
||||
coverImageUrl: z.string().trim().optional().or(z.literal("")),
|
||||
themeColor: z.string().trim().optional().or(z.literal("")),
|
||||
backgroundColor: z.string().trim().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
const decimalPrice = z.string().regex(/^\d+(\.\d{1,2})?$/, "Enter a price like 22.50");
|
||||
|
||||
export const installStarterServiceSchema = z.object({
|
||||
categoryName: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().trim().optional().or(z.literal("")),
|
||||
price: decimalPrice,
|
||||
durationMinutes: z.number().int().min(5).max(480),
|
||||
active: z.boolean(),
|
||||
allowOnlineBooking: z.boolean(),
|
||||
});
|
||||
|
||||
export const installSchema = z.object({
|
||||
installationToken: z.string().min(1, "Installation token is required"),
|
||||
administrator: installAdministratorSchema,
|
||||
business: installBusinessSchema,
|
||||
location: installLocationSchema,
|
||||
hours: z.array(installHoursDaySchema).length(7),
|
||||
bookingRules: installBookingRulesSchema,
|
||||
branding: installBrandingSchema,
|
||||
starterServices: z.array(installStarterServiceSchema).max(20),
|
||||
});
|
||||
|
||||
export type InstallAdministratorInput = z.infer<typeof installAdministratorSchema>;
|
||||
export type InstallBusinessInput = z.infer<typeof installBusinessSchema>;
|
||||
export type InstallLocationInput = z.infer<typeof installLocationSchema>;
|
||||
export type InstallHoursDayInput = z.infer<typeof installHoursDaySchema>;
|
||||
export type InstallBookingRulesInput = z.infer<typeof installBookingRulesSchema>;
|
||||
export type InstallBrandingInput = z.infer<typeof installBrandingSchema>;
|
||||
export type InstallStarterServiceInput = z.infer<typeof installStarterServiceSchema>;
|
||||
export type InstallInput = z.infer<typeof installSchema>;
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/services/scheduling/appointment.service";
|
||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingBarberChanged, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
||||
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
|
||||
|
||||
@@ -79,6 +80,7 @@ export async function ownerConfirmAppointmentAction(appointmentId: string): Prom
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingApproved(prisma, result.appointment);
|
||||
await syncAppointmentSafely(prisma, appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -125,6 +127,7 @@ export async function ownerMarkNoShowAction(appointmentId: string): Promise<Staf
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyAppointmentNoShow(prisma, result.appointment);
|
||||
await syncAppointmentSafely(prisma, appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -140,9 +143,11 @@ export async function ownerCancelAppointmentAction(params: {
|
||||
cancelledByUserId: owner.userId,
|
||||
reason: params.reason,
|
||||
});
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
|
||||
}
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface OwnerRescheduleResult {
|
||||
@@ -181,5 +186,6 @@ export async function ownerRescheduleAppointmentAction(params: {
|
||||
await notifyBookingBarberChanged(prisma, result.appointment);
|
||||
}
|
||||
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -136,8 +136,8 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
||||
onCheckedChange={(v) => set("customerCanChooseStaff", v)}
|
||||
/>
|
||||
<Switch
|
||||
label="Allow guest bookings"
|
||||
description="Let customers book without an account."
|
||||
label="Allow customers to book as guests"
|
||||
description="When enabled, customers can book without creating an account. When disabled, customers must sign in or create an account before confirming their booking."
|
||||
checked={form.allowGuestBooking}
|
||||
onCheckedChange={(v) => set("allowGuestBooking", v)}
|
||||
/>
|
||||
|
||||
@@ -1,79 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// iOS Safari's own (non-standard, but the only signal it exposes).
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function isIos(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
}
|
||||
import { useInstallPrompt } from "../hooks/use-install-prompt";
|
||||
|
||||
/**
|
||||
* Chrome/Edge/Android fire `beforeinstallprompt` when the manifest +
|
||||
* service worker criteria are met — we capture and defer it (the spec
|
||||
* requires preventDefault() to control exactly when the native prompt
|
||||
* shows) and trigger it from this button instead of an unsolicited popup.
|
||||
* iOS Safari never fires that event at all, so it gets manual
|
||||
* "Add to Home Screen" instructions instead — there's no programmatic
|
||||
* install API on that platform.
|
||||
* service worker criteria are met — captured and triggered from this
|
||||
* button instead of an unsolicited popup (see use-install-prompt.ts,
|
||||
* shared with WelcomeInstallCard so there's exactly one PWA install
|
||||
* implementation, not two). iOS Safari never fires that event at all, so
|
||||
* it gets manual "Add to Home Screen" instructions instead. `businessName`
|
||||
* comes from the caller — every Settings page already fetches branding
|
||||
* server-side, so this client component doesn't need to.
|
||||
*/
|
||||
export function InstallAppButton() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installed, setInstalled] = useState(() => isStandalone());
|
||||
export function InstallAppButton({ businessName }: { businessName: string }) {
|
||||
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
|
||||
const [showIosInstructions, setShowIosInstructions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onBeforeInstallPrompt = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
||||
};
|
||||
const onInstalled = () => {
|
||||
setInstalled(true);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", onInstalled);
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", onInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (installed) {
|
||||
return <p className="text-muted text-sm">NextChair is installed on this device.</p>;
|
||||
return <p className="text-muted text-sm">{businessName} is installed on this device.</p>;
|
||||
}
|
||||
|
||||
if (deferredPrompt) {
|
||||
if (canPromptNatively) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
setDeferredPrompt(null);
|
||||
}}
|
||||
>
|
||||
<Button size="sm" onClick={promptInstall}>
|
||||
<Download className="h-4 w-4" aria-hidden="true" /> Install App
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isIos()) {
|
||||
if (ios) {
|
||||
return showIosInstructions ? (
|
||||
<p className="text-muted text-sm">
|
||||
Tap the Share icon in Safari, then choose “Add to Home Screen”.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useInstallPrompt } from "../hooks/use-install-prompt";
|
||||
|
||||
const DISMISSED_UNTIL_KEY = "nextchair:install-card-dismissed-until";
|
||||
/** Re-prompt interval after "Not Now" — see the brief's "reasonable re-prompt interval such as 14 or 30 days." 30 days chosen: this card is one of several places to install (Settings always has it too), so a longer, less-naggy interval is appropriate here specifically. */
|
||||
const DISMISS_DAYS = 30;
|
||||
|
||||
function isDismissed(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
const raw = window.localStorage.getItem(DISMISSED_UNTIL_KEY);
|
||||
if (!raw) return false;
|
||||
const until = Number(raw);
|
||||
return Number.isFinite(until) && Date.now() < until;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prominent, inline Welcome-page counterpart to the Settings-page
|
||||
* `InstallAppButton` — same underlying detection (see
|
||||
* use-install-prompt.ts), never a second installer. Renders nothing when
|
||||
* already installed, on a non-iOS/non-installable browser, or recently
|
||||
* dismissed — it never blocks Sign In / Create Account / Continue as
|
||||
* Guest / Browse Services above it, and never opens as an unsolicited
|
||||
* modal (see the brief's explicit "do not automatically open a modal").
|
||||
*/
|
||||
export function WelcomeInstallCard({ businessName }: { businessName: string }) {
|
||||
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
|
||||
const [dismissed, setDismissed] = useState(true);
|
||||
const [showIosSteps, setShowIosSteps] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.resolve().then(() => setDismissed(isDismissed()));
|
||||
}, []);
|
||||
|
||||
const dismiss = () => {
|
||||
window.localStorage.setItem(DISMISSED_UNTIL_KEY, String(Date.now() + DISMISS_DAYS * 24 * 60 * 60 * 1000));
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
if (installed || dismissed || (!canPromptNatively && !ios)) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-jumbo mb-6 border border-white/10 bg-white/5 p-5 text-white">
|
||||
<p className="text-sm font-bold">Add {businessName} to your Home Screen</p>
|
||||
<p className="mt-1 text-sm text-white/70">Book appointments, manage your visits and receive reminders more easily.</p>
|
||||
|
||||
{ios && showIosSteps ? (
|
||||
<div className="mt-4 space-y-2 text-sm text-white/80">
|
||||
<p className="font-bold text-white">Install {businessName}</p>
|
||||
<ol className="list-inside list-decimal space-y-1">
|
||||
<li>Tap the Share button in Safari.</li>
|
||||
<li>Scroll down and choose “Add to Home Screen”.</li>
|
||||
<li>Tap Add.</li>
|
||||
</ol>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="bg-white"
|
||||
onClick={ios ? () => setShowIosSteps(true) : promptInstall}
|
||||
>
|
||||
Add to Home Screen
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-white/70 hover:bg-white/10 hover:text-white" onClick={dismiss}>
|
||||
Not Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// iOS Safari's own (non-standard, but the only signal it exposes).
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function isIos(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `beforeinstallprompt` capture + standalone/iOS detection — the
|
||||
* one PWA install implementation both `InstallAppButton` (Settings, every
|
||||
* role) and `WelcomeInstallCard` (the unauthenticated Welcome page) use.
|
||||
* See the brief's explicit "do not create a competing second installer".
|
||||
* All detection lives in `useEffect`, never a `useState` lazy
|
||||
* initializer — reading `window`/`navigator` during the initializer would
|
||||
* run during SSR too and mismatch the client's first render (the same
|
||||
* hydration bug this app's push-subscribe-toggle.tsx fixed in Phase 7).
|
||||
*/
|
||||
export function useInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installed, setInstalled] = useState(false);
|
||||
const [ios, setIos] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Async chain, not a direct setState call in the effect body — same
|
||||
// pattern push-subscribe-toggle.tsx uses (see that file's own
|
||||
// comment) to satisfy react-hooks/set-state-in-effect while still
|
||||
// reading browser-only APIs that can't run during SSR.
|
||||
void Promise.resolve().then(() => {
|
||||
setInstalled(isStandalone());
|
||||
setIos(isIos());
|
||||
});
|
||||
|
||||
const onBeforeInstallPrompt = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
||||
};
|
||||
const onInstalled = () => {
|
||||
setInstalled(true);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", onInstalled);
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", onInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const promptInstall = async (): Promise<void> => {
|
||||
if (!deferredPrompt) return;
|
||||
await deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
return { installed, ios, canPromptNatively: !!deferredPrompt, promptInstall };
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/services/scheduling/appointment.service";
|
||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
||||
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
@@ -63,6 +64,9 @@ export async function confirmAppointmentAction(
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingApproved(prisma, result.appointment);
|
||||
// Approval is the first point a PENDING appointment becomes syncable —
|
||||
// see calendar-sync.service.ts's own policy comment.
|
||||
await syncAppointmentSafely(prisma, appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -123,6 +127,7 @@ export async function markNoShowAction(
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyAppointmentNoShow(prisma, result.appointment);
|
||||
await syncAppointmentSafely(prisma, appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -138,12 +143,11 @@ export async function cancelStaffAppointmentAction(params: {
|
||||
cancelledByUserId: staff.user.id,
|
||||
reason: params.reason,
|
||||
});
|
||||
return result.success
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment.",
|
||||
};
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
|
||||
}
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function rescheduleStaffAppointmentAction(params: {
|
||||
@@ -159,12 +163,15 @@ export async function rescheduleStaffAppointmentAction(params: {
|
||||
staffId: params.staffId,
|
||||
startAt: new Date(params.startAt),
|
||||
});
|
||||
return result.success
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment.",
|
||||
};
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment." };
|
||||
}
|
||||
// Covers both a plain reschedule and a barber reassignment (staffId
|
||||
// change) — syncAppointmentSafely itself detects and cancels a stale
|
||||
// event under the previous staff member's connection when the staffId
|
||||
// changed (see that function's own comment).
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function friendlyTransitionMessage(code?: string): string {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
/**
|
||||
* The one place every customer/staff/owner-visible surface gets the
|
||||
* business's white-label identity from — see docs/phase-8-summary.md.
|
||||
* "NextChair" is the internal product name only (repo, Docker project,
|
||||
* package name, technical docs/logs); it must never be the fallback a
|
||||
* real person sees. Fallback order, per the brief:
|
||||
* 1. Configured business name + logo
|
||||
* 2. Configured business name + the app's own neutral icon (handled
|
||||
* naturally: leaving `logoUrl`/`appIconUrl`/`faviconUrl` null falls
|
||||
* through to the static /icon.png / /apple-icon.png files Next
|
||||
* already serves via its file-convention icons — no separate
|
||||
* "neutral icon" asset needed)
|
||||
* 3. Neutral wording with no visible product name at all — only
|
||||
* reachable before installation completes, since every other route
|
||||
* is gated behind a completed install (see
|
||||
* src/lib/installation/installation-state.ts).
|
||||
*
|
||||
* Deliberately a separate, lighter query from getPrimaryBusiness() (which
|
||||
* `findFirstOrThrow`s and eager-loads locations/hours/settings/
|
||||
* bookingRule) — this is called from `generateMetadata` and the root
|
||||
* layout on *every* request, including pre-install ones where no
|
||||
* Business row exists yet, so it must never throw.
|
||||
*/
|
||||
|
||||
export interface BrandingInfo {
|
||||
configured: boolean;
|
||||
name: string;
|
||||
shortName: string;
|
||||
description: string | null;
|
||||
logoUrl: string | null;
|
||||
coverImageUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
appIconUrl: string | null;
|
||||
themeColor: string;
|
||||
backgroundColor: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME_COLOR = "#111b15";
|
||||
const DEFAULT_BACKGROUND_COLOR = "#f4f6f4";
|
||||
|
||||
/** Neutral wording only — never the internal product name. */
|
||||
const UNCONFIGURED_BRANDING: BrandingInfo = {
|
||||
configured: false,
|
||||
name: "Book an Appointment",
|
||||
shortName: "Booking",
|
||||
description: null,
|
||||
logoUrl: null,
|
||||
coverImageUrl: null,
|
||||
faviconUrl: null,
|
||||
appIconUrl: null,
|
||||
themeColor: DEFAULT_THEME_COLOR,
|
||||
backgroundColor: DEFAULT_BACKGROUND_COLOR,
|
||||
email: null,
|
||||
phone: null,
|
||||
};
|
||||
|
||||
export const getBranding = cache(async (): Promise<BrandingInfo> => {
|
||||
// Reached from the root layout's generateMetadata, which Next may
|
||||
// invoke while statically prerendering a route at *build* time (e.g.
|
||||
// /_not-found) — no real database is reachable then (see the
|
||||
// Dockerfile's placeholder build-time DATABASE_URL). A transient/
|
||||
// unreachable database is treated exactly like "not installed yet":
|
||||
// neutral fallback wording, never a crashed build or a 500 at runtime.
|
||||
let business;
|
||||
try {
|
||||
business = await prisma.business.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
name: true,
|
||||
shortName: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
coverImageUrl: true,
|
||||
faviconUrl: true,
|
||||
appIconUrl: true,
|
||||
themeColor: true,
|
||||
backgroundColor: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return UNCONFIGURED_BRANDING;
|
||||
}
|
||||
if (!business) return UNCONFIGURED_BRANDING;
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
name: business.name,
|
||||
shortName: business.shortName?.trim() || business.name,
|
||||
description: business.description,
|
||||
logoUrl: business.logoUrl,
|
||||
coverImageUrl: business.coverImageUrl,
|
||||
faviconUrl: business.faviconUrl,
|
||||
appIconUrl: business.appIconUrl,
|
||||
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
|
||||
backgroundColor: business.backgroundColor || DEFAULT_BACKGROUND_COLOR,
|
||||
email: business.email,
|
||||
phone: business.phone,
|
||||
};
|
||||
});
|
||||
|
||||
/** "Book an Appointment | Business Name" — the shared page-title format used in generateMetadata across the app. */
|
||||
export function brandedTitle(pageTitle: string, branding: BrandingInfo): string {
|
||||
return `${pageTitle} | ${branding.name}`;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function generateAppointmentIcs(input: AppointmentIcsInput): string {
|
||||
const lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//NextChair//Booking//EN",
|
||||
"PRODID:-//Booking Calendar//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
"BEGIN:VEVENT",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { InstallationStatus } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* ApplicationInstallation always uses this fixed id — see the model's own
|
||||
* header in prisma/schema.prisma for why a fixed id (not a lookup by "any
|
||||
* row") is the concurrency-safety mechanism, not just a convention.
|
||||
*/
|
||||
export const INSTALLATION_SINGLETON_ID = "singleton";
|
||||
|
||||
export interface InstallationState {
|
||||
installed: boolean;
|
||||
status: InstallationStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Component-scoped read (React `cache()`-deduped per request) — use
|
||||
* this from pages/layouts. `proxy.ts` has its own process-lifetime-cached
|
||||
* check instead (see isInstalledForRequest below): a Server Component
|
||||
* dedupes per-request, but the proxy runs on every single request and a
|
||||
* fresh DB round trip each time would be real, avoidable cost once the app
|
||||
* is (permanently, for the life of the process) installed.
|
||||
*/
|
||||
export const getInstallationState = cache(async (): Promise<InstallationState> => {
|
||||
const row = await prisma.applicationInstallation.findUnique({
|
||||
where: { id: INSTALLATION_SINGLETON_ID },
|
||||
select: { status: true },
|
||||
});
|
||||
const status = row?.status ?? InstallationStatus.NOT_INSTALLED;
|
||||
return { installed: status === InstallationStatus.INSTALLED, status };
|
||||
});
|
||||
|
||||
/**
|
||||
* Module-level (not request-level) cache for `proxy.ts`. Installation is a
|
||||
* one-way, one-time transition for the life of a deployment — once this
|
||||
* process has observed `INSTALLED`, it can never legitimately become
|
||||
* un-installed again (there is no "reset" action, per the brief's own
|
||||
* "no unauthenticated reset button" requirement), so re-querying the
|
||||
* database on every single request forever after is pure waste. Before
|
||||
* install, every request still does the real check, which is exactly the
|
||||
* window where correctness (redirecting to /install) actually matters.
|
||||
*/
|
||||
let processObservedInstalled = false;
|
||||
|
||||
export async function isInstalledForRequest(): Promise<boolean> {
|
||||
if (processObservedInstalled) return true;
|
||||
const row = await prisma.applicationInstallation.findUnique({
|
||||
where: { id: INSTALLATION_SINGLETON_ID },
|
||||
select: { status: true },
|
||||
});
|
||||
const installed = row?.status === InstallationStatus.INSTALLED;
|
||||
if (installed) processObservedInstalled = true;
|
||||
return installed;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* The two system roles' permission sets — single source of truth, used by
|
||||
* both `prisma/seed.ts` (dev/demo fixtures) and the installation wizard
|
||||
* (`src/features/installation/installer.service.ts`, the real "first
|
||||
* owner" creation path). Previously duplicated only in seed.ts; extracted
|
||||
* here in Phase 8 so a permission added to one path can't silently miss
|
||||
* the other (see docs/phase-8-summary.md).
|
||||
*/
|
||||
export const OWNER_PERMISSIONS: Permission[] = [
|
||||
Permission.MANAGE_BUSINESS_PROFILE,
|
||||
Permission.MANAGE_BOOKING_RULES,
|
||||
Permission.MANAGE_SERVICES,
|
||||
Permission.MANAGE_STAFF,
|
||||
Permission.MANAGE_LOYALTY_PROGRAMME,
|
||||
Permission.MANAGE_NOTIFICATION_SETTINGS,
|
||||
Permission.VIEW_ALL_STAFF_APPOINTMENTS,
|
||||
Permission.MANAGE_ALL_APPOINTMENTS,
|
||||
Permission.MANAGE_OWN_APPOINTMENTS,
|
||||
Permission.CHECK_IN_CUSTOMERS,
|
||||
Permission.MANAGE_LOYALTY_STAMPS,
|
||||
Permission.MANAGE_OWN_AVAILABILITY,
|
||||
Permission.VIEW_CUSTOMER_DIRECTORY,
|
||||
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
|
||||
Permission.VIEW_REPORTING,
|
||||
];
|
||||
|
||||
export const BARBER_PERMISSIONS: Permission[] = [
|
||||
Permission.MANAGE_OWN_APPOINTMENTS,
|
||||
Permission.CHECK_IN_CUSTOMERS,
|
||||
Permission.MANAGE_LOYALTY_STAMPS,
|
||||
Permission.MANAGE_OWN_AVAILABILITY,
|
||||
Permission.VIEW_CUSTOMER_DIRECTORY,
|
||||
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
|
||||
];
|
||||
|
||||
/**
|
||||
* `role.upsert`'s `update: {}` deliberately does nothing on an existing
|
||||
* role, so a plain nested `permissions: { create: [...] }` only fires on
|
||||
* the `create` branch — this syncs RolePermission rows to exactly match
|
||||
* the target list every time, safe to call repeatedly. See prisma/seed.ts
|
||||
* for the original bug this fixed.
|
||||
*/
|
||||
export async function syncRolePermissions(
|
||||
prisma: Pick<PrismaClient, "rolePermission">,
|
||||
roleId: string,
|
||||
permissions: Permission[],
|
||||
): Promise<void> {
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId, permission: { notIn: permissions } } });
|
||||
await prisma.rolePermission.createMany({
|
||||
data: permissions.map((permission) => ({ roleId, permission })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createOAuthState, verifyOAuthState } from "../oauth-state";
|
||||
|
||||
describe("oauth-state", () => {
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_SECRET = "test-auth-secret-not-for-production";
|
||||
});
|
||||
|
||||
it("verifies a state token for the staff member it was issued to", () => {
|
||||
const state = createOAuthState("staff-123");
|
||||
expect(verifyOAuthState(state, "staff-123")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a state token issued to a different staff member", () => {
|
||||
const state = createOAuthState("staff-123");
|
||||
expect(verifyOAuthState(state, "staff-999")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a tampered token", () => {
|
||||
const state = createOAuthState("staff-123");
|
||||
const tampered = `${state.slice(0, -2)}xx`;
|
||||
expect(verifyOAuthState(tampered, "staff-123")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed token", () => {
|
||||
expect(verifyOAuthState("not-a-real-token", "staff-123")).toBe(false);
|
||||
expect(verifyOAuthState("", "staff-123")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an expired token", () => {
|
||||
vi.useFakeTimers();
|
||||
const state = createOAuthState("staff-123");
|
||||
vi.advanceTimersByTime(11 * 60 * 1000); // past the 10-minute max age
|
||||
expect(verifyOAuthState(state, "staff-123")).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { decryptToken, encryptToken } from "../token-encryption";
|
||||
|
||||
describe("token-encryption", () => {
|
||||
beforeEach(() => {
|
||||
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
|
||||
});
|
||||
|
||||
it("round-trips a plaintext value", () => {
|
||||
const plaintext = "1//0gExampleGoogleRefreshToken";
|
||||
const encrypted = encryptToken(plaintext);
|
||||
expect(encrypted).not.toBe(plaintext);
|
||||
expect(decryptToken(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("produces a different ciphertext for the same plaintext each time (random IV)", () => {
|
||||
const a = encryptToken("same-value");
|
||||
const b = encryptToken("same-value");
|
||||
expect(a).not.toBe(b);
|
||||
expect(decryptToken(a)).toBe("same-value");
|
||||
expect(decryptToken(b)).toBe("same-value");
|
||||
});
|
||||
|
||||
it("throws if CALENDAR_TOKEN_ENCRYPTION_KEY is not set", () => {
|
||||
delete process.env.CALENDAR_TOKEN_ENCRYPTION_KEY;
|
||||
expect(() => encryptToken("x")).toThrow(/CALENDAR_TOKEN_ENCRYPTION_KEY/);
|
||||
});
|
||||
|
||||
it("fails to decrypt with a tampered ciphertext (authentication tag mismatch)", () => {
|
||||
const encrypted = encryptToken("secret-value");
|
||||
const tampered = encrypted.slice(0, -4) + "AAAA";
|
||||
expect(() => decryptToken(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Signed, short-lived OAuth `state` for the Google Calendar connect flow
|
||||
* (see src/app/api/staff/calendar/google/{authorize,callback}/route.ts).
|
||||
* Same HMAC-over-JSON approach as src/lib/booking-draft/sign.ts, with its
|
||||
* own context string — kept as a separate module rather than reusing that
|
||||
* one directly, so the two signing keys stay cryptographically
|
||||
* independent (a leaked booking-draft signature should tell an attacker
|
||||
* nothing about this one, and vice versa).
|
||||
*/
|
||||
|
||||
const CONTEXT = "nextchair-calendar-oauth-state-v1";
|
||||
const MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
interface StatePayload {
|
||||
nonce: string;
|
||||
staffId: string;
|
||||
issuedAt: number;
|
||||
}
|
||||
|
||||
function getSigningKey(): Buffer {
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
if (!secret) throw new Error("AUTH_SECRET must be set to sign the calendar OAuth state.");
|
||||
return createHmac("sha256", secret).update(CONTEXT).digest();
|
||||
}
|
||||
|
||||
export function createOAuthState(staffId: string): string {
|
||||
const payload: StatePayload = { nonce: randomBytes(16).toString("hex"), staffId, issuedAt: Date.now() };
|
||||
const json = JSON.stringify(payload);
|
||||
const mac = createHmac("sha256", getSigningKey()).update(json).digest("base64url");
|
||||
return `${Buffer.from(json, "utf8").toString("base64url")}.${mac}`;
|
||||
}
|
||||
|
||||
/** Returns the staffId if `token` is a validly-signed, non-expired state for this exact staff member — null otherwise. Never throws. */
|
||||
export function verifyOAuthState(token: string, expectedStaffId: string): boolean {
|
||||
const separatorIndex = token.indexOf(".");
|
||||
if (separatorIndex === -1) return false;
|
||||
const encodedPayload = token.slice(0, separatorIndex);
|
||||
const mac = token.slice(separatorIndex + 1);
|
||||
if (!encodedPayload || !mac) return false;
|
||||
|
||||
let json: string;
|
||||
try {
|
||||
json = Buffer.from(encodedPayload, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedMac = createHmac("sha256", getSigningKey()).update(json).digest("base64url");
|
||||
const macBuffer = Buffer.from(mac);
|
||||
const expectedBuffer = Buffer.from(expectedMac);
|
||||
if (macBuffer.length !== expectedBuffer.length || !timingSafeEqual(macBuffer, expectedBuffer)) return false;
|
||||
|
||||
let payload: StatePayload;
|
||||
try {
|
||||
payload = JSON.parse(json);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.staffId !== expectedStaffId) return false;
|
||||
if (Date.now() - payload.issuedAt > MAX_AGE_MS) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Constant-time string comparison for secrets (cron bearer tokens, the
|
||||
* installation token) — a plain `!==` leaks a secret's length and correct
|
||||
* prefix via response timing. Shared by
|
||||
* src/app/api/cron/process-notifications/route.ts and
|
||||
* src/features/installation/actions/install.ts; see also
|
||||
* src/lib/booking-draft/sign.ts, which uses the same primitive directly
|
||||
* for HMAC comparison rather than through this helper.
|
||||
*/
|
||||
export function secretsMatch(provided: string, expected: string): boolean {
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (providedBuffer.length !== expectedBuffer.length) return false;
|
||||
return timingSafeEqual(providedBuffer, expectedBuffer);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* AES-256-GCM at-rest encryption for Google Calendar OAuth tokens (see
|
||||
* StaffCalendarConnection in prisma/schema.prisma) — refresh tokens are
|
||||
* long-lived credentials for a third-party account and must never be
|
||||
* stored in plain text. Output format: base64(iv[12] + authTag[16] +
|
||||
* ciphertext) — everything needed to decrypt in one column, no separate
|
||||
* iv/tag columns to keep in sync.
|
||||
*
|
||||
* CALENDAR_TOKEN_ENCRYPTION_KEY is expected to be a 32-byte value,
|
||||
* hex-encoded (64 hex chars) — generate with `openssl rand -hex 32`. A
|
||||
* SHA-256 of the raw env value is used as the actual AES key (not the
|
||||
* raw bytes directly) so a key of any length/format still yields a
|
||||
* valid 32-byte AES-256 key, rather than throwing at startup for a
|
||||
* slightly-wrong-length env var.
|
||||
*/
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.CALENDAR_TOKEN_ENCRYPTION_KEY;
|
||||
if (!secret) {
|
||||
throw new Error("CALENDAR_TOKEN_ENCRYPTION_KEY must be set to store Google Calendar tokens.");
|
||||
}
|
||||
return createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
export function encryptToken(plaintext: string): string {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
|
||||
}
|
||||
|
||||
export function decryptToken(encoded: string): string {
|
||||
const raw = Buffer.from(encoded, "base64");
|
||||
const iv = raw.subarray(0, IV_LENGTH);
|
||||
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const ciphertext = raw.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { safeRedirectPath } from "../safe-redirect";
|
||||
|
||||
describe("safeRedirectPath", () => {
|
||||
it("accepts a plain internal path", () => {
|
||||
expect(safeRedirectPath("/book/details", "/home")).toBe("/book/details");
|
||||
});
|
||||
|
||||
it("falls back for null/undefined/empty input", () => {
|
||||
expect(safeRedirectPath(null, "/home")).toBe("/home");
|
||||
expect(safeRedirectPath(undefined, "/home")).toBe("/home");
|
||||
expect(safeRedirectPath("", "/home")).toBe("/home");
|
||||
});
|
||||
|
||||
it("rejects protocol-relative URLs", () => {
|
||||
expect(safeRedirectPath("//evil.com", "/home")).toBe("/home");
|
||||
});
|
||||
|
||||
it("rejects absolute URLs to another host", () => {
|
||||
expect(safeRedirectPath("https://evil.com/phish", "/home")).toBe("/home");
|
||||
expect(safeRedirectPath("http://evil.com", "/home")).toBe("/home");
|
||||
});
|
||||
|
||||
it("rejects a path not starting with a single slash", () => {
|
||||
expect(safeRedirectPath("book/details", "/home")).toBe("/home");
|
||||
expect(safeRedirectPath("javascript:alert(1)", "/home")).toBe("/home");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Validates a caller-supplied return path (a `callbackUrl` query param,
|
||||
* a booking-draft continuation target) is a safe, internal, relative
|
||||
* path before it's ever used in a redirect — see the brief's "Accept
|
||||
* only safe internal return paths. Prevent open redirects." Auth.js's
|
||||
* own `signIn()`/`redirectTo` already restricts to same-origin
|
||||
* internally, but this is checked explicitly rather than relied on
|
||||
* implicitly, since it guards a value that reached this code from a URL
|
||||
* query string, not from a trusted internal source.
|
||||
*/
|
||||
export function safeRedirectPath(path: string | null | undefined, fallback: string): string {
|
||||
if (!path) return fallback;
|
||||
// Must start with exactly one "/" — rejects "//evil.com" (protocol-relative),
|
||||
// "https://evil.com", and "javascript:..." in one check.
|
||||
if (!path.startsWith("/") || path.startsWith("//")) return fallback;
|
||||
if (path.includes("://")) return fallback;
|
||||
return path;
|
||||
}
|
||||
+43
-2
@@ -1,6 +1,7 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { authConfig } from "@/auth.config";
|
||||
import { isInstalledForRequest } from "@/lib/installation/installation-state";
|
||||
|
||||
// /home, /appointments and /settings are intentionally NOT hard-gated here
|
||||
// as of Phase 4 — Home must work for guests (public business browsing +
|
||||
@@ -10,6 +11,25 @@ import { authConfig } from "@/auth.config";
|
||||
// permission-based tooling with no meaningful "guest" experience at all.
|
||||
const PROTECTED_PREFIXES = ["/staff", "/owner"];
|
||||
|
||||
// Reachable before installation completes: the installer itself, and the
|
||||
// small set of static/manifest assets a browser requests independently of
|
||||
// the page it's rendering (favicon, PWA manifest/icons, the offline
|
||||
// fallback, the service worker script). Everything else redirects to
|
||||
// /install until ApplicationInstallation flips to INSTALLED — see
|
||||
// src/lib/installation/installation-state.ts. `/api/*` never reaches this
|
||||
// file at all (excluded by `config.matcher` below); API routes that need
|
||||
// business data guard themselves (see /api/health and
|
||||
// /api/cron/process-notifications).
|
||||
const INSTALL_EXEMPT_PREFIXES = [
|
||||
"/install",
|
||||
"/offline",
|
||||
"/manifest.webmanifest",
|
||||
"/icon.png",
|
||||
"/apple-icon.png",
|
||||
"/icons/",
|
||||
"/sw.js",
|
||||
];
|
||||
|
||||
// Lightweight instance built from the DB-free config — see auth.config.ts.
|
||||
// Next.js 16 renamed this file convention from `middleware` to `proxy`
|
||||
// (https://nextjs.org/docs/messages/middleware-to-proxy); it now always
|
||||
@@ -57,12 +77,33 @@ function buildCsp(): string {
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
export default auth((req) => {
|
||||
export default auth(async (req) => {
|
||||
const { pathname } = req.nextUrl;
|
||||
const csp = buildCsp();
|
||||
|
||||
const isInstallExempt = INSTALL_EXEMPT_PREFIXES.some(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
||||
);
|
||||
const installed = await isInstalledForRequest();
|
||||
|
||||
if (!installed && !isInstallExempt) {
|
||||
const response = NextResponse.redirect(new URL("/install", req.nextUrl.origin));
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
return response;
|
||||
}
|
||||
if (installed && pathname === "/install") {
|
||||
// The wizard itself also refuses to render/submit once installed (see
|
||||
// src/app/install/page.tsx and the installer Server Action) — this
|
||||
// redirect is a convenience for direct navigation, not the actual
|
||||
// enforcement boundary.
|
||||
const response = NextResponse.redirect(new URL("/", req.nextUrl.origin));
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
return response;
|
||||
}
|
||||
|
||||
const isProtected = PROTECTED_PREFIXES.some(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||
);
|
||||
const csp = buildCsp();
|
||||
|
||||
if (isProtected && !req.auth) {
|
||||
const signInUrl = new URL("/sign-in", req.nextUrl.origin);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
|
||||
import { encryptToken } from "@/lib/security/token-encryption";
|
||||
import { MockCalendarProvider } from "../mock-calendar.provider";
|
||||
import { runInitialSync, syncAppointmentSafely } from "../calendar-sync.service";
|
||||
|
||||
/**
|
||||
* Exercises the real sync policy (create/update/cancel/duplicate
|
||||
* prevention/reassignment) against a real database, with a
|
||||
* `MockCalendarProvider` standing in for Google — see that file's own
|
||||
* header for why (the brief: "Do not call real Google Calendar APIs in
|
||||
* automated tests").
|
||||
*/
|
||||
describe("calendar-sync.service (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let staffId: string;
|
||||
let customerId: string;
|
||||
let provider: MockCalendarProvider;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
const staff = await createTestStaff(business);
|
||||
staffId = staff.staffId;
|
||||
const customer = await createTestCustomer(business);
|
||||
customerId = customer.customerId;
|
||||
provider = new MockCalendarProvider();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
async function connectCalendar(): Promise<string> {
|
||||
const connection = await prisma.staffCalendarConnection.create({
|
||||
data: {
|
||||
staffId,
|
||||
externalAccountId: "mock-account",
|
||||
accountEmail: "barber@example.com",
|
||||
encryptedAccessToken: encryptToken("mock-access-token"),
|
||||
encryptedRefreshToken: encryptToken("mock-refresh-token"),
|
||||
tokenExpiresAt: new Date(Date.now() + 3600_000),
|
||||
selectedCalendarId: "primary",
|
||||
selectedCalendarName: "Primary Calendar",
|
||||
},
|
||||
});
|
||||
return connection.id;
|
||||
}
|
||||
|
||||
async function createAppointment(status: AppointmentStatus, overrides: { staffId?: string } = {}) {
|
||||
return prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId: overrides.staffId ?? staffId,
|
||||
customerId,
|
||||
status,
|
||||
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("creates a remote event for a CONFIRMED appointment", async () => {
|
||||
await connectCalendar();
|
||||
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||
expect(event?.status).toBe(CalendarSyncStatus.SYNCED);
|
||||
expect(provider.events.size).toBe(1);
|
||||
const [providerEvent] = [...provider.events.values()];
|
||||
expect(providerEvent?.summary).toContain("Test Customer");
|
||||
});
|
||||
|
||||
it("does not sync a PENDING appointment", async () => {
|
||||
await connectCalendar();
|
||||
const appointment = await createAppointment(AppointmentStatus.PENDING);
|
||||
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||
expect(event).toBeNull();
|
||||
expect(provider.events.size).toBe(0);
|
||||
});
|
||||
|
||||
it("is idempotent — syncing an already-synced appointment updates the same remote event rather than creating a second one", async () => {
|
||||
await connectCalendar();
|
||||
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
const events = await prisma.appointmentCalendarEvent.findMany({ where: { appointmentId: appointment.id } });
|
||||
expect(events).toHaveLength(1);
|
||||
expect(provider.events.size).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes the remote event when the appointment is cancelled", async () => {
|
||||
await connectCalendar();
|
||||
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
expect(provider.events.size).toBe(1);
|
||||
|
||||
await prisma.appointment.update({ where: { id: appointment.id }, data: { status: AppointmentStatus.CANCELLED } });
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
expect(provider.events.size).toBe(0);
|
||||
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||
expect(event?.status).toBe(CalendarSyncStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it("cancels the previous staff member's event on reassignment and creates one under the new staff member's connection", async () => {
|
||||
await connectCalendar();
|
||||
const otherStaff = await createTestStaff(business);
|
||||
const otherConnection = await prisma.staffCalendarConnection.create({
|
||||
data: {
|
||||
staffId: otherStaff.staffId,
|
||||
externalAccountId: "mock-account-2",
|
||||
accountEmail: "other-barber@example.com",
|
||||
encryptedAccessToken: encryptToken("mock-access-token-2"),
|
||||
encryptedRefreshToken: encryptToken("mock-refresh-token-2"),
|
||||
tokenExpiresAt: new Date(Date.now() + 3600_000),
|
||||
selectedCalendarId: "primary",
|
||||
selectedCalendarName: "Primary Calendar",
|
||||
},
|
||||
});
|
||||
|
||||
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
expect(provider.events.size).toBe(1);
|
||||
|
||||
await prisma.appointment.update({ where: { id: appointment.id }, data: { staffId: otherStaff.staffId } });
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
const originalConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
|
||||
where: { appointmentId: appointment.id, connection: { staffId } },
|
||||
});
|
||||
expect(originalConnectionEvent?.status).toBe(CalendarSyncStatus.CANCELLED);
|
||||
|
||||
const newConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
|
||||
where: { appointmentId: appointment.id, connectionId: otherConnection.id },
|
||||
});
|
||||
expect(newConnectionEvent?.status).toBe(CalendarSyncStatus.SYNCED);
|
||||
|
||||
await prisma.staffCalendarConnection.delete({ where: { id: otherConnection.id } }).catch(() => {});
|
||||
});
|
||||
|
||||
it("runInitialSync syncs every future non-pending, non-cancelled appointment for a newly connected staff member", async () => {
|
||||
await connectCalendar();
|
||||
const confirmed = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
const pending = await createAppointment(AppointmentStatus.PENDING);
|
||||
const cancelled = await createAppointment(AppointmentStatus.CANCELLED);
|
||||
|
||||
const result = await runInitialSync(prisma, staffId, provider);
|
||||
|
||||
expect(result.synced).toBe(1);
|
||||
expect(provider.events.size).toBe(1);
|
||||
const syncedEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: confirmed.id } });
|
||||
expect(syncedEvent).toBeTruthy();
|
||||
const pendingEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: pending.id } });
|
||||
expect(pendingEvent).toBeNull();
|
||||
const cancelledEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: cancelled.id } });
|
||||
expect(cancelledEvent).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing when no calendar connection exists for the staff member", async () => {
|
||||
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||
expect(event).toBeNull();
|
||||
expect(provider.events.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
|
||||
|
||||
/**
|
||||
* Provider-neutral operations `CalendarSyncService` calls — Google is the
|
||||
* only implementation today (`google-calendar.provider.ts`), but nothing
|
||||
* in the sync service or appointment Server Actions calls Google's API
|
||||
* directly (see the brief's explicit "Do not call Google APIs directly
|
||||
* from appointment Server Actions"), so a second provider is additive.
|
||||
*/
|
||||
export interface CalendarProvider {
|
||||
name: string;
|
||||
|
||||
/** OAuth authorization URL the browser is redirected to — `state` is an opaque, server-generated CSRF/replay token the callback route re-verifies. */
|
||||
buildAuthorizationUrl(params: { redirectUri: string; state: string }): string;
|
||||
|
||||
/** Exchanges the callback's `code` for tokens, and resolves which external account this actually is. */
|
||||
exchangeCodeForTokens(params: { code: string; redirectUri: string }): Promise<ExchangedTokens>;
|
||||
|
||||
refreshAccessToken(refreshToken: string): Promise<RefreshedAccessToken>;
|
||||
|
||||
/** Only ever returns calendars the account can write to — see RemoteCalendarInfo.writable. */
|
||||
listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]>;
|
||||
|
||||
createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }>;
|
||||
|
||||
updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void>;
|
||||
|
||||
deleteEvent(params: { accessToken: string; calendarId: string; providerEventId: string }): Promise<void>;
|
||||
|
||||
/** Best-effort server-side revocation at disconnect — see the brief's "Revoke credentials where possible." */
|
||||
revoke(refreshToken: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
|
||||
import { decryptToken, encryptToken } from "@/lib/security/token-encryption";
|
||||
import type { CalendarProvider } from "./calendar-provider";
|
||||
import { GoogleCalendarProvider } from "./google-calendar.provider";
|
||||
import { CalendarProviderError } from "./types";
|
||||
import type { CalendarEventInput } from "./types";
|
||||
|
||||
/**
|
||||
* Appointment → Google Calendar sync policy — the one place that decides
|
||||
* *whether* and *how* an appointment's status maps to a calendar event.
|
||||
* Provider-neutral: everything here goes through `CalendarProvider`, never
|
||||
* a Google-specific call directly (see calendar-provider.ts). Every
|
||||
* public function here is safe to call from a Server Action after an
|
||||
* appointment mutation commits — sync failures are caught and recorded,
|
||||
* never thrown, so a Google outage can never roll back or block a real
|
||||
* booking change (see the brief's explicit requirement).
|
||||
*/
|
||||
|
||||
function getProvider(): CalendarProvider {
|
||||
// Google is the only provider today — see CalendarProvider's own header
|
||||
// for why a second one is additive, not a rewrite, when needed.
|
||||
return new GoogleCalendarProvider();
|
||||
}
|
||||
|
||||
async function getValidAccessToken(
|
||||
prisma: PrismaClient,
|
||||
connection: { id: string; encryptedAccessToken: string | null; encryptedRefreshToken: string; tokenExpiresAt: Date | null },
|
||||
provider: CalendarProvider,
|
||||
): Promise<string> {
|
||||
const stillValid =
|
||||
connection.encryptedAccessToken && connection.tokenExpiresAt && connection.tokenExpiresAt.getTime() - Date.now() > 60_000;
|
||||
if (stillValid) return decryptToken(connection.encryptedAccessToken!);
|
||||
|
||||
const refreshToken = decryptToken(connection.encryptedRefreshToken);
|
||||
const refreshed = await provider.refreshAccessToken(refreshToken);
|
||||
await prisma.staffCalendarConnection.update({
|
||||
where: { id: connection.id },
|
||||
data: { encryptedAccessToken: encryptToken(refreshed.accessToken), tokenExpiresAt: refreshed.expiresAt },
|
||||
});
|
||||
return refreshed.accessToken;
|
||||
}
|
||||
|
||||
function buildEventContent(appointment: {
|
||||
id: string;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
status: AppointmentStatus;
|
||||
customer: { firstName: string; lastName: string };
|
||||
services: { service: { name: string } }[];
|
||||
business: { name: string; locations: { addressLine1: string; city: string; postalCode: string }[] };
|
||||
}): CalendarEventInput {
|
||||
const serviceNames = appointment.services.map((s) => s.service.name).join(", ");
|
||||
const location = appointment.business.locations[0];
|
||||
const locationLine = location ? `${location.addressLine1}, ${location.city} ${location.postalCode}` : undefined;
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
|
||||
|
||||
const descriptionLines = [
|
||||
`Customer: ${appointment.customer.firstName} ${appointment.customer.lastName}`,
|
||||
`Service: ${serviceNames}`,
|
||||
`Business: ${appointment.business.name}`,
|
||||
locationLine ? `Address: ${locationLine}` : undefined,
|
||||
`Reference: ${appointment.id}`,
|
||||
appUrl ? `View: ${appUrl}/staff/appointments/${appointment.id}` : undefined,
|
||||
appointment.status === AppointmentStatus.NO_SHOW ? "Status: customer did not show up." : undefined,
|
||||
].filter((line): line is string => !!line);
|
||||
|
||||
return {
|
||||
summary: `${serviceNames} — ${appointment.customer.firstName} ${appointment.customer.lastName}`,
|
||||
description: descriptionLines.join("\n"),
|
||||
location: locationLine,
|
||||
startAt: appointment.startAt,
|
||||
endAt: appointment.endAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy: PENDING appointments are never synced (an unapproved booking
|
||||
* shouldn't occupy the barber's personal calendar); CANCELLED deletes the
|
||||
* remote event outright (a cancelled slot has no reason to still show
|
||||
* busy); everything else (CONFIRMED/CHECKED_IN/IN_PROGRESS/COMPLETED/
|
||||
* NO_SHOW) creates or updates the event, with NO_SHOW additionally noting
|
||||
* the outcome in the description rather than removing the event (the
|
||||
* barber's calendar should still show they were scheduled to work).
|
||||
*/
|
||||
async function syncOneConnection(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
appointmentId: string;
|
||||
connection: {
|
||||
id: string;
|
||||
encryptedAccessToken: string | null;
|
||||
encryptedRefreshToken: string;
|
||||
tokenExpiresAt: Date | null;
|
||||
selectedCalendarId: string;
|
||||
};
|
||||
provider: CalendarProvider;
|
||||
},
|
||||
): Promise<void> {
|
||||
const appointment = await prisma.appointment.findUnique({
|
||||
where: { id: params.appointmentId },
|
||||
include: {
|
||||
customer: { select: { firstName: true, lastName: true } },
|
||||
services: { include: { service: { select: { name: true } } } },
|
||||
business: { select: { name: true, locations: { where: { isPrimary: true }, take: 1 } } },
|
||||
},
|
||||
});
|
||||
if (!appointment) return;
|
||||
|
||||
const provider = params.provider;
|
||||
const existing = await prisma.appointmentCalendarEvent.findUnique({
|
||||
where: { appointmentId_connectionId: { appointmentId: params.appointmentId, connectionId: params.connection.id } },
|
||||
});
|
||||
|
||||
try {
|
||||
if (appointment.status === AppointmentStatus.PENDING) return;
|
||||
|
||||
const accessToken = await getValidAccessToken(prisma, params.connection, provider);
|
||||
|
||||
if (appointment.status === AppointmentStatus.CANCELLED) {
|
||||
if (existing) {
|
||||
await provider.deleteEvent({ accessToken, calendarId: params.connection.selectedCalendarId, providerEventId: existing.providerEventId });
|
||||
await prisma.appointmentCalendarEvent.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: CalendarSyncStatus.CANCELLED, lastSyncedAt: new Date(), lastSyncError: null },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const event = buildEventContent(appointment);
|
||||
|
||||
if (existing && existing.status !== CalendarSyncStatus.CANCELLED) {
|
||||
await provider.updateEvent({
|
||||
accessToken,
|
||||
calendarId: params.connection.selectedCalendarId,
|
||||
providerEventId: existing.providerEventId,
|
||||
event,
|
||||
});
|
||||
await prisma.appointmentCalendarEvent.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: CalendarSyncStatus.SYNCED, lastSyncedAt: new Date(), lastSyncError: null, sourceVersion: { increment: 1 } },
|
||||
});
|
||||
} else {
|
||||
const created = await provider.createEvent({ accessToken, calendarId: params.connection.selectedCalendarId, event });
|
||||
await prisma.appointmentCalendarEvent.upsert({
|
||||
where: { appointmentId_connectionId: { appointmentId: params.appointmentId, connectionId: params.connection.id } },
|
||||
create: {
|
||||
appointmentId: params.appointmentId,
|
||||
connectionId: params.connection.id,
|
||||
providerEventId: created.providerEventId,
|
||||
providerCalendarId: params.connection.selectedCalendarId,
|
||||
status: CalendarSyncStatus.SYNCED,
|
||||
},
|
||||
update: {
|
||||
providerEventId: created.providerEventId,
|
||||
providerCalendarId: params.connection.selectedCalendarId,
|
||||
status: CalendarSyncStatus.SYNCED,
|
||||
lastSyncedAt: new Date(),
|
||||
lastSyncError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncAt: new Date(), lastSyncError: null } });
|
||||
} catch (error) {
|
||||
const message = error instanceof CalendarProviderError ? error.message : "Calendar sync failed unexpectedly.";
|
||||
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncError: message } }).catch(() => {});
|
||||
if (existing) {
|
||||
await prisma.appointmentCalendarEvent
|
||||
.update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.FAILED, lastSyncError: message } })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call after any appointment mutation that should be reflected in
|
||||
* calendars — confirmation, approval, reschedule, service change,
|
||||
* cancellation, no-show. Never throws; a Google failure is recorded on
|
||||
* the connection/event rows for the staff settings page to surface, not
|
||||
* propagated back to the caller (see this file's own header). If the
|
||||
* appointment was reassigned to a different staff member, the previous
|
||||
* staff member's event (if any) is cancelled first so responsibility
|
||||
* moves cleanly rather than leaving a stale duplicate.
|
||||
*/
|
||||
export async function syncAppointmentSafely(
|
||||
prisma: PrismaClient,
|
||||
appointmentId: string,
|
||||
provider: CalendarProvider = getProvider(),
|
||||
): Promise<void> {
|
||||
const appointment = await prisma.appointment.findUnique({ where: { id: appointmentId }, select: { staffId: true } });
|
||||
if (!appointment) return;
|
||||
|
||||
const staleEvents = await prisma.appointmentCalendarEvent.findMany({
|
||||
where: { appointmentId, status: { not: CalendarSyncStatus.CANCELLED }, connection: { staffId: { not: appointment.staffId } } },
|
||||
include: { connection: true },
|
||||
});
|
||||
for (const stale of staleEvents) {
|
||||
await syncOneConnection(prisma, {
|
||||
appointmentId,
|
||||
provider,
|
||||
connection: {
|
||||
id: stale.connection.id,
|
||||
encryptedAccessToken: stale.connection.encryptedAccessToken,
|
||||
encryptedRefreshToken: stale.connection.encryptedRefreshToken,
|
||||
tokenExpiresAt: stale.connection.tokenExpiresAt,
|
||||
selectedCalendarId: stale.connection.selectedCalendarId,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
// A reassigned-away staff member's event is deleted via the CANCELLED
|
||||
// path above regardless of the appointment's own current status — it's
|
||||
// no longer this connection's appointment to reflect.
|
||||
if (staleEvents.length > 0) {
|
||||
await prisma.appointmentCalendarEvent.updateMany({
|
||||
where: { id: { in: staleEvents.map((e) => e.id) } },
|
||||
data: { status: CalendarSyncStatus.CANCELLED },
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId: appointment.staffId } });
|
||||
if (!connection || connection.revokedAt) return;
|
||||
|
||||
await syncOneConnection(prisma, { appointmentId, connection, provider });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync every future appointment for a newly connected staff member — see
|
||||
* the brief's "Initial sync... default to the next 90 days." `provider`
|
||||
* is injectable (defaults to the real Google provider) purely so tests
|
||||
* can pass a `MockCalendarProvider` — see
|
||||
* src/services/calendar/mock-calendar.provider.ts and the brief's "Do
|
||||
* not call real Google Calendar APIs in automated tests."
|
||||
*/
|
||||
export async function runInitialSync(
|
||||
prisma: PrismaClient,
|
||||
staffId: string,
|
||||
provider: CalendarProvider = getProvider(),
|
||||
): Promise<{ synced: number }> {
|
||||
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||
if (!connection) return { synced: 0 };
|
||||
|
||||
const appointments = await prisma.appointment.findMany({
|
||||
where: {
|
||||
staffId,
|
||||
startAt: { gte: new Date(), lte: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
|
||||
status: { notIn: [AppointmentStatus.PENDING, AppointmentStatus.CANCELLED] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const appointment of appointments) {
|
||||
await syncOneConnection(prisma, { appointmentId: appointment.id, connection, provider });
|
||||
}
|
||||
return { synced: appointments.length };
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { CalendarProvider } from "./calendar-provider";
|
||||
import { CalendarProviderError } from "./types";
|
||||
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
|
||||
|
||||
/**
|
||||
* Google Calendar over its plain REST API (OAuth 2.0 + Calendar API v3)
|
||||
* via `fetch` — no `googleapis` SDK dependency, matching this app's
|
||||
* existing pattern of building directly against a documented REST
|
||||
* contract rather than adding a heavy client library (see
|
||||
* brevo-email-provider.ts's own header for the same reasoning applied to
|
||||
* email). Minimum scopes: `calendar.events` (create/update/delete events)
|
||||
* and `calendar.readonly` (list calendars to choose a destination) —
|
||||
* deliberately not the full `calendar` scope, which also grants calendar
|
||||
* *settings* management this app never needs.
|
||||
*
|
||||
* Unverified against a live Google account in this environment — no
|
||||
* OAuth credentials were available to test against. See
|
||||
* docs/phase-8-summary.md, "Unverified external-provider behaviour."
|
||||
*/
|
||||
|
||||
const SCOPES = ["https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly", "email"].join(" ");
|
||||
|
||||
interface GoogleErrorBody {
|
||||
error?: string | { message?: string; errors?: { reason?: string }[] };
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
function mapGoogleError(status: number, body: GoogleErrorBody | undefined): CalendarProviderError {
|
||||
const reason =
|
||||
typeof body?.error === "object" ? body.error.errors?.[0]?.reason : typeof body?.error === "string" ? body.error : undefined;
|
||||
|
||||
if (status === 401 || reason === "authError") {
|
||||
return new CalendarProviderError("TOKEN_EXPIRED", "The connection to Google Calendar has expired.", true);
|
||||
}
|
||||
if (status === 403 && reason === "insufficientPermissions") {
|
||||
return new CalendarProviderError("CALENDAR_NOT_WRITABLE", "This calendar can't be written to.", false);
|
||||
}
|
||||
if (status === 403 || status === 429) {
|
||||
return new CalendarProviderError("RATE_LIMITED", "Google Calendar is temporarily rate-limiting requests.", true);
|
||||
}
|
||||
if (status === 404) {
|
||||
return new CalendarProviderError("CALENDAR_NOT_FOUND", "That calendar no longer exists.", false);
|
||||
}
|
||||
if (status === 400) {
|
||||
return new CalendarProviderError("INVALID_EVENT", "Google Calendar rejected this event.", false);
|
||||
}
|
||||
if (status >= 500) {
|
||||
return new CalendarProviderError("PROVIDER_OUTAGE", "Google Calendar is temporarily unavailable.", true);
|
||||
}
|
||||
return new CalendarProviderError("UNKNOWN", "Google Calendar returned an unexpected error.", true);
|
||||
}
|
||||
|
||||
async function googleFetch(url: string, init: RequestInit): Promise<Response> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch {
|
||||
throw new CalendarProviderError("TIMEOUT", "Could not reach Google Calendar.", true);
|
||||
}
|
||||
if (!response.ok) {
|
||||
let body: GoogleErrorBody | undefined;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// Non-JSON error body — mapGoogleError below still has the status code.
|
||||
}
|
||||
throw mapGoogleError(response.status, body);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
interface GoogleConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
function loadConfig(): GoogleConfig {
|
||||
const clientId = process.env.GOOGLE_CALENDAR_CLIENT_ID;
|
||||
const clientSecret = process.env.GOOGLE_CALENDAR_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error("GOOGLE_CALENDAR_CLIENT_ID and GOOGLE_CALENDAR_CLIENT_SECRET must be set to use Google Calendar sync.");
|
||||
}
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
function toGoogleEventBody(event: CalendarEventInput) {
|
||||
return {
|
||||
summary: event.summary,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
start: { dateTime: event.startAt.toISOString() },
|
||||
end: { dateTime: event.endAt.toISOString() },
|
||||
// Never public — see the brief's "Events must not be public."
|
||||
visibility: "private",
|
||||
};
|
||||
}
|
||||
|
||||
export class GoogleCalendarProvider implements CalendarProvider {
|
||||
name = "google";
|
||||
|
||||
buildAuthorizationUrl(params: { redirectUri: string; state: string }): string {
|
||||
const { clientId } = loadConfig();
|
||||
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
||||
url.searchParams.set("client_id", clientId);
|
||||
url.searchParams.set("redirect_uri", params.redirectUri);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", SCOPES);
|
||||
url.searchParams.set("access_type", "offline");
|
||||
// Forces Google to reissue a refresh token even for a user who
|
||||
// previously connected — otherwise a reconnect after disconnect can
|
||||
// silently get no refresh token at all.
|
||||
url.searchParams.set("prompt", "consent");
|
||||
url.searchParams.set("state", params.state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async exchangeCodeForTokens(params: { code: string; redirectUri: string }): Promise<ExchangedTokens> {
|
||||
const { clientId, clientSecret } = loadConfig();
|
||||
const response = await googleFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
code: params.code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: params.redirectUri,
|
||||
grant_type: "authorization_code",
|
||||
}),
|
||||
});
|
||||
const tokens = await response.json();
|
||||
if (!tokens.refresh_token) {
|
||||
throw new CalendarProviderError(
|
||||
"MISSING_REFRESH_TOKEN",
|
||||
"Google didn't return a refresh token — please try connecting again.",
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const userInfoResponse = await googleFetch("https://www.googleapis.com/oauth2/v2/userinfo", {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const userInfo = await userInfoResponse.json();
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||
externalAccountId: userInfo.id,
|
||||
accountEmail: userInfo.email,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshAccessToken(refreshToken: string): Promise<RefreshedAccessToken> {
|
||||
const { clientId, clientSecret } = loadConfig();
|
||||
const response = await googleFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
const tokens = await response.json();
|
||||
return { accessToken: tokens.access_token, expiresAt: new Date(Date.now() + tokens.expires_in * 1000) };
|
||||
}
|
||||
|
||||
async listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]> {
|
||||
const response = await googleFetch("https://www.googleapis.com/calendar/v3/users/me/calendarList", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = await response.json();
|
||||
const items: { id: string; summary: string; primary?: boolean; accessRole: string }[] = data.items ?? [];
|
||||
return items
|
||||
.filter((item) => item.accessRole === "owner" || item.accessRole === "writer")
|
||||
.map((item) => ({ id: item.id, name: item.summary, writable: true, primary: !!item.primary }));
|
||||
}
|
||||
|
||||
async createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }> {
|
||||
const response = await googleFetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toGoogleEventBody(params.event)),
|
||||
},
|
||||
);
|
||||
const created = await response.json();
|
||||
return { providerEventId: created.id };
|
||||
}
|
||||
|
||||
async updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void> {
|
||||
await googleFetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.providerEventId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toGoogleEventBody(params.event)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteEvent(params: { accessToken: string; calendarId: string; providerEventId: string }): Promise<void> {
|
||||
try {
|
||||
await googleFetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.providerEventId)}`,
|
||||
{ method: "DELETE", headers: { Authorization: `Bearer ${params.accessToken}` } },
|
||||
);
|
||||
} catch (error) {
|
||||
// Already deleted on the Google side (e.g. the owner removed it
|
||||
// manually) — not a failure from this app's point of view.
|
||||
if (error instanceof CalendarProviderError && error.code === "CALENDAR_NOT_FOUND") return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(refreshToken: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(refreshToken)}`, { method: "POST" });
|
||||
} catch {
|
||||
// Best-effort — see the brief's "Revoke credentials where possible." A failure here doesn't block local disconnect.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { CalendarProvider } from "./calendar-provider";
|
||||
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
|
||||
|
||||
/**
|
||||
* In-memory `CalendarProvider` for tests — never makes a real network
|
||||
* call, per the brief's "Do not call real Google Calendar APIs in
|
||||
* automated tests." Records every create/update/delete so a test can
|
||||
* assert on sync behavior (idempotency, retry, error mapping) without a
|
||||
* live Google account.
|
||||
*/
|
||||
export class MockCalendarProvider implements CalendarProvider {
|
||||
name = "mock";
|
||||
|
||||
events = new Map<string, CalendarEventInput>();
|
||||
private nextEventId = 1;
|
||||
public calendars: RemoteCalendarInfo[] = [{ id: "primary", name: "Primary Calendar", writable: true, primary: true }];
|
||||
|
||||
buildAuthorizationUrl(): string {
|
||||
return "https://mock.local/authorize";
|
||||
}
|
||||
|
||||
async exchangeCodeForTokens(): Promise<ExchangedTokens> {
|
||||
return {
|
||||
accessToken: "mock-access-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
externalAccountId: "mock-account-id",
|
||||
accountEmail: "mock@example.com",
|
||||
};
|
||||
}
|
||||
|
||||
async refreshAccessToken(): Promise<RefreshedAccessToken> {
|
||||
return { accessToken: "mock-access-token-refreshed", expiresAt: new Date(Date.now() + 3600_000) };
|
||||
}
|
||||
|
||||
async listWritableCalendars(): Promise<RemoteCalendarInfo[]> {
|
||||
return this.calendars;
|
||||
}
|
||||
|
||||
async createEvent(params: { event: CalendarEventInput }): Promise<{ providerEventId: string }> {
|
||||
const id = `mock-event-${this.nextEventId++}`;
|
||||
this.events.set(id, params.event);
|
||||
return { providerEventId: id };
|
||||
}
|
||||
|
||||
async updateEvent(params: { providerEventId: string; event: CalendarEventInput }): Promise<void> {
|
||||
this.events.set(params.providerEventId, params.event);
|
||||
}
|
||||
|
||||
async deleteEvent(params: { providerEventId: string }): Promise<void> {
|
||||
this.events.delete(params.providerEventId);
|
||||
}
|
||||
|
||||
async revoke(): Promise<void> {
|
||||
// No-op — nothing to revoke in-memory.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Provider-neutral shapes for one-way (business → calendar) appointment
|
||||
* sync — see calendar-provider.ts for the interface these compose, and
|
||||
* docs/phase-8-summary.md for the overall architecture.
|
||||
*/
|
||||
|
||||
export interface CalendarEventInput {
|
||||
summary: string;
|
||||
description: string;
|
||||
location?: string;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
}
|
||||
|
||||
export interface RemoteCalendarInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Only calendars the connected account can write to are ever offered — see the brief's "Do not allow read-only calendars." */
|
||||
writable: boolean;
|
||||
primary: boolean;
|
||||
}
|
||||
|
||||
export interface ExchangedTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
/** Absent when the provider didn't return one (e.g. a token refresh call, which doesn't reissue a refresh token). */
|
||||
expiresAt: Date;
|
||||
externalAccountId: string;
|
||||
accountEmail: string;
|
||||
}
|
||||
|
||||
export interface RefreshedAccessToken {
|
||||
accessToken: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export type CalendarProviderErrorCode =
|
||||
| "CONSENT_DENIED"
|
||||
| "TOKEN_EXPIRED"
|
||||
| "TOKEN_REVOKED"
|
||||
| "MISSING_REFRESH_TOKEN"
|
||||
| "CALENDAR_NOT_FOUND"
|
||||
| "CALENDAR_NOT_WRITABLE"
|
||||
| "RATE_LIMITED"
|
||||
| "PROVIDER_OUTAGE"
|
||||
| "INVALID_EVENT"
|
||||
| "TIMEOUT"
|
||||
| "UNKNOWN";
|
||||
|
||||
/** Never exposes a raw provider response — see the brief's "Do not expose raw provider responses." */
|
||||
export class CalendarProviderError extends Error {
|
||||
code: CalendarProviderErrorCode;
|
||||
retryable: boolean;
|
||||
|
||||
constructor(code: CalendarProviderErrorCode, message: string, retryable: boolean) {
|
||||
super(message);
|
||||
this.name = "CalendarProviderError";
|
||||
this.code = code;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { MockEmailProvider } from "../providers/mock-email-provider";
|
||||
import { notify } from "../notification.service";
|
||||
import { processDueNotificationDeliveries } from "../reminder-worker";
|
||||
import { renderEmailForNotification } from "../render-email";
|
||||
|
||||
/**
|
||||
* The follow-up-to-Phase-8 behaviour: every outgoing email's visible
|
||||
* sender NAME is the installed business's own name, resolved fresh per
|
||||
* send — never a global lookup, never cached across requests/business
|
||||
* renames. See docs/phase-8-email-sender-summary.md.
|
||||
*/
|
||||
describe("business-name-as-sender (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let provider: MockEmailProvider;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
provider = new MockEmailProvider();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("resolves the installed business's name as the sender for a booking-confirmation-style email", async () => {
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const { notification } = await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "You've earned a stamp.",
|
||||
idempotencyKey: "test:sender:booking",
|
||||
});
|
||||
|
||||
const rendered = await renderEmailForNotification(prisma, notification);
|
||||
expect(rendered?.sender.name).toBe("Test Barbershop");
|
||||
expect(rendered?.sender.name).not.toBe("NextChair");
|
||||
});
|
||||
|
||||
it("resolves the business name for a staff-invitation notification (falls through to the generic template, same sender resolution)", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const { notification } = await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.STAFF_INVITATION_RECEIVED,
|
||||
recipientUserId: userId,
|
||||
subject: "You've been invited",
|
||||
bodyText: "You've been invited to join the team.",
|
||||
idempotencyKey: "test:sender:staff-invite",
|
||||
});
|
||||
|
||||
const rendered = await renderEmailForNotification(prisma, notification);
|
||||
expect(rendered?.sender.name).toBe("Test Barbershop");
|
||||
});
|
||||
|
||||
it("passes the resolved business name all the way through the background processor to the email provider", async () => {
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "You've earned a stamp.",
|
||||
idempotencyKey: "test:sender:worker",
|
||||
});
|
||||
|
||||
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
expect(result.sent).toBe(1);
|
||||
expect(provider.sentEmails).toHaveLength(1);
|
||||
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
|
||||
});
|
||||
|
||||
it("uses the updated business name for the very next email after a rename — no restart, no stale cache", async () => {
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "First stamp.",
|
||||
idempotencyKey: "test:sender:rename-before",
|
||||
});
|
||||
await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
|
||||
|
||||
await prisma.business.update({ where: { id: business.businessId }, data: { name: "Renamed Barbershop" } });
|
||||
|
||||
await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "Second stamp.",
|
||||
idempotencyKey: "test:sender:rename-after",
|
||||
});
|
||||
await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
|
||||
expect(provider.sentEmails).toHaveLength(2);
|
||||
expect(provider.sentEmails[1]?.sender.name).toBe("Renamed Barbershop");
|
||||
});
|
||||
|
||||
it("never lets one business's notification resolve another business's name (no accidental cross-business branding)", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
await prisma.business.update({ where: { id: otherBusiness.businessId }, data: { name: "Other Business Name" } });
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "Stamp for business A.",
|
||||
idempotencyKey: "test:sender:isolation",
|
||||
});
|
||||
|
||||
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
expect(result.sent).toBe(1);
|
||||
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
|
||||
expect(provider.sentEmails[0]?.sender.name).not.toBe("Other Business Name");
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resolveSenderName } from "../sender-identity";
|
||||
|
||||
describe("resolveSenderName", () => {
|
||||
const originalEmailFromName = process.env.EMAIL_FROM_NAME;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.EMAIL_FROM_NAME;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEmailFromName === undefined) delete process.env.EMAIL_FROM_NAME;
|
||||
else process.env.EMAIL_FROM_NAME = originalEmailFromName;
|
||||
});
|
||||
|
||||
it("uses the business name when present", () => {
|
||||
expect(resolveSenderName("Blake Heritage Barbershop")).toBe("Blake Heritage Barbershop");
|
||||
});
|
||||
|
||||
it("prefers the business name even when EMAIL_FROM_NAME is also set", () => {
|
||||
process.env.EMAIL_FROM_NAME = "Some Configured Name";
|
||||
expect(resolveSenderName("Riverside Barbershop")).toBe("Riverside Barbershop");
|
||||
});
|
||||
|
||||
it("falls back to EMAIL_FROM_NAME only when no business name is available", () => {
|
||||
process.env.EMAIL_FROM_NAME = "Emergency Fallback Name";
|
||||
expect(resolveSenderName(null)).toBe("Emergency Fallback Name");
|
||||
expect(resolveSenderName(undefined)).toBe("Emergency Fallback Name");
|
||||
expect(resolveSenderName("")).toBe("Emergency Fallback Name");
|
||||
expect(resolveSenderName(" ")).toBe("Emergency Fallback Name");
|
||||
});
|
||||
|
||||
it("falls back to the neutral name when neither a business name nor EMAIL_FROM_NAME is available", () => {
|
||||
expect(resolveSenderName(null)).toBe("Booking Notifications");
|
||||
expect(resolveSenderName(undefined)).toBe("Booking Notifications");
|
||||
});
|
||||
|
||||
it("never defaults to the internal product name at any fallback tier", () => {
|
||||
// Tier 1 (business name present), tier 3 (nothing configured at all) —
|
||||
// neither branch has any "NextChair" literal anywhere in this
|
||||
// function's source, so this is really a regression guard: if someone
|
||||
// later "helpfully" reintroduces a NextChair default, this fails.
|
||||
expect(resolveSenderName("Blake Heritage Barbershop")).not.toBe("NextChair");
|
||||
expect(resolveSenderName(null)).not.toBe("NextChair");
|
||||
expect(resolveSenderName(undefined)).not.toBe("NextChair");
|
||||
});
|
||||
});
|
||||
@@ -20,14 +20,19 @@ describe("BrevoEmailProvider", () => {
|
||||
username: "test-user",
|
||||
password: "test-pass",
|
||||
fromAddress: "noreply@example.com",
|
||||
fromName: "Test Business",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sendMail.mockReset();
|
||||
});
|
||||
|
||||
const params = { to: { email: "customer@example.com" }, subject: "Hi", html: "<p>Hi</p>", text: "Hi" };
|
||||
const params = {
|
||||
to: { email: "customer@example.com" },
|
||||
sender: { name: "Blake Heritage Barbershop" },
|
||||
subject: "Hi",
|
||||
html: "<p>Hi</p>",
|
||||
text: "Hi",
|
||||
};
|
||||
|
||||
it("reports success with the provider message id on a clean send", async () => {
|
||||
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
|
||||
@@ -72,4 +77,12 @@ describe("BrevoEmailProvider", () => {
|
||||
expect(JSON.stringify(result)).not.toContain("test-pass");
|
||||
expect(JSON.stringify(result)).not.toContain("test-user");
|
||||
});
|
||||
|
||||
it("sends with the per-message sender name and the provider's own configured address, ignoring any sender.email", async () => {
|
||||
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
|
||||
await provider.sendEmail({ ...params, sender: { name: "Riverside Barbershop", email: "someone-else@other-domain.com" } });
|
||||
expect(sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ from: { address: "noreply@example.com", name: "Riverside Barbershop" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,16 @@ import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-pr
|
||||
* BrevoApiEmailProvider) can be added without touching
|
||||
* NotificationService or the background processor — that's the whole
|
||||
* point of the provider abstraction.
|
||||
*
|
||||
* Sender identity (Phase 8 follow-up, see docs/phase-8-email-sender-summary.md):
|
||||
* this config carries only the sender *address* (`fromAddress`) — a
|
||||
* static provider setting, since it must be a domain/address actually
|
||||
* verified with the SMTP relay. The visible sender *name* is no longer
|
||||
* part of this config at all; it arrives per-message via
|
||||
* `SendEmailParams.sender.name`, resolved fresh for every send by
|
||||
* render-email.ts from the notification's own business. This file
|
||||
* deliberately has no Prisma import and no business lookup of any kind —
|
||||
* see that same doc for why the resolution lives one layer up.
|
||||
*/
|
||||
export interface BrevoEmailProviderConfig {
|
||||
host: string;
|
||||
@@ -45,7 +55,6 @@ export interface BrevoEmailProviderConfig {
|
||||
username: string;
|
||||
password: string;
|
||||
fromAddress: string;
|
||||
fromName: string;
|
||||
}
|
||||
|
||||
export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig {
|
||||
@@ -64,7 +73,6 @@ export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig
|
||||
username,
|
||||
password,
|
||||
fromAddress,
|
||||
fromName: process.env.EMAIL_FROM_NAME ?? "NextChair",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,11 +98,9 @@ export class BrevoEmailProvider implements EmailProvider {
|
||||
readonly name = "brevo";
|
||||
private readonly transporter: nodemailer.Transporter;
|
||||
private readonly fromAddress: string;
|
||||
private readonly fromName: string;
|
||||
|
||||
constructor(config: BrevoEmailProviderConfig) {
|
||||
this.fromAddress = config.fromAddress;
|
||||
this.fromName = config.fromName;
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
@@ -109,7 +115,11 @@ export class BrevoEmailProvider implements EmailProvider {
|
||||
async sendEmail(params: SendEmailParams): Promise<EmailSendResult> {
|
||||
try {
|
||||
const info = await this.transporter.sendMail({
|
||||
from: { address: this.fromAddress, name: this.fromName },
|
||||
// The address is always this provider's own env-configured,
|
||||
// verified-with-the-relay value — `params.sender.email` is
|
||||
// deliberately ignored (see EmailSender's own header). Only the
|
||||
// display name comes from the caller.
|
||||
from: { address: this.fromAddress, name: params.sender.name },
|
||||
to: { address: params.to.email, name: params.to.name },
|
||||
subject: params.subject,
|
||||
html: params.html,
|
||||
|
||||
@@ -16,6 +16,7 @@ export class ConsoleEmailProvider implements EmailProvider {
|
||||
console.log(
|
||||
[
|
||||
"──── [ConsoleEmailProvider] would send an email (not actually sent) ────",
|
||||
`From: ${params.sender.name}`,
|
||||
`To: ${params.to.name ? `${params.to.name} <${params.to.email}>` : params.to.email}`,
|
||||
`Subject: ${params.subject}`,
|
||||
"",
|
||||
|
||||
@@ -12,8 +12,25 @@ export interface EmailAddress {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible sender identity for one message — resolved per-send by the
|
||||
* notification/rendering layer (see render-email.ts's `resolveSenderName`),
|
||||
* never baked into static provider config. `email` is optional and, for
|
||||
* BrevoEmailProvider specifically, ignored: the sender *address* stays
|
||||
* exclusively environment-configured (`EMAIL_FROM_ADDRESS`) because it
|
||||
* must match a domain/address actually verified with the SMTP relay — see
|
||||
* that provider's own header. `name` is present here so a future provider
|
||||
* that *can* vary its address per-message isn't blocked by this shape.
|
||||
*/
|
||||
export interface EmailSender {
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface SendEmailParams {
|
||||
to: EmailAddress;
|
||||
/** The resolved business name (or fallback) to show as the visible sender — see render-email.ts. */
|
||||
sender: EmailSender;
|
||||
subject: string;
|
||||
html: string;
|
||||
/** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { EmailProvider } from "./providers/email-provider";
|
||||
import type { PushProvider } from "./providers/push-provider";
|
||||
import { getOwnerUserIds } from "./recipients";
|
||||
import { renderEmailForNotification } from "./render-email";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
|
||||
/**
|
||||
* The background notification processor — the one place EMAIL and PUSH
|
||||
@@ -161,6 +162,7 @@ async function processEmailDelivery(prisma: PrismaClient, delivery: DueDelivery,
|
||||
|
||||
const sendResult = await emailProvider.sendEmail({
|
||||
to: { email: recipientEmail },
|
||||
sender: email.sender,
|
||||
subject: email.subject,
|
||||
html: email.html,
|
||||
text: email.text,
|
||||
@@ -202,10 +204,17 @@ async function processPushDelivery(prisma: PrismaClient, delivery: DueDelivery,
|
||||
let lastFailureReason = "";
|
||||
let anyRetryable = false;
|
||||
|
||||
// Every notify() call sets a subject (see notification.service.ts),
|
||||
// so this fallback is defensive rather than a normal path — but a
|
||||
// push notification's title is genuinely user-visible (shown by the
|
||||
// OS), so it still needs the business name, not the internal product
|
||||
// name, when reached.
|
||||
const fallbackTitle = delivery.notification.subject ?? (await getBranding()).name;
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const sendResult = await pushProvider.sendPush({
|
||||
subscription: { endpoint: subscription.endpoint, p256dhKey: subscription.p256dhKey, authKey: subscription.authKey },
|
||||
title: delivery.notification.subject ?? "NextChair",
|
||||
title: fallbackTitle,
|
||||
body: delivery.notification.bodyText,
|
||||
deepLinkPath: delivery.notification.deepLinkPath ?? undefined,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import { renderBookingCancelledEmail, renderBookingConfirmedEmail, renderBookingPendingApprovalEmail, renderBookingReminderEmail, renderBookingRescheduledEmail } from "./templates/booking-emails";
|
||||
import {
|
||||
renderBookingCancelledEmail,
|
||||
renderBookingConfirmedEmail,
|
||||
renderBookingPendingApprovalEmail,
|
||||
renderBookingReminderEmail,
|
||||
renderBookingRescheduledEmail,
|
||||
} from "./templates/booking-emails";
|
||||
import type { AppointmentEmailData } from "./templates/booking-emails";
|
||||
import { renderGenericNotificationEmail } from "./templates/generic-email";
|
||||
import { renderLoyaltyRewardEarnedEmail, renderLoyaltyRewardRedeemedEmail } from "./templates/loyalty-emails";
|
||||
import {
|
||||
renderLoyaltyRewardEarnedEmail,
|
||||
renderLoyaltyRewardRedeemedEmail,
|
||||
} from "./templates/loyalty-emails";
|
||||
import type { LoyaltyRewardEmailData } from "./templates/loyalty-emails";
|
||||
import type { RenderedEmail } from "./templates/shared";
|
||||
import { resolveSenderName } from "./sender-identity";
|
||||
import type { EmailSender } from "./providers/email-provider";
|
||||
|
||||
export interface RenderedEmailWithSender extends RenderedEmail {
|
||||
/** Resolved fresh on every call from the notification's own business — see resolveSenderName. */
|
||||
sender: EmailSender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a stored Notification row, builds the actual EMAIL to send —
|
||||
@@ -17,17 +33,35 @@ import type { RenderedEmail } from "./templates/shared";
|
||||
* (e.g. a since-updated business name) rather than a stale snapshot. This
|
||||
* is the file that dispatches by NotificationType; the templates/ folder
|
||||
* itself is pure and knows nothing about Prisma.
|
||||
*
|
||||
* The sender *name* (see RenderedEmailWithSender) is resolved here too,
|
||||
* from this exact same `business` row — scoped by `notification.businessId`
|
||||
* via `findUnique`, never a global `findFirst()`, so one business can
|
||||
* never accidentally supply another's branding to a misdirected send.
|
||||
*/
|
||||
export async function renderEmailForNotification(
|
||||
prisma: PrismaClient,
|
||||
notification: { id: string; type: NotificationType; businessId: string; appointmentId: string | null; loyaltyRewardId: string | null; subject: string | null; bodyText: string; deepLinkPath: string | null; recipientUserId: string | null; recipientCustomerId: string | null },
|
||||
): Promise<RenderedEmail | null> {
|
||||
notification: {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
businessId: string;
|
||||
appointmentId: string | null;
|
||||
loyaltyRewardId: string | null;
|
||||
subject: string | null;
|
||||
bodyText: string;
|
||||
deepLinkPath: string | null;
|
||||
recipientUserId: string | null;
|
||||
recipientCustomerId: string | null;
|
||||
},
|
||||
): Promise<RenderedEmailWithSender | null> {
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: notification.businessId },
|
||||
include: { locations: true, settings: true },
|
||||
});
|
||||
if (!business) return null;
|
||||
|
||||
const sender: EmailSender = { name: resolveSenderName(business.name) };
|
||||
|
||||
const businessInfo = {
|
||||
name: business.name,
|
||||
logoUrl: business.logoUrl ?? undefined,
|
||||
@@ -39,47 +73,58 @@ export async function renderEmailForNotification(
|
||||
};
|
||||
|
||||
const recipientName = await resolveRecipientName(prisma, notification);
|
||||
const rendered = await renderBody();
|
||||
return { ...rendered, sender };
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.BOOKING_CONFIRMED:
|
||||
case NotificationType.BOOKING_PENDING_APPROVAL:
|
||||
case NotificationType.BOOKING_RESCHEDULED:
|
||||
case NotificationType.BOOKING_CANCELLED:
|
||||
case NotificationType.BOOKING_REMINDER: {
|
||||
const data = await buildAppointmentEmailData(prisma, notification, {
|
||||
async function renderBody(): Promise<RenderedEmail> {
|
||||
switch (notification.type) {
|
||||
case NotificationType.BOOKING_CONFIRMED:
|
||||
case NotificationType.BOOKING_PENDING_APPROVAL:
|
||||
case NotificationType.BOOKING_RESCHEDULED:
|
||||
case NotificationType.BOOKING_CANCELLED:
|
||||
case NotificationType.BOOKING_REMINDER: {
|
||||
const data = await buildAppointmentEmailData(prisma, notification, {
|
||||
business: businessInfo,
|
||||
recipientName,
|
||||
manageAppointmentPath: notification.deepLinkPath ?? "/appointments",
|
||||
});
|
||||
if (!data) return renderFallback();
|
||||
if (notification.type === NotificationType.BOOKING_CONFIRMED)
|
||||
return renderBookingConfirmedEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_PENDING_APPROVAL)
|
||||
return renderBookingPendingApprovalEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_RESCHEDULED)
|
||||
return renderBookingRescheduledEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_CANCELLED)
|
||||
return renderBookingCancelledEmail(data);
|
||||
return renderBookingReminderEmail(data);
|
||||
}
|
||||
|
||||
case NotificationType.LOYALTY_REWARD_EARNED:
|
||||
case NotificationType.LOYALTY_REWARD_REDEEMED: {
|
||||
const data = await buildLoyaltyRewardEmailData(prisma, notification, {
|
||||
business: businessInfo,
|
||||
recipientName,
|
||||
});
|
||||
if (!data) return renderFallback();
|
||||
return notification.type === NotificationType.LOYALTY_REWARD_EARNED
|
||||
? renderLoyaltyRewardEarnedEmail(data)
|
||||
: renderLoyaltyRewardRedeemedEmail(data);
|
||||
}
|
||||
|
||||
default:
|
||||
return renderFallback();
|
||||
}
|
||||
|
||||
function renderFallback(): RenderedEmail {
|
||||
return renderGenericNotificationEmail({
|
||||
business: businessInfo,
|
||||
recipientName,
|
||||
manageAppointmentPath: notification.deepLinkPath ?? "/appointments",
|
||||
subject: notification.subject ?? businessInfo.name,
|
||||
bodyText: notification.bodyText,
|
||||
deepLinkPath: notification.deepLinkPath ?? undefined,
|
||||
});
|
||||
if (!data) return renderFallback();
|
||||
if (notification.type === NotificationType.BOOKING_CONFIRMED) return renderBookingConfirmedEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_PENDING_APPROVAL) return renderBookingPendingApprovalEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_RESCHEDULED) return renderBookingRescheduledEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_CANCELLED) return renderBookingCancelledEmail(data);
|
||||
return renderBookingReminderEmail(data);
|
||||
}
|
||||
|
||||
case NotificationType.LOYALTY_REWARD_EARNED:
|
||||
case NotificationType.LOYALTY_REWARD_REDEEMED: {
|
||||
const data = await buildLoyaltyRewardEmailData(prisma, notification, { business: businessInfo, recipientName });
|
||||
if (!data) return renderFallback();
|
||||
return notification.type === NotificationType.LOYALTY_REWARD_EARNED
|
||||
? renderLoyaltyRewardEarnedEmail(data)
|
||||
: renderLoyaltyRewardRedeemedEmail(data);
|
||||
}
|
||||
|
||||
default:
|
||||
return renderFallback();
|
||||
}
|
||||
|
||||
function renderFallback(): RenderedEmail {
|
||||
return renderGenericNotificationEmail({
|
||||
business: businessInfo,
|
||||
recipientName,
|
||||
subject: notification.subject ?? businessInfo.name,
|
||||
bodyText: notification.bodyText,
|
||||
deepLinkPath: notification.deepLinkPath ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +140,10 @@ async function resolveRecipientName(
|
||||
if (customer) return `${customer.firstName} ${customer.lastName}`.trim();
|
||||
}
|
||||
if (notification.recipientUserId) {
|
||||
const user = await prisma.user.findUnique({ where: { id: notification.recipientUserId }, select: { name: true } });
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: notification.recipientUserId },
|
||||
select: { name: true },
|
||||
});
|
||||
if (user?.name) return user.name;
|
||||
}
|
||||
return "there";
|
||||
@@ -113,15 +161,28 @@ async function buildAppointmentEmailData(
|
||||
});
|
||||
if (!appointment) return null;
|
||||
|
||||
const totalPrice = appointment.services.reduce((sum: number, s: { priceAtBooking: Prisma.Decimal }) => sum + Number(s.priceAtBooking), 0);
|
||||
const totalMinutes = appointment.services.reduce((sum: number, s: { durationAtBookingMinutes: number }) => sum + s.durationAtBookingMinutes, 0);
|
||||
const totalPrice = appointment.services.reduce(
|
||||
(sum: number, s: { priceAtBooking: Prisma.Decimal }) => sum + Number(s.priceAtBooking),
|
||||
0,
|
||||
);
|
||||
const totalMinutes = appointment.services.reduce(
|
||||
(sum: number, s: { durationAtBookingMinutes: number }) => sum + s.durationAtBookingMinutes,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
staffName: appointment.staff.user.name ?? "your barber",
|
||||
serviceNames: appointment.services.map((s: { service: { name: string } }) => s.service.name),
|
||||
dateLabel: appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }),
|
||||
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
|
||||
dateLabel: appointment.startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
}),
|
||||
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
durationLabel: `${totalMinutes} min`,
|
||||
totalLabel: `£${totalPrice.toFixed(2)}`,
|
||||
appointmentRef: appointment.id.slice(-8),
|
||||
@@ -134,7 +195,9 @@ async function buildLoyaltyRewardEmailData(
|
||||
base: Pick<LoyaltyRewardEmailData, "business" | "recipientName">,
|
||||
): Promise<LoyaltyRewardEmailData | null> {
|
||||
if (!notification.loyaltyRewardId) return null;
|
||||
const reward = await prisma.loyaltyReward.findUnique({ where: { id: notification.loyaltyRewardId } });
|
||||
const reward = await prisma.loyaltyReward.findUnique({
|
||||
where: { id: notification.loyaltyRewardId },
|
||||
});
|
||||
if (!reward) return null;
|
||||
|
||||
return {
|
||||
@@ -142,7 +205,11 @@ async function buildLoyaltyRewardEmailData(
|
||||
rewardLabel: reward.label,
|
||||
rewardDescription: reward.label,
|
||||
expiresLabel: reward.expiresAt
|
||||
? reward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
|
||||
? reward.expiresAt.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
: undefined,
|
||||
loyaltyCardPath: "/home",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Resolves the visible email sender *name* — pure and DB-free (the caller,
|
||||
* render-email.ts, has already loaded the business); kept as its own tiny
|
||||
* module so the priority order is independently unit-testable. Never
|
||||
* returns the internal product name. See
|
||||
* docs/phase-8-email-sender-summary.md for the full priority-order
|
||||
* rationale.
|
||||
*/
|
||||
|
||||
const NEUTRAL_FALLBACK_SENDER_NAME = "Booking Notifications";
|
||||
|
||||
/**
|
||||
* Priority: 1) the installed business's own name, 2) EMAIL_FROM_NAME as an
|
||||
* emergency-only fallback (never required to be kept in sync with the
|
||||
* business name — see .env.example), 3) a neutral, non-product name. A
|
||||
* normally-installed application always resolves tier 1 — every
|
||||
* Notification is business-scoped (see prisma/schema.prisma,
|
||||
* Notification.businessId is required) — so tiers 2/3 exist purely as
|
||||
* defensive fallbacks for a business row somehow having a blank name, or
|
||||
* for a future notification type with no business association at all.
|
||||
*/
|
||||
export function resolveSenderName(businessName: string | null | undefined): string {
|
||||
const trimmedBusinessName = businessName?.trim();
|
||||
if (trimmedBusinessName) return trimmedBusinessName;
|
||||
|
||||
const envFallback = process.env.EMAIL_FROM_NAME?.trim();
|
||||
if (envFallback) return envFallback;
|
||||
|
||||
return NEUTRAL_FALLBACK_SENDER_NAME;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function renderStaffInvitationEmail(data: StaffInvitationEmailData): Rend
|
||||
const bodyHtml = [
|
||||
paragraph(`Hi ${data.staffName},`),
|
||||
paragraph(
|
||||
`${data.invitedByName} has added you as ${data.roleName === "Owner" ? "an" : "a"} ${data.roleName} at ${data.business.name} on NextChair. Set your password to get started.`,
|
||||
`${data.invitedByName} has added you as ${data.roleName === "Owner" ? "an" : "a"} ${data.roleName} at ${data.business.name}. Set your password to get started.`,
|
||||
),
|
||||
].join("");
|
||||
|
||||
@@ -37,7 +37,7 @@ export function renderStaffInvitationEmail(data: StaffInvitationEmailData): Rend
|
||||
bodyLines: [
|
||||
`Hi ${data.staffName},`,
|
||||
"",
|
||||
`${data.invitedByName} has added you as a ${data.roleName} at ${data.business.name} on NextChair. Set your password to get started.`,
|
||||
`${data.invitedByName} has added you as a ${data.roleName} at ${data.business.name}. Set your password to get started.`,
|
||||
],
|
||||
actionLabel: "Set Your Password",
|
||||
actionUrl,
|
||||
|
||||
Reference in new issue
Block a user