Phase 6
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:
73 files changed
+8400
-53
No files matched your search
@@ -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() };
|
||||
}
|
||||
Reference in new issue
Block a user