Add staff permission controls for editing staff and customers

- Add can_edit_staff and can_edit_customers dangerous permissions
- Move Site Builder, Services, Locations, Time Blocks, Payments to Settings permissions
- Link Edit Others' Schedules and Edit Own Schedule permissions
- Add permission checks to StaffViewSet (partial_update, toggle_active, verify_email)
- Add permission checks to CustomerViewSet (update, partial_update, verify_email)
- Fix CustomerViewSet permission key mismatch (can_access_customers)
- Hide Edit/Verify buttons on Staff and Customers pages without permission
- Make dangerous permissions section more visually distinct (darker red)
- Fix StaffDashboard links to use correct paths (/dashboard/my-schedule)
- Disable settings sub-permissions when Access Settings is unchecked

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-29 17:38:48 -05:00
parent d7700a68fd
commit 47657e7076
105 changed files with 29709 additions and 873 deletions

View File

@@ -0,0 +1,210 @@
/**
* Tests for helpSearchIndex data and utility functions
*/
import { describe, it, expect } from 'vitest';
import {
helpSearchIndex,
getHelpContextForAI,
HelpPage,
} from '../helpSearchIndex';
describe('helpSearchIndex', () => {
it('contains help pages', () => {
expect(helpSearchIndex.length).toBeGreaterThan(0);
});
it('all pages have required properties', () => {
helpSearchIndex.forEach((page) => {
expect(page.path).toBeTruthy();
expect(page.title).toBeTruthy();
expect(page.description).toBeTruthy();
expect(Array.isArray(page.topics)).toBe(true);
expect(page.topics.length).toBeGreaterThan(0);
expect(page.category).toBeTruthy();
});
});
it('all paths start with /dashboard/help', () => {
helpSearchIndex.forEach((page) => {
expect(page.path.startsWith('/dashboard/help')).toBe(true);
});
});
it('paths are unique', () => {
const paths = helpSearchIndex.map((p) => p.path);
const uniquePaths = new Set(paths);
expect(uniquePaths.size).toBe(paths.length);
});
describe('categories', () => {
it('contains Core Features category', () => {
const corePages = helpSearchIndex.filter((p) => p.category === 'Core Features');
expect(corePages.length).toBeGreaterThan(0);
});
it('contains Management category', () => {
const managementPages = helpSearchIndex.filter((p) => p.category === 'Management');
expect(managementPages.length).toBeGreaterThan(0);
});
it('contains Communication category', () => {
const communicationPages = helpSearchIndex.filter((p) => p.category === 'Communication');
expect(communicationPages.length).toBeGreaterThan(0);
});
it('contains Payments category', () => {
const paymentPages = helpSearchIndex.filter((p) => p.category === 'Payments');
expect(paymentPages.length).toBeGreaterThan(0);
});
it('contains Automations category', () => {
const automationPages = helpSearchIndex.filter((p) => p.category === 'Automations');
expect(automationPages.length).toBeGreaterThan(0);
});
it('contains API category', () => {
const apiPages = helpSearchIndex.filter((p) => p.category === 'API');
expect(apiPages.length).toBeGreaterThan(0);
});
it('contains Settings category', () => {
const settingsPages = helpSearchIndex.filter((p) => p.category === 'Settings');
expect(settingsPages.length).toBeGreaterThan(0);
});
});
describe('specific pages', () => {
it('includes Dashboard page', () => {
const dashboard = helpSearchIndex.find((p) => p.path === '/dashboard/help/dashboard');
expect(dashboard).toBeDefined();
expect(dashboard?.title).toBe('Dashboard');
expect(dashboard?.category).toBe('Core Features');
});
it('includes Scheduler page', () => {
const scheduler = helpSearchIndex.find((p) => p.path === '/dashboard/help/scheduler');
expect(scheduler).toBeDefined();
expect(scheduler?.title).toBe('Scheduler');
expect(scheduler?.topics).toContain('calendar');
});
it('includes Customers page', () => {
const customers = helpSearchIndex.find((p) => p.path === '/dashboard/help/customers');
expect(customers).toBeDefined();
expect(customers?.title).toBe('Customers');
expect(customers?.category).toBe('Management');
});
it('includes Services page', () => {
const services = helpSearchIndex.find((p) => p.path === '/dashboard/help/services');
expect(services).toBeDefined();
expect(services?.topics).toContain('pricing');
});
it('includes API Overview page', () => {
const api = helpSearchIndex.find((p) => p.path === '/dashboard/help/api');
expect(api).toBeDefined();
expect(api?.title).toBe('API Overview');
expect(api?.category).toBe('API');
});
it('includes Booking Settings page', () => {
const booking = helpSearchIndex.find((p) => p.path === '/dashboard/help/settings/booking');
expect(booking).toBeDefined();
expect(booking?.topics).toContain('cancellation');
expect(booking?.topics).toContain('reschedule');
});
});
describe('topics', () => {
it('pages have relevant topics', () => {
const scheduler = helpSearchIndex.find((p) => p.path === '/dashboard/help/scheduler');
expect(scheduler?.topics).toContain('appointments');
expect(scheduler?.topics).toContain('bookings');
expect(scheduler?.topics).toContain('calendar');
});
it('payments page has payment-related topics', () => {
const payments = helpSearchIndex.find((p) => p.path === '/dashboard/help/payments');
expect(payments?.topics).toContain('payments');
expect(payments?.topics).toContain('stripe');
expect(payments?.topics).toContain('credit card');
});
it('staff page has staff-related topics', () => {
const staff = helpSearchIndex.find((p) => p.path === '/dashboard/help/staff');
expect(staff?.topics).toContain('permissions');
expect(staff?.topics).toContain('roles');
});
});
});
describe('getHelpContextForAI', () => {
it('returns a string', () => {
const context = getHelpContextForAI();
expect(typeof context).toBe('string');
});
it('contains all page titles', () => {
const context = getHelpContextForAI();
helpSearchIndex.forEach((page) => {
expect(context).toContain(`Page: ${page.title}`);
});
});
it('contains all page paths', () => {
const context = getHelpContextForAI();
helpSearchIndex.forEach((page) => {
expect(context).toContain(`Path: ${page.path}`);
});
});
it('contains all page categories', () => {
const context = getHelpContextForAI();
helpSearchIndex.forEach((page) => {
expect(context).toContain(`Category: ${page.category}`);
});
});
it('contains all page descriptions', () => {
const context = getHelpContextForAI();
helpSearchIndex.forEach((page) => {
expect(context).toContain(`Description: ${page.description}`);
});
});
it('contains topics for each page', () => {
const context = getHelpContextForAI();
helpSearchIndex.forEach((page) => {
expect(context).toContain(`Topics: ${page.topics.join(', ')}`);
});
});
it('uses separator between pages', () => {
const context = getHelpContextForAI();
expect(context).toContain('---');
});
it('is non-empty', () => {
const context = getHelpContextForAI();
expect(context.length).toBeGreaterThan(0);
});
});
describe('HelpPage type', () => {
it('can create a valid HelpPage object', () => {
const page: HelpPage = {
path: '/test',
title: 'Test Page',
description: 'A test page',
topics: ['test', 'example'],
category: 'Test Category',
};
expect(page.path).toBe('/test');
expect(page.title).toBe('Test Page');
expect(page.description).toBe('A test page');
expect(page.topics).toContain('test');
expect(page.category).toBe('Test Category');
});
});

