Staff / Owner
Merged staff and owner settings
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
Reference in new issue
Block a user