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:
79 files changed
+4121
-450
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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>>>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in new issue
Block a user