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

-394
View File
@@ -1,394 +0,0 @@
"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 {
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." };
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 };
}