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.
This commit is contained in:
Stefan Lazic committed 2026-08-03 12:56:55 +01:00
1 parent ffee9d6754
commit ec5583f972
73 files changed
+8400 -53

No files matched your search

+361 -1
View File
@@ -31,7 +31,10 @@ export type StaffServiceErrorCode =
| "BREAK_NOT_FOUND"
| "TIME_OFF_NOT_FOUND"
| "APPOINTMENT_CONFLICTS"
| "PERMISSION_DENIED";
| "PERMISSION_DENIED"
| "EMAIL_ALREADY_IN_USE"
| "ROLE_NOT_FOUND"
| "LAST_OWNER";
export class StaffPermissionDeniedError extends Error {
constructor(message = "You don't have permission to manage this staff member's schedule.") {
@@ -625,3 +628,360 @@ export async function setNotificationPreferences(
);
return { success: true };
}
// ── Owner: team management ─────────────────────────────────────────────
// Everything below acts on staff *other than* the caller — the owner's own
// business-membership must already be verified by the caller (see
// src/features/owner/authorization.ts); every function here additionally
// re-scopes by businessId itself rather than trusting the caller alone.
export async function listStaffForBusiness(
prisma: PrismaClient,
params: { businessId: string; q?: string; includeInactive?: boolean },
) {
return prisma.staffProfile.findMany({
where: {
businessId: params.businessId,
...(params.includeInactive ? {} : { status: { not: "INACTIVE" } }),
...(params.q
? {
user: {
OR: [
{ name: { contains: params.q, mode: "insensitive" } },
{ email: { contains: params.q, mode: "insensitive" } },
],
},
}
: {}),
},
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
role: { select: { id: true, name: true } },
invitation: true,
},
orderBy: { createdAt: "asc" },
});
}
export async function getStaffDetailById(prisma: PrismaClient, params: { staffId: string; businessId: string }) {
return prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
role: { include: { permissions: true } },
services: { include: { service: true } },
invitation: true,
},
});
}
export async function listRolesForBusiness(prisma: PrismaClient, businessId: string) {
return prisma.role.findMany({
where: { businessId },
include: { permissions: true },
orderBy: { name: "asc" },
});
}
/** A role holding MANAGE_STAFF is this app's definition of "an owner" — see docs/phase-6-summary.md, "Roles and permissions". */
async function countOtherActiveManagers(
prisma: PrismaClient,
params: { businessId: string; excludeStaffId: string },
) {
return prisma.staffProfile.count({
where: {
businessId: params.businessId,
status: "ACTIVE",
id: { not: params.excludeStaffId },
role: { permissions: { some: { permission: "MANAGE_STAFF" } } },
},
});
}
async function staffCurrentlyManagesStaff(prisma: PrismaClient, staffId: string): Promise<boolean> {
const staff = await prisma.staffProfile.findUnique({
where: { id: staffId },
select: { status: true, role: { select: { permissions: { select: { permission: true } } } } },
});
return (
!!staff &&
staff.status === "ACTIVE" &&
staff.role.permissions.some((p) => p.permission === "MANAGE_STAFF")
);
}
export interface CreateStaffInput {
name: string;
email: string;
phone?: string;
image?: string;
bio?: string;
roleId: string;
serviceIds: string[];
isBookableOnline: boolean;
}
export function validateCreateStaffInput(input: CreateStaffInput): ValidationResult<StaffServiceErrorCode> {
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Name is required." });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
return fail({ code: "INVALID_INPUT", message: "Enter a valid email address." });
}
if (!input.roleId) return fail({ code: "INVALID_INPUT", message: "Choose a role." });
return ok();
}
/** Creates the User + StaffProfile + initial service eligibility immediately — the profile is fully usable right away. Working hours default to "off every day" until the owner (or the staff member, once they've accepted) sets them; StaffInvitationService is what tracks acceptance, deliberately kept separate — see that file. */
export async function createStaffProfile(
prisma: PrismaClient,
params: { businessId: string; input: CreateStaffInput },
) {
const validation = validateCreateStaffInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const [role, existingUser] = await Promise.all([
prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } }),
prisma.user.findUnique({ where: { email: params.input.email.toLowerCase() } }),
]);
if (!role) {
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
}
if (existingUser) {
return {
success: false as const,
validation: fail({ code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." }),
};
}
const user = await prisma.user.create({
data: {
name: params.input.name.trim(),
email: params.input.email.toLowerCase(),
phone: params.input.phone || null,
image: params.input.image || null,
staffProfile: {
create: {
businessId: params.businessId,
roleId: params.input.roleId,
bio: params.input.bio?.trim() || null,
isBookableOnline: params.input.isBookableOnline,
services: { create: params.input.serviceIds.map((serviceId) => ({ serviceId })) },
},
},
},
include: { staffProfile: true },
});
return { success: true as const, staff: user.staffProfile!, validation: ok() };
}
export interface UpdateStaffAsOwnerInput {
name?: string;
phone?: string;
image?: string;
bio?: string;
isBookableOnline?: boolean;
roleId?: string;
}
export async function updateStaffAsOwner(
prisma: PrismaClient,
params: { staffId: string; businessId: string; input: UpdateStaffAsOwnerInput },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
});
if (!existing) {
return { success: false as const, validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }) };
}
if (params.input.roleId && params.input.roleId !== existing.roleId) {
const role = await prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } });
if (!role) {
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
}
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
const stillManagesAfter = !!(await prisma.rolePermission.findFirst({
where: { roleId: role.id, permission: "MANAGE_STAFF" },
}));
if (wasManager && !stillManagesAfter) {
const otherManagers = await countOtherActiveManagers(prisma, {
businessId: params.businessId,
excludeStaffId: params.staffId,
});
if (otherManagers === 0) {
return {
success: false as const,
validation: fail({
code: "LAST_OWNER",
message: "This is the only active owner — assign another owner first.",
}),
};
}
}
}
await prisma.$transaction([
prisma.user.update({
where: { id: existing.userId },
data: {
...(params.input.name !== undefined ? { name: params.input.name } : {}),
...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}),
...(params.input.image !== undefined ? { image: params.input.image || null } : {}),
},
}),
prisma.staffProfile.update({
where: { id: params.staffId },
data: {
...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}),
...(params.input.isBookableOnline !== undefined
? { isBookableOnline: params.input.isBookableOnline }
: {}),
...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}),
},
}),
]);
return { success: true as const, validation: ok() };
}
/** Future, non-cancelled appointments still assigned to this staff member — shown for acknowledgement before deactivating, never auto-resolved. Deactivating does not touch these rows; it only removes the staff member from future booking/availability. */
export async function findStaffDeactivationConflicts(
prisma: PrismaClient,
params: { staffId: string },
) {
return prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
},
include: { customer: true, services: { include: { service: true } } },
orderBy: { startAt: "asc" },
});
}
export async function deactivateStaff(
prisma: PrismaClient,
params: { staffId: string; businessId: string; acknowledgeConflicts?: boolean },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
conflicts: [],
};
}
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
if (wasManager) {
const otherManagers = await countOtherActiveManagers(prisma, {
businessId: params.businessId,
excludeStaffId: params.staffId,
});
if (otherManagers === 0) {
return {
success: false as const,
validation: fail({
code: "LAST_OWNER",
message: "This is the only active owner — assign another owner first.",
}),
conflicts: [],
};
}
}
if (!params.acknowledgeConflicts) {
const conflicts = await findStaffDeactivationConflicts(prisma, { staffId: params.staffId });
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This staff member still has upcoming appointments.",
}),
};
}
}
await prisma.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } });
return { success: true as const, conflicts: [], validation: ok() };
}
export async function reactivateStaff(
prisma: PrismaClient,
params: { staffId: string; businessId: string },
): Promise<{ success: boolean }> {
const result = await prisma.staffProfile.updateMany({
where: { id: params.staffId, businessId: params.businessId },
data: { status: "ACTIVE" },
});
return { success: result.count > 0 };
}
/** Future, non-cancelled appointments this staff member has that book a service being *removed* from their eligible list — the only ones that matter, since keeping or adding eligibility can never orphan an existing booking. */
export async function findServiceEligibilityConflicts(
prisma: PrismaClient,
params: { staffId: string; removedServiceIds: string[] },
) {
if (params.removedServiceIds.length === 0) return [];
return prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
services: { some: { serviceId: { in: params.removedServiceIds } } },
},
include: { customer: true, services: { include: { service: true } } },
orderBy: { startAt: "asc" },
});
}
export async function setStaffServiceEligibility(
prisma: PrismaClient,
params: { staffId: string; businessId: string; serviceIds: string[]; acknowledgeConflicts?: boolean },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
include: { services: true },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
conflicts: [],
};
}
const currentServiceIds = existing.services.map((s) => s.serviceId);
const removedServiceIds = currentServiceIds.filter((id) => !params.serviceIds.includes(id));
if (!params.acknowledgeConflicts) {
const conflicts = await findServiceEligibilityConflicts(prisma, {
staffId: params.staffId,
removedServiceIds,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This staff member has upcoming appointments for a service you're removing.",
}),
};
}
}
await prisma.$transaction([
prisma.staffService.deleteMany({ where: { staffId: params.staffId } }),
prisma.staffService.createMany({
data: params.serviceIds.map((serviceId) => ({ staffId: params.staffId, serviceId })),
}),
]);
return { success: true as const, conflicts: [], validation: ok() };
}