Owner Administration is complete. Summary of this final stretch of work: Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB. Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres: Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation). Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres. 12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively. Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected. Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
122 lines
4.1 KiB
TypeScript
122 lines
4.1 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { prisma } from "@/lib/db/prisma";
|
|
import { Permission } from "@/generated/prisma/enums";
|
|
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
|
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
|
import {
|
|
setOpeningHours,
|
|
updateBusinessProfile,
|
|
updateBusinessSettings,
|
|
} from "@/services/business/business.service";
|
|
import type { BusinessLocationInput, BusinessProfileInput, BusinessSettingsInput, OpeningHoursDayInput } from "@/services/business/business.service";
|
|
|
|
export interface BusinessActionResult {
|
|
success: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
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 staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
|
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
|
|
|
return { staff, 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 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 };
|
|
}
|