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

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
690 files changed
+33944 -12046

No files matched your search

-166
View File
@@ -1,166 +0,0 @@
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.
*/
/** One iOS `apple-touch-startup-image` entry — an exact per-device media query paired with its splash image, matching the shape a generator like progressier.com's ZIP bundle produces. See importAppIconZipAction. */
export interface SplashScreenEntry {
media: string;
url: string;
}
export interface BrandingInfo {
configured: boolean;
name: string;
shortName: string;
description: string | null;
logoUrl: string | null;
coverImageUrl: string | null;
faviconUrl: string | null;
/** Apple touch icon (iOS "Add to Home Screen") — distinct from the manifest icon fields below, which Android/Chrome installs read instead. */
appIconUrl: string | null;
themeColor: string;
backgroundColor: string;
/** PWA manifest `icons` array — see src/app/manifest.ts. Each falls back independently to the app's own generated icon when unset. */
icon192Url: string | null;
icon512Url: string | null;
maskableIcon192Url: string | null;
maskableIcon512Url: string | null;
/** iOS `apple-touch-startup-image` splash screens — see src/app/layout.tsx. */
splashScreens: SplashScreenEntry[];
/** Icon-only vs icon+label bottom navigation — see components/navigation/bottom-nav.tsx. */
bottomNavShowLabels: boolean;
/** Welcome page (/) background — see src/app/page.tsx. Independent images, not one image cropped two ways. */
welcomeBackgroundDesktopUrl: string | null;
welcomeBackgroundMobileUrl: string | null;
email: string | null;
phone: string | null;
}
/** Defensive parse of the `Business.splashScreens` JSON column — never trusts its shape at compile time. Drops any entry missing a string `media`/`url` rather than throwing. */
export function parseSplashScreens(value: unknown): SplashScreenEntry[] {
if (!Array.isArray(value)) return [];
return value.filter(
(entry): entry is SplashScreenEntry =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as Record<string, unknown>).media === "string" &&
typeof (entry as Record<string, unknown>).url === "string",
);
}
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,
icon192Url: null,
icon512Url: null,
maskableIcon192Url: null,
maskableIcon512Url: null,
splashScreens: [],
bottomNavShowLabels: true,
welcomeBackgroundDesktopUrl: null,
welcomeBackgroundMobileUrl: null,
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 env vars). 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,
icon192Url: true,
icon512Url: true,
maskableIcon192Url: true,
maskableIcon512Url: true,
splashScreens: true,
bottomNavShowLabels: true,
welcomeBackgroundDesktopUrl: true,
welcomeBackgroundMobileUrl: 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,
icon192Url: business.icon192Url,
icon512Url: business.icon512Url,
maskableIcon192Url: business.maskableIcon192Url,
maskableIcon512Url: business.maskableIcon512Url,
splashScreens: parseSplashScreens(business.splashScreens),
bottomNavShowLabels: business.bottomNavShowLabels,
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
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}`;
}
@@ -1,57 +0,0 @@
import { cache } from "react";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { isSuperAdmin } from "@/lib/auth/permissions";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
/**
* Resolves "which business is the signed-in user acting on behalf of" for
* pages/actions that only need the business id/name/timezone — not full
* staff identity (see getActingStaff for that, which stays the right
* choice for anything that needs a real StaffProfile id, e.g. loyalty
* stamp authorship or appointment reassignment).
*
* A regular staff member/owner resolves via their StaffProfile, same as
* getActingStaff. A Super Administrator (see scripts/ensure-super-admin.ts)
* deliberately has no StaffProfile at all — that's what keeps them out of
* the Team list/barber picker/appointment FKs by construction rather than
* a filter — so they resolve straight to the app's one business instead
* (v1 is single-business, same assumption getPrimaryBusiness() already
* makes everywhere else). Returns null only for a signed-in user who is
* neither staff nor a super admin.
*/
export const getActingBusinessContext = cache(async () => {
const session = await auth();
if (!session?.user?.id) return null;
const staffProfile = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
select: { id: true, businessId: true, business: { select: { name: true, timezone: true } } },
});
if (staffProfile) {
return {
userId: session.user.id,
staffId: staffProfile.id,
businessId: staffProfile.businessId,
businessName: staffProfile.business.name,
businessTimezone: staffProfile.business.timezone,
};
}
if (!(await isSuperAdmin(session.user.id))) return null;
const business = await getPrimaryBusiness();
return {
userId: session.user.id,
// A Super Administrator has no StaffProfile by design (see isSuperAdmin) —
// null here is what lets callers tell "acting as real staff" apart from
// "acting as a Super Administrator with no personal staff identity",
// e.g. to withhold note authorship (see CustomerNotes' actingStaffId).
staffId: null,
businessId: business.id,
businessName: business.name,
businessTimezone: business.timezone,
};
});
export type ActingBusinessContext = NonNullable<Awaited<ReturnType<typeof getActingBusinessContext>>>;
-21
View File
@@ -1,21 +0,0 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
/**
* v1 is single-business (see docs/implementation-plan.md §11) — every
* page that needs "the business" fetches it through here rather than
* hardcoding a slug or re-querying independently. Wrapped in React's
* `cache()` so multiple Server Components in the same request (a layout
* and its page, for instance) share one query instead of issuing it
* once each.
*/
export const getPrimaryBusiness = cache(async () => {
return prisma.business.findFirstOrThrow({
include: {
locations: true,
hours: true,
settings: true,
bookingRule: true,
},
});
});
-45
View File
@@ -1,45 +0,0 @@
import { minutesFromMidnight, todayIn, weekdayOf } from "@/lib/time/business-time";
import { Weekday } from "@/generated/prisma/enums";
export interface OpeningStatus {
isOpen: boolean;
label: string;
}
interface DayHoursRow {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes: number | null;
closesAtMinutes: number | null;
}
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/**
* Purely presentational (read-only "open now?" label for the home page)
* — not part of the booking engine's own open/closed check, which
* AvailabilityService already performs independently as a real booking
* rule. This is display-only, so it lives here rather than in
* services/scheduling.
*/
export function computeOpeningStatus(hours: DayHoursRow[], timezone: string): OpeningStatus {
const today = todayIn(timezone);
const weekday = weekdayOf(today);
const todayHours = hours.find((h) => h.dayOfWeek === weekday);
if (!todayHours || todayHours.isClosed || todayHours.opensAtMinutes === null || todayHours.closesAtMinutes === null) {
return { isOpen: false, label: "Closed today" };
}
const nowMinutes = minutesFromMidnight(new Date(), timezone);
const isOpen = nowMinutes >= todayHours.opensAtMinutes && nowMinutes < todayHours.closesAtMinutes;
return {
isOpen,
label: isOpen ? `Open until ${formatMinutes(todayHours.closesAtMinutes)}` : `Closed · Opens ${formatMinutes(todayHours.opensAtMinutes)}`,
};
}