View File

@@ -0,0 +1,312 @@
/**
* Tests for navigationSearchIndex data and search function
*/
import { describe, it, expect } from 'vitest';
import {
navigationSearchIndex,
searchNavigation,
NavigationItem,
} from '../navigationSearchIndex';
describe('navigationSearchIndex', () => {
it('contains navigation items', () => {
expect(navigationSearchIndex.length).toBeGreaterThan(0);
});
it('all items have required properties', () => {
navigationSearchIndex.forEach((item) => {
expect(item.path).toBeTruthy();
expect(item.title).toBeTruthy();
expect(item.description).toBeTruthy();
expect(Array.isArray(item.keywords)).toBe(true);
expect(item.keywords.length).toBeGreaterThan(0);
expect(item.icon).toBeDefined();
expect(item.category).toBeTruthy();
});
});
it('all paths start with /dashboard', () => {
navigationSearchIndex.forEach((item) => {
expect(item.path.startsWith('/dashboard')).toBe(true);
});
});
it('paths are unique', () => {
const paths = navigationSearchIndex.map((i) => i.path);
const uniquePaths = new Set(paths);
expect(uniquePaths.size).toBe(paths.length);
});
describe('categories', () => {
it('contains Analytics category', () => {
const items = navigationSearchIndex.filter((i) => i.category === 'Analytics');
expect(items.length).toBeGreaterThan(0);
});
it('contains Manage category', () => {
const items = navigationSearchIndex.filter((i) => i.category === 'Manage');
expect(items.length).toBeGreaterThan(0);
});
it('contains Communicate category', () => {
const items = navigationSearchIndex.filter((i) => i.category === 'Communicate');
expect(items.length).toBeGreaterThan(0);
});
it('contains Extend category', () => {
const items = navigationSearchIndex.filter((i) => i.category === 'Extend');
expect(items.length).toBeGreaterThan(0);
});
it('contains Settings category', () => {
const items = navigationSearchIndex.filter((i) => i.category === 'Settings');
expect(items.length).toBeGreaterThan(0);
});
it('contains Help category', () => {
const items = navigationSearchIndex.filter((i) => i.category === 'Help');
expect(items.length).toBeGreaterThan(0);
});
it('only contains valid categories', () => {
const validCategories = ['Analytics', 'Manage', 'Communicate', 'Extend', 'Settings', 'Help'];
navigationSearchIndex.forEach((item) => {
expect(validCategories).toContain(item.category);
});
});
});
describe('specific items', () => {
it('includes Dashboard', () => {
const dashboard = navigationSearchIndex.find((i) => i.path === '/dashboard');
expect(dashboard).toBeDefined();
expect(dashboard?.title).toBe('Dashboard');
expect(dashboard?.category).toBe('Analytics');
});
it('includes Scheduler', () => {
const scheduler = navigationSearchIndex.find((i) => i.path === '/dashboard/scheduler');
expect(scheduler).toBeDefined();
expect(scheduler?.title).toBe('Scheduler');
expect(scheduler?.keywords).toContain('calendar');
});
it('includes Customers', () => {
const customers = navigationSearchIndex.find((i) => i.path === '/dashboard/customers');
expect(customers).toBeDefined();
expect(customers?.title).toBe('Customers');
expect(customers?.permission).toBe('can_access_customers');
});
it('includes Settings', () => {
const settings = navigationSearchIndex.find((i) => i.path === '/dashboard/settings');
expect(settings).toBeDefined();
expect(settings?.title).toBe('Settings');
});
it('includes Help', () => {
const help = navigationSearchIndex.find((i) => i.path === '/dashboard/help');
expect(help).toBeDefined();
expect(help?.category).toBe('Help');
});
});
describe('permissions', () => {
it('some items have permission keys', () => {
const itemsWithPermission = navigationSearchIndex.filter((i) => i.permission);
expect(itemsWithPermission.length).toBeGreaterThan(0);
});
it('Payments requires can_access_payments permission', () => {
const payments = navigationSearchIndex.find((i) => i.path === '/dashboard/payments');
expect(payments?.permission).toBe('can_access_payments');
});
it('Scheduler requires can_access_scheduler permission', () => {
const scheduler = navigationSearchIndex.find((i) => i.path === '/dashboard/scheduler');
expect(scheduler?.permission).toBe('can_access_scheduler');
});
});
describe('feature keys', () => {
it('Contracts has contracts feature key', () => {
const contracts = navigationSearchIndex.find((i) => i.path === '/dashboard/contracts');
expect(contracts?.featureKey).toBe('contracts');
});
it('Automations has automations feature key', () => {
const automations = navigationSearchIndex.find((i) => i.path === '/dashboard/automations');
expect(automations?.featureKey).toBe('automations');
});
});
});
describe('searchNavigation', () => {
describe('empty/invalid queries', () => {
it('returns empty array for empty string', () => {
const results = searchNavigation('');
expect(results).toEqual([]);
});
it('returns empty array for whitespace only', () => {
const results = searchNavigation(' ');
expect(results).toEqual([]);
});
});
describe('exact title matches', () => {
it('finds Dashboard by exact title', () => {
const results = searchNavigation('Dashboard');
expect(results.length).toBeGreaterThan(0);
expect(results[0].title).toBe('Dashboard');
});
it('finds Scheduler by exact title', () => {
const results = searchNavigation('Scheduler');
expect(results.length).toBeGreaterThan(0);
expect(results[0].title).toBe('Scheduler');
});
it('case insensitive title match', () => {
const results = searchNavigation('dashboard');
expect(results.length).toBeGreaterThan(0);
expect(results[0].title).toBe('Dashboard');
});
});
describe('partial matches', () => {
it('finds items by title prefix', () => {
const results = searchNavigation('Sched');
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.title === 'Scheduler')).toBe(true);
});
it('finds items by title substring', () => {
const results = searchNavigation('omer');
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.title === 'Customers')).toBe(true);
});
});
describe('keyword matches', () => {
it('finds Scheduler by "calendar" keyword', () => {
const results = searchNavigation('calendar');
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.title === 'Scheduler')).toBe(true);
});
it('finds Payments by "money" keyword', () => {
const results = searchNavigation('money');
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.title === 'Payments')).toBe(true);
});
it('finds Staff by "employees" keyword', () => {
const results = searchNavigation('employees');
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.title === 'Staff')).toBe(true);
});
});
describe('description matches', () => {
it('finds items by description text', () => {
const results = searchNavigation('appointments');
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.title === 'Scheduler')).toBe(true);
});
});
describe('multi-word queries', () => {
it('handles multi-word queries', () => {
const results = searchNavigation('staff permissions');
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.title === 'Staff Roles')).toBe(true);
});
it('handles business hours query', () => {
const results = searchNavigation('business hours');
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.title === 'Business Hours')).toBe(true);
});
});
describe('limit parameter', () => {
it('defaults to returning max 10 results', () => {
const results = searchNavigation('a');
expect(results.length).toBeLessThanOrEqual(10);
});
it('respects custom limit', () => {
const results = searchNavigation('a', 3);
expect(results.length).toBeLessThanOrEqual(3);
});
it('limit of 1 returns only top match', () => {
const results = searchNavigation('dashboard', 1);
expect(results.length).toBe(1);
expect(results[0].title).toBe('Dashboard');
});
});
describe('scoring', () => {
it('exact title match ranks higher than keyword match', () => {
const results = searchNavigation('dashboard');
expect(results[0].title).toBe('Dashboard');
});
it('title prefix match ranks higher than substring match', () => {
const results = searchNavigation('set');
// Settings should rank high because it starts with "set"
const settingsIndex = results.findIndex((r) => r.title === 'Settings');
expect(settingsIndex).toBeLessThan(5);
});
});
describe('no matches', () => {
it('returns empty array for nonsense query', () => {
const results = searchNavigation('xyznonexistent123');
expect(results).toEqual([]);
});
});
describe('special characters', () => {
it('handles query with special characters', () => {
const results = searchNavigation('api');
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.path.includes('api'))).toBe(true);
});
});
});
describe('NavigationItem type', () => {
it('can create a valid NavigationItem object', () => {
const item: NavigationItem = {
path: '/test',
title: 'Test',
description: 'Test description',
keywords: ['test', 'example'],
icon: {} as any, // Mock icon
category: 'Analytics',
permission: 'can_access_test',
};
expect(item.path).toBe('/test');
expect(item.title).toBe('Test');
expect(item.category).toBe('Analytics');
expect(item.permission).toBe('can_access_test');
});
it('permission and featureKey are optional', () => {
const item: NavigationItem = {
path: '/test',
title: 'Test',
description: 'Test description',
keywords: ['test'],
icon: {} as any,
category: 'Help',
};
expect(item.permission).toBeUndefined();
expect(item.featureKey).toBeUndefined();
});
});