Post Phase 8 - Fixes 1

Delivered:

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

No files matched your search

+20
View File
@@ -26,7 +26,27 @@ export const getStaffPermissions = cache(async (userId: string): Promise<Set<Per
return new Set(staffProfile.role.permissions.map((rolePermission) => rolePermission.permission));
});
/**
* Phase 9 — the platform-level Super Administrator (see
* scripts/ensure-super-admin.ts). Deliberately re-checks the database
* rather than trusting the JWT, same rationale as getStaffPermissions.
*/
export const isSuperAdmin = cache(async (userId: string): Promise<boolean> => {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { isSuperAdmin: true } });
return user?.isSuperAdmin ?? false;
});
/**
* A Super Administrator has every permission an Owner has — OWNER_PERMISSIONS
* (src/lib/roles/default-permissions.ts) already IS the full Permission enum,
* so short-circuiting true here rather than granting a StaffProfile/Role is
* exactly equivalent, without giving the Super Admin a StaffProfile row at
* all (see docs/phase-9-summary.md, "Super Administrator" — this is what
* keeps them out of the Team list, barber picker, and appointment FKs
* structurally rather than via a filter).
*/
export async function hasPermission(userId: string, permission: Permission): Promise<boolean> {
if (await isSuperAdmin(userId)) return true;
const permissions = await getStaffPermissions(userId);
return permissions.has(permission);
}
+9
View File
@@ -36,6 +36,9 @@ export interface BrandingInfo {
appIconUrl: string | null;
themeColor: string;
backgroundColor: string;
/** 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;
}
@@ -55,6 +58,8 @@ const UNCONFIGURED_BRANDING: BrandingInfo = {
appIconUrl: null,
themeColor: DEFAULT_THEME_COLOR,
backgroundColor: DEFAULT_BACKGROUND_COLOR,
welcomeBackgroundDesktopUrl: null,
welcomeBackgroundMobileUrl: null,
email: null,
phone: null,
};
@@ -80,6 +85,8 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
appIconUrl: true,
themeColor: true,
backgroundColor: true,
welcomeBackgroundDesktopUrl: true,
welcomeBackgroundMobileUrl: true,
email: true,
phone: true,
},
@@ -100,6 +107,8 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
appIconUrl: business.appIconUrl,
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
backgroundColor: business.backgroundColor || DEFAULT_BACKGROUND_COLOR,
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
email: business.email,
phone: business.phone,
};
@@ -0,0 +1,51 @@
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: { businessId: true, business: { select: { name: true, timezone: true } } },
});
if (staffProfile) {
return {
userId: session.user.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,
businessId: business.id,
businessName: business.name,
businessTimezone: business.timezone,
};
});
export type ActingBusinessContext = NonNullable<Awaited<ReturnType<typeof getActingBusinessContext>>>;
+104
View File
@@ -0,0 +1,104 @@
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faAward,
faBell,
faBolt,
faBriefcase,
faBrush,
faCalendarCheck,
faCamera,
faCheck,
faCircleCheck,
faClock,
faCoffee,
faCompass,
faCrown,
faDiamond,
faFire,
faGem,
faGift,
faGlassCheers,
faHandSparkles,
faHatCowboy,
faHeart,
faLeaf,
faLightbulb,
faMedal,
faMugSaucer,
faPaintbrush,
faRibbon,
faScissors,
faShieldHeart,
faSprayCanSparkles,
faStamp,
faStar,
faStarHalfStroke,
faSun,
faTag,
faThumbsUp,
faTicket,
faTrophy,
faUserTie,
faWandMagicSparkles,
} from "@fortawesome/free-solid-svg-icons";
/**
* Curated, not the full ~2000-icon Font Awesome solid set — deliberately:
* a searchable picker over every icon Font Awesome ships (razors,
* spreadsheets, ambulances...) is a worse experience for "pick a loyalty
* stamp icon" than a relevant shortlist, and named imports here (rather
* than `import { fas } from "..."`) keep only these icons' path data in
* the bundle — this component is only ever loaded on the owner Loyalty
* settings page, never the customer bundle, but there's no reason to
* bundle 2000 unused icons regardless. Add to this list as new use cases
* come up; nothing about IconPicker itself is tied to this specific set.
*/
export const ICON_CATALOG: IconDefinition[] = [
faScissors,
faStar,
faStarHalfStroke,
faHeart,
faGift,
faCrown,
faGem,
faDiamond,
faAward,
faMedal,
faTrophy,
faRibbon,
faStamp,
faTag,
faTicket,
faThumbsUp,
faCheck,
faCircleCheck,
faFire,
faBolt,
faSun,
faLeaf,
faCoffee,
faMugSaucer,
faGlassCheers,
faHandSparkles,
faSprayCanSparkles,
faWandMagicSparkles,
faBrush,
faPaintbrush,
faCamera,
faHatCowboy,
faUserTie,
faBriefcase,
faShieldHeart,
faLightbulb,
faCompass,
faClock,
faCalendarCheck,
faBell,
];
const ICON_BY_NAME = new Map<string, IconDefinition>(ICON_CATALOG.map((icon) => [icon.iconName, icon]));
/** Falls back to a neutral default for anything not in the catalogue — covers legacy free-text values from before this picker existed. */
export function findCatalogIcon(iconName: string | null | undefined): IconDefinition {
return (iconName && ICON_BY_NAME.get(iconName)) || faScissors;
}
+68
View File
@@ -0,0 +1,68 @@
import { randomUUID } from "node:crypto";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Local-disk storage for the Media Manager (see src/services/media) —
* files live under public/media/<category>/..., served for free by
* Next's own static file handling for public/ (no custom GET route
* needed). A deliberate v1 choice for this app's single-container Docker
* deployment model, not a cloud object-storage integration — see
* docker-compose.local.yaml's media volume for why this survives a
* container restart. Swapping to S3-compatible storage later only means
* changing this one file; every caller only ever sees a public URL.
*
* SVG is deliberately not in the allow-list — an uploaded SVG can embed
* <script>, making it a stored-XSS vector if ever served inline.
*/
const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
};
export const MEDIA_MAX_UPLOAD_BYTES = Number(process.env.MEDIA_MAX_UPLOAD_BYTES ?? 5_000_000);
const MEDIA_ROOT = path.join(process.cwd(), "public", "media");
const MEDIA_URL_PREFIX = "/media/";
export function isAllowedMediaMimeType(mimeType: string): boolean {
return mimeType in ALLOWED_MIME_EXTENSIONS;
}
export interface StoredMediaFile {
/** Public path — what gets saved on MediaAsset.url and rendered directly. */
url: string;
}
function sanitizeCategory(category: string): string {
return category.replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "other";
}
export async function storeMediaFile(params: {
buffer: Buffer;
mimeType: string;
category: string;
}): Promise<StoredMediaFile> {
const extension = ALLOWED_MIME_EXTENSIONS[params.mimeType];
if (!extension) throw new Error(`Unsupported media type: ${params.mimeType}`);
const safeCategory = sanitizeCategory(params.category);
const filename = `${randomUUID()}.${extension}`;
const dir = path.join(MEDIA_ROOT, safeCategory);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, filename), params.buffer);
return { url: `${MEDIA_URL_PREFIX}${safeCategory}/${filename}` };
}
/** Best-effort — a missing file (already deleted, or a URL that was never ours) never blocks removing the DB row. */
export async function deleteMediaFile(url: string): Promise<void> {
if (!url.startsWith(MEDIA_URL_PREFIX)) return;
const relative = url.slice(MEDIA_URL_PREFIX.length);
const absolutePath = path.join(MEDIA_ROOT, relative);
// Path-traversal guard — a corrupted/crafted url should never resolve outside MEDIA_ROOT.
if (!absolutePath.startsWith(MEDIA_ROOT + path.sep)) return;
await unlink(absolutePath).catch(() => {});
}
+2
View File
@@ -25,6 +25,8 @@ export const OWNER_PERMISSIONS: Permission[] = [
Permission.VIEW_CUSTOMER_DIRECTORY,
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
Permission.VIEW_REPORTING,
Permission.MANAGE_MEDIA_LIBRARY,
Permission.MANAGE_REVIEWS,
];
export const BARBER_PERMISSIONS: Permission[] = [