Staff / Owner

Merged staff and owner settings
This commit is contained in:
Stefan Lazic committed 2026-08-05 16:35:52 +01:00
1 parent cf3a28f3cd
commit 3300045d9e
23 files changed
+693 -236

No files matched your search

@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from "vitest";
import { getNavRole } from "../permissions";
import { cleanupTestBusiness, createTestBusiness, createTestStaff, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* getNavRole is what both (staff)/layout.tsx and (owner)/layout.tsx use to
* pick a merged nav item set (see lib/navigation/nav-config.ts) and to
* gate entry to the (staff) route group at all. These tests cover the
* three roles the merged-nav brief distinguishes, plus the "signed in but
* neither staff nor super admin" case that must still redirect a
* registered customer away.
*/
describe("getNavRole (integration)", () => {
let business: TestBusiness | null = null;
const extraUserIds: string[] = [];
afterEach(async () => {
if (business) {
await cleanupTestBusiness(business);
business = null;
}
if (extraUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: extraUserIds } } }).catch(() => {});
extraUserIds.length = 0;
}
});
it("classifies an ordinary barber (no MANAGE_STAFF) as staff", async () => {
business = await createTestBusiness();
const { userId } = await createTestStaff(business);
await expect(getNavRole(userId)).resolves.toBe("staff");
});
it("classifies a staff member whose Role grants MANAGE_STAFF as owner", async () => {
business = await createTestBusiness();
const role = await prisma.role.create({
data: { businessId: business.businessId, name: "Owner", permissions: { create: [{ permission: "MANAGE_STAFF" }] } },
});
const user = await prisma.user.create({
data: { email: "owner-role@test.local", name: "Owner", staffProfile: { create: { businessId: business.businessId, roleId: role.id } } },
});
business.createdUserIds.push(user.id);
await expect(getNavRole(user.id)).resolves.toBe("owner");
});
it("classifies a Super Administrator as superadmin even with no StaffProfile at all", async () => {
const user = await prisma.user.create({
data: { email: "superadmin-navrole@test.local", name: "Super Admin", isSuperAdmin: true },
});
extraUserIds.push(user.id);
await expect(getNavRole(user.id)).resolves.toBe("superadmin");
});
it("returns null for a signed-in user who is neither staff nor a super admin (a registered customer)", async () => {
const user = await prisma.user.create({ data: { email: "customer-navrole@test.local", name: "Customer" } });
extraUserIds.push(user.id);
await expect(getNavRole(user.id)).resolves.toBeNull();
});
});
+18 -1
View File
@@ -1,6 +1,7 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
import type { Permission } from "@/generated/prisma/enums";
import { Permission } from "@/generated/prisma/enums";
import type { NavRole } from "@/lib/navigation/nav-config";
/**
* Foundation for Phase 3+ role gating — not exercised by any UI yet.
@@ -50,3 +51,19 @@ export async function hasPermission(userId: string, permission: Permission): Pro
const permissions = await getStaffPermissions(userId);
return permissions.has(permission);
}
/**
* The single role classification behind the merged nav (see
* lib/navigation/nav-config.ts) — "staff" vs "owner" is just whether the
* account's Role grants MANAGE_STAFF, and "superadmin" short-circuits both
* (a Super Administrator has no StaffProfile at all, see isSuperAdmin).
* Returns null for a signed-in user who is neither staff nor a super
* admin — callers should redirect those away, same as today.
*/
export const getNavRole = cache(async (userId: string): Promise<NavRole | null> => {
if (await isSuperAdmin(userId)) return "superadmin";
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId }, select: { id: true } });
if (!staffProfile) return null;
const permissions = await getStaffPermissions(userId);
return permissions.has(Permission.MANAGE_STAFF) ? "owner" : "staff";
});
@@ -26,11 +26,12 @@ export const getActingBusinessContext = cache(async () => {
const staffProfile = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
select: { businessId: true, business: { select: { name: true, timezone: true } } },
select: { id: true, businessId: true, business: { select: { name: true, timezone: true } } },
});
if (staffProfile) {
return {
userId: session.user.id,
staffId: staffProfile.id,
businessId: staffProfile.businessId,
businessName: staffProfile.business.name,
businessTimezone: staffProfile.business.timezone,
@@ -42,6 +43,11 @@ export const getActingBusinessContext = cache(async () => {
const business = await getPrimaryBusiness();
return {
userId: session.user.id,
// A Super Administrator has no StaffProfile by design (see isSuperAdmin) —
// null here is what lets callers tell "acting as real staff" apart from
// "acting as a Super Administrator with no personal staff identity",
// e.g. to withhold note authorship (see CustomerNotes' actingStaffId).
staffId: null,
businessId: business.id,
businessName: business.name,
businessTimezone: business.timezone,
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { getNavItems, resolveActiveHref } from "../nav-config";
describe("getNavItems", () => {
it("gives a barber exactly Calendar/Now/Clients/Settings", () => {
const labels = getNavItems("staff").map((i) => i.label);
expect(labels).toEqual(["Calendar", "Now", "Clients", "Settings"]);
});
it("gives an owner Calendar/Now/Clients/Manage/Settings — barber tools and business tools in one menu", () => {
const labels = getNavItems("owner").map((i) => i.label);
expect(labels).toEqual(["Calendar", "Now", "Clients", "Manage", "Settings"]);
});
it("gives a Super Administrator no 'Now' tab — there is no personal barber schedule to show", () => {
const labels = getNavItems("superadmin").map((i) => i.label);
expect(labels).toEqual(["Calendar", "Clients", "Manage", "Settings"]);
expect(labels).not.toContain("Now");
});
it("points owner's Now/Clients/Settings at the same routes a barber uses — no duplicate business-wide pages", () => {
const owner = getNavItems("owner");
const staff = getNavItems("staff");
expect(owner.find((i) => i.label === "Now")?.href).toBe(staff.find((i) => i.label === "Now")?.href);
expect(owner.find((i) => i.label === "Clients")?.href).toBe(staff.find((i) => i.label === "Clients")?.href);
expect(owner.find((i) => i.label === "Settings")?.href).toBe(staff.find((i) => i.label === "Settings")?.href);
});
it("gives owner and super-admin a business-wide Calendar, distinct from the barber's personal one", () => {
expect(getNavItems("owner").find((i) => i.label === "Calendar")?.href).toBe("/owner/calendar");
expect(getNavItems("superadmin").find((i) => i.label === "Calendar")?.href).toBe("/owner/calendar");
expect(getNavItems("staff").find((i) => i.label === "Calendar")?.href).toBe("/staff");
});
it("caps every role's nav at 5 items", () => {
for (const role of ["staff", "owner", "superadmin"] as const) {
expect(getNavItems(role).length).toBeLessThanOrEqual(5);
}
});
});
describe("resolveActiveHref", () => {
const staffItems = getNavItems("staff");
const ownerItems = getNavItems("owner");
it("activates Calendar for the barber's own calendar root", () => {
expect(resolveActiveHref("/staff", staffItems)).toBe("/staff");
});
it("activates Now for /staff/current, not Calendar — the classic longest-prefix trap", () => {
expect(resolveActiveHref("/staff/current", staffItems)).toBe("/staff/current");
});
it("activates Clients for a nested client detail route", () => {
expect(resolveActiveHref("/staff/clients/abc123", staffItems)).toBe("/staff/clients");
});
it("activates Settings for a nested personal-settings route", () => {
expect(resolveActiveHref("/staff/settings/calendar", staffItems)).toBe("/staff/settings");
});
it("activates Manage for every /owner/* business-management route", () => {
expect(resolveActiveHref("/owner/team", ownerItems)).toBe("/owner");
expect(resolveActiveHref("/owner/services", ownerItems)).toBe("/owner");
expect(resolveActiveHref("/owner/settings/loyalty", ownerItems)).toBe("/owner");
});
it("activates the business-wide Calendar for /owner/calendar, not Manage", () => {
expect(resolveActiveHref("/owner/calendar", ownerItems)).toBe("/owner/calendar");
});
it("activates Now for /staff/current even from the owner nav (shared personal route)", () => {
expect(resolveActiveHref("/staff/current", ownerItems)).toBe("/staff/current");
});
it("returns null for a route no item covers", () => {
expect(resolveActiveHref("/sign-in", staffItems)).toBeNull();
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { BottomNavItem } from "@/components/navigation/bottom-nav";
/**
* Single source of truth for the merged staff/owner/super-admin bottom nav
* and desktop sidebar (see docs/implementation-plan.md and the Phase 11
* "merged navigation" brief). Owner is a permission on a staff account, not
* a separate app — Owner and SuperAdmin nav reuse the exact same personal
* routes (`/staff/current`, `/staff/clients`, `/staff/settings`) a barber
* uses, rather than duplicating them under `/owner`. This is what lets
* owners reach barber tools and business tools from one menu with zero new
* routes.
*
* A Super Administrator has no StaffProfile by construction (see
* isSuperAdmin/getActingBusinessContext) — they get no "Now" tab, since
* there is no personal schedule to show, per the brief's explicit "do not
* fabricate a personal barber schedule for the Super Administrator".
*/
export type NavRole = "staff" | "owner" | "superadmin";
const CALENDAR: BottomNavItem = { href: "/staff", label: "Calendar", icon: "calendar-days" };
const OWNER_CALENDAR: BottomNavItem = { href: "/owner/calendar", label: "Calendar", icon: "calendar-days" };
const NOW: BottomNavItem = { href: "/staff/current", label: "Now", icon: "scissors" };
const CLIENTS: BottomNavItem = { href: "/staff/clients", label: "Clients", icon: "users" };
const MANAGE: BottomNavItem = { href: "/owner", label: "Manage", icon: "briefcase" };
const SETTINGS: BottomNavItem = { href: "/staff/settings", label: "Settings", icon: "settings" };
const NAV_ITEMS_BY_ROLE: Record<NavRole, BottomNavItem[]> = {
staff: [CALENDAR, NOW, CLIENTS, SETTINGS],
owner: [OWNER_CALENDAR, NOW, CLIENTS, MANAGE, SETTINGS],
superadmin: [OWNER_CALENDAR, CLIENTS, MANAGE, SETTINGS],
};
export function getNavItems(role: NavRole): BottomNavItem[] {
return NAV_ITEMS_BY_ROLE[role];
}
/**
* Longest-matching-href wins, rather than "every item whose href is a
* pathname prefix" (the previous per-component logic) — that scheme let
* `/staff/current` mark both "Calendar" (href `/staff`) and "Now" active at
* once, since `/staff/current` also starts with `/staff`. Sorting by href
* length and taking the first match makes only the most specific item win,
* which is what makes nested routes (e.g. `/owner/settings/loyalty`,
* `/staff/clients/[id]`) activate exactly one, correct primary tab.
*/
export function resolveActiveHref(pathname: string, items: BottomNavItem[]): string | null {
const matches = items.filter((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
if (matches.length === 0) return null;
return matches.reduce((longest, item) => (item.href.length > longest.href.length ? item : longest)).href;
}
-33
View File
@@ -1,33 +0,0 @@
import type { BottomNavItem } from "@/components/navigation/bottom-nav";
/**
* Single source of truth for the staff bottom nav. The templates had two
* competing 4-item sets for what is conceptually the same nav
* (Calendar/Current/Clients/Settings on most staff+owner screens vs.
* Dashboard/Staff/Calendar/Admin on the Team Management screen — see
* docs/implementation-plan.md §5/§8). Phase 5 resolved that by having
* (owner) reuse this same list; Phase 6 gives (owner) its own
* OWNER_NAV_ITEMS below instead, per that phase's own explicit nav
* requirement (Dashboard/Calendar/Team/Services/Settings) — see
* docs/phase-6-summary.md, "Owner navigation".
*
* Icons are string keys, not component references — this module is
* imported by Server Component layouts, and a Lucide icon component isn't
* a serializable prop across the Server → Client boundary into `BottomNav`
* (see bottom-nav.tsx).
*/
export const STAFF_NAV_ITEMS: BottomNavItem[] = [
{ href: "/staff", label: "Calendar", icon: "calendar-days" },
{ href: "/staff/current", label: "Current", icon: "scissors" },
{ href: "/staff/clients", label: "Clients", icon: "users" },
{ href: "/staff/settings", label: "Settings", icon: "settings" },
];
/** The (owner) route group's own nav — distinct from STAFF_NAV_ITEMS per the Phase 6 brief. "Team" covers staff management (incl. individual staff detail); "Services" covers categories+services; business/booking-rules/loyalty/notifications live under Settings. */
export const OWNER_NAV_ITEMS: BottomNavItem[] = [
{ href: "/owner", label: "Dashboard", icon: "chart-line" },
{ href: "/owner/calendar", label: "Calendar", icon: "calendar-days" },
{ href: "/owner/team", label: "Team", icon: "users" },
{ href: "/owner/services", label: "Services", icon: "scissors" },
{ href: "/owner/settings", label: "Settings", icon: "settings" },
];