Files
NextChair/src/features/owner/actions/business.ts
T
Stefan Lazic 68a62e02a4
CI / ci (push) Canceled after 0s
Remaining features
Built
No-show flag and block:
Two new numbers under Booking Rules (blank means off).
A symbol appears on the clients list, client page, calendar entries and appointment pages.
Online booking is refused at the block number, and customers are told at the details step.
Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button.
The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile.
Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning.
Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email.
WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting.
Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business.
Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
2026-10-17 23:10:18 +01:00

400 lines
15 KiB
TypeScript

"use server";
import JSZip from "jszip";
import { imageSize } from "image-size";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { isSuperAdmin } from "@/lib/auth/permissions";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { parseSplashScreens } from "@/lib/business/branding";
import type { SplashScreenEntry } from "@/lib/business/branding";
import { MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
import { createMediaAsset } from "@/services/media/media.service";
import { MAX_WHATSAPP_LEAD_HOURS, validateWhatsAppLeadHours } from "@/services/scheduling/whatsapp-reminders.service";
import {
setOpeningHours,
updateAppIconSettings,
updateBusinessProfile,
updateBusinessSettings,
updateNavigationSettings,
} from "@/services/business/business.service";
import type {
AppIconSettingsInput,
BusinessLocationInput,
BusinessProfileInput,
BusinessSettingsInput,
NavigationSettingsInput,
OpeningHoursDayInput,
} from "@/services/business/business.service";
export interface BusinessActionResult {
success: boolean;
error?: string;
}
/**
* Uses getActingBusinessContext (not a raw StaffProfile lookup) so a
* Super Administrator — who has no StaffProfile by design, see
* docs/phase-9-summary.md — can manage Business Branding too.
*/
async function requireBusinessManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
throw error;
}
const context = await getActingBusinessContext();
if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
}
/**
* Stricter than requireBusinessManager above — the App Icon & Home
* Screen settings are Super Administrator only (not Owner), unlike every
* other business-profile action in this file. Re-checked here rather
* than trusting the page-level redirect, same "enforce again at the
* action layer" rule the rest of the app follows.
*/
async function requireSuperAdminBusinessManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
if (!(await isSuperAdmin(session.user.id))) {
return { staff: null, error: "Only a Super Administrator can change these settings." } as const;
}
const context = await getActingBusinessContext();
if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
}
export async function updateBusinessProfileAction(params: {
profile: BusinessProfileInput;
location: BusinessLocationInput;
}): Promise<BusinessActionResult> {
const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
const result = await updateBusinessProfile(prisma, { businessId: staff.businessId, ...params });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save business details." };
}
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_PROFILE_UPDATED",
entityType: "Business",
entityId: staff.businessId,
metadata: { name: params.profile.name },
});
return { success: true };
}
export async function updateBusinessSettingsAction(settings: BusinessSettingsInput): Promise<BusinessActionResult> {
const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
if (settings.whatsappReminderLeadHours !== undefined && !validateWhatsAppLeadHours(settings.whatsappReminderLeadHours)) {
return { success: false, error: `WhatsApp reminders can be due between 1 and ${MAX_WHATSAPP_LEAD_HOURS} hours ahead.` };
}
await updateBusinessSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_SETTINGS_UPDATED",
entityType: "BusinessSettings",
entityId: staff.businessId,
});
return { success: true };
}
export async function updateAppIconSettingsAction(settings: AppIconSettingsInput): Promise<BusinessActionResult> {
const { staff, error } = await requireSuperAdminBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
await updateAppIconSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_APP_ICON_SETTINGS_UPDATED",
entityType: "Business",
entityId: staff.businessId,
});
return { success: true };
}
/** Icon+label vs icon-only bottom navigation — Super Administrator only, same rationale as updateAppIconSettingsAction (a change here is visible to every signed-in user immediately, not scoped to the actor). */
export async function updateNavigationSettingsAction(
settings: NavigationSettingsInput,
): Promise<BusinessActionResult> {
const { staff, error } = await requireSuperAdminBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
await updateNavigationSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_NAVIGATION_SETTINGS_UPDATED",
entityType: "Business",
entityId: staff.businessId,
metadata: { bottomNavShowLabels: settings.bottomNavShowLabels },
});
return { success: true };
}
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function isPngBuffer(buffer: Buffer): boolean {
return buffer.length >= PNG_SIGNATURE.length && buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE);
}
/**
* Extracts `<link rel="apple-touch-startup-image" media="..." href="...">`
* tags from a generator's readme (e.g. progressier.com's PWA icon/splash
* bundle) — attribute-order-agnostic within each tag, since the only
* contract that matters is "this is an apple-touch-startup-image link
* with a media query and an href", not the exact source formatting.
*/
function parseAppleTouchStartupImageTags(readmeText: string): { media: string; hrefBasename: string }[] {
const tags = readmeText.match(/<link\b[^>]*rel="apple-touch-startup-image"[^>]*>/gi) ?? [];
const results: { media: string; hrefBasename: string }[] = [];
for (const tag of tags) {
const media = tag.match(/media="([^"]*)"/i)?.[1];
const href = tag.match(/href="([^"]*)"/i)?.[1];
if (!media || !href) continue;
results.push({ media, hrefBasename: href.split("/").pop() ?? href });
}
return results;
}
/** Validates, stores, and records one extracted ZIP image — null on anything that fails validation (caller turns that into a warning, not a hard failure). */
async function uploadZipImage(
entry: JSZip.JSZipObject,
staff: { businessId: string; userId: string },
): Promise<string | null> {
const buffer = Buffer.from(await entry.async("nodebuffer"));
if (!isPngBuffer(buffer) || buffer.length > MEDIA_MAX_UPLOAD_BYTES) return null;
let width: number | null = null;
let height: number | null = null;
try {
const dimensions = imageSize(buffer);
width = dimensions.width ?? null;
height = dimensions.height ?? null;
} catch {
// Best-effort — an unrecognised/corrupt header shouldn't block the upload.
}
const stored = await storeMediaFile({ buffer, mimeType: "image/png", category: "branding" });
await createMediaAsset(prisma, {
businessId: staff.businessId,
url: stored.url,
originalFilename: entry.name.split("/").pop() ?? null,
mimeType: "image/png",
sizeBytes: buffer.length,
width,
height,
category: "branding",
uploadedByUserId: staff.userId,
});
return stored.url;
}
export interface ImportAppIconZipResult {
success: boolean;
error?: string;
warnings?: string[];
applied?: {
appIconUrl: string | null;
icon192Url: string | null;
icon512Url: string | null;
maskableIcon192Url: string | null;
maskableIcon512Url: string | null;
splashScreens: SplashScreenEntry[];
};
}
/**
* Accepts a ZIP from a PWA icon/splash-screen generator (e.g.
* progressier.com) — extracts `icon.png` (applied to the Apple touch icon
* AND all four Android manifest icon fields, since the generator only
* produces one square source image) and every splash screen referenced
* by `readme.txt`'s `apple-touch-startup-image` tags (see
* parseAppleTouchStartupImageTags), each uploaded through the same media
* pipeline as a manual MediaPicker upload. Partial-success by design: a
* missing readme, a missing icon, or a handful of unreadable entries
* degrade to warnings rather than aborting the whole import — only "found
* nothing at all usable" is a hard failure.
*/
export async function importAppIconZipAction(formData: FormData): Promise<ImportAppIconZipResult> {
const { staff, error } = await requireSuperAdminBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
const file = formData.get("file");
if (!(file instanceof File)) return { success: false, error: "No file provided." };
if (!file.name.toLowerCase().endsWith(".zip")) {
return { success: false, error: "Please upload a .zip file." };
}
let zip: JSZip;
try {
zip = await JSZip.loadAsync(Buffer.from(await file.arrayBuffer()));
} catch {
return { success: false, error: "Couldn't read that file as a ZIP archive." };
}
const entries = Object.values(zip.files).filter((entry) => !entry.dir);
const entryByBasename = new Map(entries.map((entry) => [entry.name.split("/").pop() ?? entry.name, entry]));
const warnings: string[] = [];
const iconEntry = entries.find((entry) => /^icon\.png$/i.test(entry.name.split("/").pop() ?? ""));
let iconUrl: string | null = null;
if (iconEntry) {
iconUrl = await uploadZipImage(iconEntry, staff);
if (!iconUrl) warnings.push("Couldn't import icon.png — it may be too large or not a valid PNG.");
} else {
warnings.push("No icon.png found in the ZIP — icon fields were left unchanged.");
}
const readmeEntry = entries.find((entry) => (entry.name.split("/").pop() ?? "").toLowerCase() === "readme.txt");
const splashScreens: SplashScreenEntry[] = [];
if (readmeEntry) {
const tags = parseAppleTouchStartupImageTags(await readmeEntry.async("text"));
if (tags.length === 0) {
warnings.push("readme.txt was found but contained no apple-touch-startup-image tags.");
}
for (const { media, hrefBasename } of tags) {
const imageEntry = entryByBasename.get(hrefBasename);
if (!imageEntry) {
warnings.push(`Referenced splash image "${hrefBasename}" wasn't found in the ZIP — skipped.`);
continue;
}
const url = await uploadZipImage(imageEntry, staff);
if (url) {
splashScreens.push({ media, url });
} else {
warnings.push(`Couldn't import "${hrefBasename}" — skipped.`);
}
}
} else {
warnings.push("No readme.txt found in the ZIP — splash screens were left unchanged.");
}
if (!iconUrl && splashScreens.length === 0) {
return {
success: false,
error:
"Nothing usable was found in that ZIP — expected an icon.png and/or a readme.txt with apple-touch-startup-image tags.",
warnings,
};
}
const settings: AppIconSettingsInput = {};
if (iconUrl) {
settings.appIconUrl = iconUrl;
settings.icon192Url = iconUrl;
settings.icon512Url = iconUrl;
settings.maskableIcon192Url = iconUrl;
settings.maskableIcon512Url = iconUrl;
}
if (splashScreens.length > 0) settings.splashScreens = splashScreens;
await updateAppIconSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_APP_ICONS_IMPORTED_FROM_ZIP",
entityType: "Business",
entityId: staff.businessId,
metadata: { iconImported: !!iconUrl, splashScreenCount: splashScreens.length, warningCount: warnings.length },
});
const current = await prisma.business.findUnique({
where: { id: staff.businessId },
select: {
appIconUrl: true,
icon192Url: true,
icon512Url: true,
maskableIcon192Url: true,
maskableIcon512Url: true,
splashScreens: true,
},
});
return {
success: true,
warnings: warnings.length > 0 ? warnings : undefined,
applied: {
appIconUrl: current?.appIconUrl ?? null,
icon192Url: current?.icon192Url ?? null,
icon512Url: current?.icon512Url ?? null,
maskableIcon192Url: current?.maskableIcon192Url ?? null,
maskableIcon512Url: current?.maskableIcon512Url ?? null,
splashScreens: parseSplashScreens(current?.splashScreens),
},
};
}
export interface OpeningHoursActionResult {
success: boolean;
error?: string;
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string; staffName: string }[];
}
export async function updateOpeningHoursAction(
days: OpeningHoursDayInput[],
acknowledgeConflicts?: boolean,
): Promise<OpeningHoursActionResult> {
const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
const business = await prisma.business.findUnique({ where: { id: staff.businessId }, select: { timezone: true } });
if (!business) return { success: false, error: "Business not found." };
const result = await setOpeningHours(prisma, {
businessId: staff.businessId,
businessTimezone: business.timezone,
days,
acknowledgeConflicts,
});
if (!result.success) {
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't save opening hours.",
conflicts: result.conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
staffName: c.staff.user.name ?? "Staff",
})),
};
}
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_HOURS_UPDATED",
entityType: "BusinessHours",
entityId: staff.businessId,
});
return { success: true };
}