"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 { 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 { 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 { 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 { 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 `` * 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(/]*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 { 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 { 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 { 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 }; }