Update staff roles, documentation, and add tenant API

Staff Roles:
- Remove Support Staff default role (now Manager and Staff only)
- Add position field for custom role ordering
- Update StaffRolesSettings with improved permission UI
- Add RolePermissions component for visual permission display

Documentation Updates:
- HelpStaff: Explain two-tier permission system (User Roles + Staff Roles)
- HelpSettingsStaffRoles: Update default roles, add settings access permissions
- HelpComprehensive: Update staff roles section with correct role structure
- HelpCustomers: Add customer creation and onboarding sections
- HelpContracts: Add lifecycle, snapshotting, and signing experience docs
- HelpSettingsAppearance: Update with 20 color palettes and navigation text

Tenant API:
- Add new isolated API at /tenant-api/v1/ for third-party integrations
- Token-based authentication with scope permissions
- Endpoints: business, services, resources, availability, bookings, customers, webhooks

Tests:
- Add test coverage for Celery tasks across modules
- Reorganize schedule view tests for better maintainability

🤖 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-24 20:46:36 -05:00
parent d8d3a4e846
commit 464726ee3e
47 changed files with 7826 additions and 2723 deletions

View File

@@ -1,4 +1,4 @@
VITE_DEV_MODE=true
VITE_API_URL=http://api.lvh.me:8000
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51Sa2i4G4IkZ6cJFI77f9dXf1ljmDPAInxbjLCJRRJk4ng1qmJKtWEqkFcDuoVcAdQsxcMH1L1UiQFfPwy8OmLSaz008GsGQ63y
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SdeoF5LKpRprAbuX9NpM0MJ1Sblr5qY5bNjozrirDWZXZub8XhJ6wf4VA3jfNhf5dXuWP8SPW1Cn5ZrZaMo2wg500QonC8D56
VITE_GOOGLE_MAPS_API_KEY=

View File

@@ -829,7 +829,6 @@ const AppContent: React.FC = () => {
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
<Route path="/dashboard/help/automations" element={<HelpAutomations />} />
<Route path="/dashboard/help/site-builder" element={<HelpSiteBuilder />} />
<Route path="/dashboard/help/api" element={<HelpApiOverview />} />
<Route path="/dashboard/help/api/appointments" element={<HelpApiAppointments />} />
<Route path="/dashboard/help/api/services" element={<HelpApiServices />} />
<Route path="/dashboard/help/api/resources" element={<HelpApiResources />} />

View File

@@ -566,7 +566,7 @@ const ApiTokensSection: React.FC = () => {
</div>
<div className="flex items-center gap-2">
<a
href="/help/api"
href="/dashboard/help/api"
className="px-3 py-2 text-sm font-medium text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 rounded-lg transition-colors flex items-center gap-2"
>
<ExternalLink size={16} />

View File

@@ -0,0 +1,266 @@
/**
* Shared Role Permission Components
*
* Reusable components for displaying and editing staff role permissions.
* Used in both StaffRolesSettings and the Invite Staff modal.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { PermissionDefinition } from '../../types';
export interface PermissionSectionProps {
title: string;
description: string;
permissions: Record<string, PermissionDefinition>;
values: Record<string, boolean>;
onChange: (key: string, value: boolean) => void;
onSelectAll?: () => void;
onClearAll?: () => void;
variant?: 'default' | 'settings' | 'dangerous';
readOnly?: boolean;
columns?: 1 | 2;
}
/**
* A section of permission checkboxes with header and select/clear all buttons
*/
export const PermissionSection: React.FC<PermissionSectionProps> = ({
title,
description,
permissions,
values,
onChange,
onSelectAll,
onClearAll,
variant = 'default',
readOnly = false,
columns = 2,
}) => {
const { t } = useTranslation();
const variantStyles = {
default: {
container: '',
checkbox: 'text-brand-600 focus:ring-brand-500',
hover: 'hover:bg-gray-50 dark:hover:bg-gray-700/50',
},
settings: {
container: 'p-3 bg-blue-50/50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-900/30',
checkbox: 'text-blue-600 focus:ring-blue-500',
hover: 'hover:bg-blue-100/50 dark:hover:bg-blue-900/20',
},
dangerous: {
container: 'p-3 bg-red-50/50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-900/30',
checkbox: 'text-red-600 focus:ring-red-500',
hover: 'hover:bg-red-100/50 dark:hover:bg-red-900/20',
},
};
const styles = variantStyles[variant];
const gridCols = columns === 1 ? 'grid-cols-1' : 'grid-cols-2';
return (
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
{title}
{variant === 'dangerous' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">
{t('common.caution', 'Caution')}
</span>
)}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">{description}</p>
</div>
{!readOnly && onSelectAll && onClearAll && (
<div className="flex gap-2">
<button
type="button"
onClick={onSelectAll}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={onClearAll}
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
{t('common.clearAll', 'Clear All')}
</button>
</div>
)}
</div>
<div className={`grid ${gridCols} gap-2 ${styles.container}`}>
{Object.entries(permissions).map(([key, def]) => (
<PermissionCheckbox
key={key}
permissionKey={key}
definition={def}
checked={values[key] || false}
onChange={(value) => onChange(key, value)}
checkboxClass={styles.checkbox}
hoverClass={styles.hover}
readOnly={readOnly}
/>
))}
</div>
</div>
);
};
interface PermissionCheckboxProps {
permissionKey: string;
definition: PermissionDefinition;
checked: boolean;
onChange: (value: boolean) => void;
checkboxClass?: string;
hoverClass?: string;
readOnly?: boolean;
}
/**
* Individual permission checkbox with label and description
*/
export const PermissionCheckbox: React.FC<PermissionCheckboxProps> = ({
permissionKey,
definition,
checked,
onChange,
checkboxClass = 'text-brand-600 focus:ring-brand-500',
hoverClass = 'hover:bg-gray-50 dark:hover:bg-gray-700/50',
readOnly = false,
}) => {
return (
<label
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer ${readOnly ? 'opacity-60 cursor-default' : hoverClass}`}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={readOnly}
className={`w-4 h-4 border-gray-300 dark:border-gray-600 rounded ${checkboxClass} disabled:opacity-50`}
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{definition.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{definition.description}
</div>
</div>
</label>
);
};
interface RolePermissionsEditorProps {
permissions: Record<string, boolean>;
onChange: (permissions: Record<string, boolean>) => void;
availablePermissions: {
menu: Record<string, PermissionDefinition>;
settings: Record<string, PermissionDefinition>;
dangerous: Record<string, PermissionDefinition>;
};
readOnly?: boolean;
columns?: 1 | 2;
}
/**
* Full role permissions editor with all three sections
*/
export const RolePermissionsEditor: React.FC<RolePermissionsEditorProps> = ({
permissions,
onChange,
availablePermissions,
readOnly = false,
columns = 2,
}) => {
const { t } = useTranslation();
const togglePermission = (key: string, value: boolean) => {
const updates: Record<string, boolean> = { [key]: value };
// If enabling any settings sub-permission, also enable the main settings access
if (value && key.startsWith('can_access_settings_')) {
updates['can_access_settings'] = true;
}
// If disabling the main settings access, disable all sub-permissions
if (!value && key === 'can_access_settings') {
Object.keys(availablePermissions.settings).forEach((settingKey) => {
if (settingKey !== 'can_access_settings') {
updates[settingKey] = false;
}
});
}
onChange({ ...permissions, ...updates });
};
const toggleAllInCategory = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
const categoryPerms = availablePermissions[category];
const updates: Record<string, boolean> = {};
Object.keys(categoryPerms).forEach((key) => {
updates[key] = enable;
});
// If enabling settings permissions, ensure main settings access is also enabled
if (category === 'settings' && enable) {
updates['can_access_settings'] = true;
}
onChange({ ...permissions, ...updates });
};
return (
<div className="space-y-6">
{/* Menu Permissions */}
<PermissionSection
title={t('settings.staffRoles.menuPermissions', 'Menu Access')}
description={t('settings.staffRoles.menuPermissionsDescription', 'Control which pages staff can see in the sidebar.')}
permissions={availablePermissions.menu}
values={permissions}
onChange={togglePermission}
onSelectAll={() => toggleAllInCategory('menu', true)}
onClearAll={() => toggleAllInCategory('menu', false)}
variant="default"
readOnly={readOnly}
columns={columns}
/>
{/* Settings Permissions */}
<PermissionSection
title={t('settings.staffRoles.settingsPermissions', 'Business Settings Access')}
description={t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')}
permissions={availablePermissions.settings}
values={permissions}
onChange={togglePermission}
onSelectAll={() => toggleAllInCategory('settings', true)}
onClearAll={() => toggleAllInCategory('settings', false)}
variant="settings"
readOnly={readOnly}
columns={columns}
/>
{/* Dangerous Permissions */}
<PermissionSection
title={t('settings.staffRoles.dangerousPermissions', 'Dangerous Operations')}
description={t('settings.staffRoles.dangerousPermissionsDescription', 'Allow staff to perform destructive or sensitive actions.')}
permissions={availablePermissions.dangerous}
values={permissions}
onChange={togglePermission}
onSelectAll={() => toggleAllInCategory('dangerous', true)}
onClearAll={() => toggleAllInCategory('dangerous', false)}
variant="dangerous"
readOnly={readOnly}
columns={columns}
/>
</div>
);
};
export default RolePermissionsEditor;

View File

@@ -53,7 +53,6 @@ export interface CreateInvitationData {
role: 'TENANT_STAFF';
create_bookable_resource?: boolean;
resource_name?: string;
permissions?: StaffPermissions;
staff_role_id?: number | null;
}

View File

@@ -93,3 +93,21 @@ export const useDeleteStaffRole = () => {
},
});
};
/**
* Hook to reorder staff roles
*/
export const useReorderStaffRoles = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (roleIds: number[]) => {
const response = await apiClient.post('/staff-roles/reorder/', { role_ids: roleIds });
return response.data as StaffRole[];
},
onSuccess: (data) => {
// Update the cache with the new order
queryClient.setQueryData(['staffRoles'], data);
},
});
};

View File

@@ -248,6 +248,17 @@
"retrieveCustomer": "Retrieve a customer",
"updateCustomer": "Update a customer",
"listCustomers": "List all customers",
"filtering": "Filtering & Sorting",
"filteringDescription": "Most list endpoints support filtering and sorting to help you retrieve exactly the data you need. Use query parameters to narrow your results.",
"comparisonOperators": "Comparison Operators",
"comparisonOperatorsDescription": "Append these suffixes to field names to filter by comparison:",
"servicesFilters": "Services Filters",
"bookingsFilters": "Bookings Filters",
"sorting": "Sorting",
"sortingDescription": "Use the ordering parameter to sort results. Prefix with a minus sign (-) for descending order.",
"filterServicesExample": "Filter Services Example",
"filterBookingsExample": "Filter Bookings Example",
"filteredResponse": "Filtered Response",
"webhooks": "Webhooks",
"webhookEvents": "Webhook events",
"webhookEventsDescription": "Webhooks allow you to receive real-time notifications when events occur in your business.",
@@ -3777,7 +3788,15 @@
"emailTemplates": "Email Templates",
"embedWidget": "Embed Widget",
"staffRoles": "Staff Roles",
"smsCalling": "SMS &amp; Calling"
"smsCalling": "SMS &amp; Calling",
"settingsSections": {
"business": "Business",
"branding": "Branding",
"integrations": "Integrations",
"access": "Access",
"communication": "Communication",
"billing": "Billing"
}
},
"introduction": {
"title": "Introduction",

View File

@@ -42,7 +42,7 @@ const LANGUAGES: Record<CodeLanguage, LanguageConfig> = {
// Default test credentials (used when no tokens are available)
const DEFAULT_TEST_API_KEY = 'ss_test_<your_test_token_here>';
const DEFAULT_TEST_WEBHOOK_SECRET = 'whsec_test_abc123def456ghi789jkl012mno345pqr678';
const SANDBOX_URL = 'https://sandbox.smoothschedule.com/v1';
const SANDBOX_URL = 'https://sandbox.smoothschedule.com/tenant-api/v1';
// Multi-language code interface
interface MultiLangCode {
@@ -385,6 +385,7 @@ const navSections: NavSection[] = [
{ titleKey: 'help.api.listCustomers', id: 'list-customers', method: 'GET' },
],
},
{ titleKey: 'help.api.filtering', id: 'filtering' },
{
titleKey: 'help.api.webhooks',
id: 'webhooks',
@@ -1926,6 +1927,139 @@ my $response = $ua->get('${SANDBOX_URL}/services/',
);`,
};
// Filtering example code
const filterServicesCode: MultiLangCode = {
curl: `# Filter services by price range (in cents)
curl "${SANDBOX_URL}/services/?price__gte=2000&price__lte=10000" \\
-H "Authorization: Bearer ${TEST_API_KEY}"
# Search by name and sort by price descending
curl "${SANDBOX_URL}/services/?search=haircut&ordering=-price" \\
-H "Authorization: Bearer ${TEST_API_KEY}"`,
javascript: `// Filter services by price range
const params = new URLSearchParams({
price__gte: '2000', // >= $20.00
price__lte: '10000', // <= $100.00
ordering: '-price' // Sort by price descending
});
const response = await fetch(
'${SANDBOX_URL}/services/?' + params,
{ headers: { 'Authorization': 'Bearer ${TEST_API_KEY}' } }
);`,
python: `import requests
# Filter by price range (in cents) and sort
response = requests.get(
'${SANDBOX_URL}/services/',
headers={'Authorization': 'Bearer ${TEST_API_KEY}'},
params={
'price__gte': 2000, # >= $20.00
'price__lte': 10000, # <= $100.00
'ordering': '-price' # Sort descending
}
)`,
go: `// Build URL with query parameters
req, _ := http.NewRequest("GET", "${SANDBOX_URL}/services/?price__gte=2000&price__lte=10000&ordering=-price", nil)
req.Header.Set("Authorization", "Bearer ${TEST_API_KEY}")
resp, _ := http.DefaultClient.Do(req)`,
java: `// Filter with query parameters
String url = "${SANDBOX_URL}/services/?price__gte=2000&price__lte=10000&ordering=-price";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer ${TEST_API_KEY}")
.build();`,
csharp: `// Filter services with query parameters
var url = "${SANDBOX_URL}/services/?price__gte=2000&price__lte=10000&ordering=-price";
var response = await client.GetAsync(url);`,
php: `$params = http_build_query([
'price__gte' => 2000,
'price__lte' => 10000,
'ordering' => '-price'
]);
$ch = curl_init('${SANDBOX_URL}/services/?' . $params);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ${TEST_API_KEY}'
]);`,
ruby: `uri = URI('${SANDBOX_URL}/services/')
uri.query = URI.encode_www_form({
price__gte: 2000,
price__lte: 10000,
ordering: '-price'
})
request = Net::HTTP::Get.new(uri)
request['Authorization'] = 'Bearer ${TEST_API_KEY}'`,
perl: `my $url = '${SANDBOX_URL}/services/?price__gte=2000&price__lte=10000&ordering=-price';
my $response = $ua->get($url,
'Authorization' => 'Bearer ${TEST_API_KEY}'
);`,
};
const filterBookingsCode: MultiLangCode = {
curl: `# Get bookings in a date range
curl "${SANDBOX_URL}/bookings/?start_time__gte=2024-01-01T00:00:00Z&start_time__lte=2024-01-31T23:59:59Z" \\
-H "Authorization: Bearer ${TEST_API_KEY}"
# Filter by status and sort newest first
curl "${SANDBOX_URL}/bookings/?status=CONFIRMED&ordering=-start_time" \\
-H "Authorization: Bearer ${TEST_API_KEY}"`,
javascript: `// Get bookings in January 2024
const params = new URLSearchParams({
start_time__gte: '2024-01-01T00:00:00Z',
start_time__lte: '2024-01-31T23:59:59Z',
status: 'CONFIRMED',
ordering: '-start_time' // Newest first
});
const response = await fetch(
'${SANDBOX_URL}/bookings/?' + params,
{ headers: { 'Authorization': 'Bearer ${TEST_API_KEY}' } }
);`,
python: `import requests
# Get bookings in date range
response = requests.get(
'${SANDBOX_URL}/bookings/',
headers={'Authorization': 'Bearer ${TEST_API_KEY}'},
params={
'start_time__gte': '2024-01-01T00:00:00Z',
'start_time__lte': '2024-01-31T23:59:59Z',
'status': 'CONFIRMED',
'ordering': '-start_time'
}
)`,
go: `req, _ := http.NewRequest("GET", "${SANDBOX_URL}/bookings/?start_time__gte=2024-01-01T00:00:00Z&ordering=-start_time", nil)
req.Header.Set("Authorization", "Bearer ${TEST_API_KEY}")
resp, _ := http.DefaultClient.Do(req)`,
java: `String url = "${SANDBOX_URL}/bookings/?" +
"start_time__gte=2024-01-01T00:00:00Z&" +
"start_time__lte=2024-01-31T23:59:59Z&" +
"ordering=-start_time";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer ${TEST_API_KEY}")
.build();`,
csharp: `var url = "${SANDBOX_URL}/bookings/?" +
"start_time__gte=2024-01-01T00:00:00Z&" +
"start_time__lte=2024-01-31T23:59:59Z&" +
"ordering=-start_time";
var response = await client.GetAsync(url);`,
php: `$params = http_build_query([
'start_time__gte' => '2024-01-01T00:00:00Z',
'start_time__lte' => '2024-01-31T23:59:59Z',
'ordering' => '-start_time'
]);
$ch = curl_init('${SANDBOX_URL}/bookings/?' . $params);`,
ruby: `uri = URI('${SANDBOX_URL}/bookings/')
uri.query = URI.encode_www_form({
start_time__gte: '2024-01-01T00:00:00Z',
start_time__lte: '2024-01-31T23:59:59Z',
ordering: '-start_time'
})`,
perl: `my $url = '${SANDBOX_URL}/bookings/?start_time__gte=2024-01-01T00:00:00Z&ordering=-start_time';
my $response = $ua->get($url, 'Authorization' => 'Bearer ${TEST_API_KEY}');`,
};
return (
<LanguageContext.Provider value={{ activeLanguage, setActiveLanguage }}>
<div className="min-h-screen bg-white dark:bg-gray-900">
@@ -1964,15 +2098,6 @@ my $response = $ua->get('${SANDBOX_URL}/services/',
</div>
)}
<a
href={`${API_BASE_URL}/v1/docs/`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-lg transition-colors"
>
<ExternalLink size={16} />
{t('help.api.interactiveExplorer')}
</a>
</div>
</div>
</header>
@@ -2038,7 +2163,7 @@ my $response = $ua->get('${SANDBOX_URL}/services/',
<CodeBlock
title={t('help.api.baseUrl')}
language="http"
code={`https://api.smoothschedule.com/v1/
code={`https://api.smoothschedule.com/tenant-api/v1/
# Sandbox/Test environment
${SANDBOX_URL}`}
@@ -2243,7 +2368,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/business/"
code="GET /tenant-api/v1/business/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getServicesCode} />
</ApiExample>
@@ -2315,7 +2440,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/services/"
code="GET /tenant-api/v1/services/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getServicesCode} />
<CodeBlock
@@ -2366,7 +2491,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/services/{id}/"
code="GET /tenant-api/v1/services/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getServiceCode} />
<CodeBlock
@@ -2461,7 +2586,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/resources/"
code="GET /tenant-api/v1/resources/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getResourcesCode} />
<CodeBlock
@@ -2518,7 +2643,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/resources/{id}/"
code="GET /tenant-api/v1/resources/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getResourceCode} />
<CodeBlock
@@ -2574,7 +2699,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/availability/"
code="GET /tenant-api/v1/availability/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getAvailabilityCode} />
<CodeBlock
@@ -2703,7 +2828,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="POST /v1/appointments/"
code="POST /tenant-api/v1/appointments/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={createAppointmentCode} />
<CodeBlock
@@ -2756,7 +2881,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/appointments/{id}/"
code="GET /tenant-api/v1/appointments/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getAppointmentCode} />
<CodeBlock
@@ -2818,7 +2943,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="PATCH /v1/appointments/{id}/"
code="PATCH /tenant-api/v1/appointments/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={updateAppointmentCode} />
<CodeBlock
@@ -2872,7 +2997,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="DELETE /v1/appointments/{id}/"
code="DELETE /tenant-api/v1/appointments/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={cancelAppointmentCode} />
<CodeBlock
@@ -2919,7 +3044,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/appointments/"
code="GET /tenant-api/v1/appointments/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getAppointmentsCode} />
<CodeBlock
@@ -3045,7 +3170,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="POST /v1/customers/"
code="POST /tenant-api/v1/customers/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={createCustomerCode} />
<CodeBlock
@@ -3087,7 +3212,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/customers/{id}/"
code="GET /tenant-api/v1/customers/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getCustomerCode} />
<CodeBlock
@@ -3139,7 +3264,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="PATCH /v1/customers/{id}/"
code="PATCH /tenant-api/v1/customers/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={updateCustomerCode} />
<CodeBlock
@@ -3190,7 +3315,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/customers/"
code="GET /tenant-api/v1/customers/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getCustomersCode} />
<CodeBlock
@@ -3220,6 +3345,118 @@ X-RateLimit-Burst-Remaining: 95`}
</ApiExample>
</ApiSection>
{/* Filtering & Sorting */}
<ApiSection id="filtering">
<ApiContent>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-0">
{t('help.api.filtering')}
</h2>
<p className="text-gray-600 dark:text-gray-300">
{t('help.api.filteringDescription')}
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('help.api.comparisonOperators')}
</h3>
<p className="text-gray-600 dark:text-gray-300">
{t('help.api.comparisonOperatorsDescription')}
</p>
<AttributeTable
attributes={[
{ name: '__lt', type: 'suffix', description: 'Less than the specified value.' },
{ name: '__lte', type: 'suffix', description: 'Less than or equal to the specified value.' },
{ name: '__gt', type: 'suffix', description: 'Greater than the specified value.' },
{ name: '__gte', type: 'suffix', description: 'Greater than or equal to the specified value.' },
]}
/>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('help.api.servicesFilters')}
</h3>
<AttributeTable
attributes={[
{ name: 'is_active', type: 'boolean', description: 'Filter by active/inactive status.' },
{ name: 'price__gte', type: 'integer', description: 'Minimum price in cents.' },
{ name: 'price__lte', type: 'integer', description: 'Maximum price in cents.' },
{ name: 'duration__gte', type: 'integer', description: 'Minimum duration in minutes.' },
{ name: 'duration__lte', type: 'integer', description: 'Maximum duration in minutes.' },
]}
/>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('help.api.bookingsFilters')}
</h3>
<AttributeTable
attributes={[
{ name: 'status', type: 'string', description: 'Filter by status: scheduled, completed, cancelled, no_show.' },
{ name: 'start_time__gte', type: 'datetime', description: 'Start time on or after (ISO 8601).' },
{ name: 'start_time__lte', type: 'datetime', description: 'Start time on or before (ISO 8601).' },
{ name: 'service_id', type: 'integer', description: 'Filter by specific service.' },
{ name: 'resource_id', type: 'integer', description: 'Filter by specific resource.' },
{ name: 'customer_id', type: 'integer', description: 'Filter by specific customer.' },
]}
/>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('help.api.sorting')}
</h3>
<p className="text-gray-600 dark:text-gray-300">
{t('help.api.sortingDescription')}
</p>
<AttributeTable
attributes={[
{ name: 'ordering', type: 'string', description: 'Field to sort by. Prefix with - for descending (e.g., -start_time).' },
]}
/>
</ApiContent>
<ApiExample>
<TabbedCodeBlock title={t('help.api.filterServicesExample')} codes={filterServicesCode} />
<CodeBlock
title={t('help.api.filteredResponse')}
language="json"
code={`[
{
"id": "svc_h8i9j0",
"name": "Premium Package",
"description": "Full spa treatment",
"duration": 90,
"price": 8500,
"is_active": true
},
{
"id": "svc_k1l2m3",
"name": "Deluxe Treatment",
"description": "Extended massage session",
"duration": 60,
"price": 6000,
"is_active": true
}
]`}
/>
<TabbedCodeBlock title={t('help.api.filterBookingsExample')} codes={filterBookingsCode} />
<CodeBlock
title={t('help.api.filteredResponse')}
language="json"
code={`[
{
"id": "apt_x1y2z3",
"service": { "id": "svc_123", "name": "Haircut" },
"customer": { "id": "cust_456", "name": "Jane Doe" },
"resource": { "id": "res_789", "name": "Sarah" },
"start_time": "2024-01-15T10:00:00Z",
"end_time": "2024-01-15T11:00:00Z",
"status": "scheduled"
},
{
"id": "apt_a4b5c6",
"service": { "id": "svc_123", "name": "Haircut" },
"customer": { "id": "cust_789", "name": "John Smith" },
"resource": { "id": "res_789", "name": "Sarah" },
"start_time": "2024-01-20T14:30:00Z",
"end_time": "2024-01-20T15:30:00Z",
"status": "completed"
}
]`}
/>
</ApiExample>
</ApiSection>
{/* Webhook Events */}
<ApiSection id="webhook-events">
<ApiContent>
@@ -3304,7 +3541,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="POST /v1/webhooks/"
code="POST /tenant-api/v1/webhooks/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={createWebhookCode} />
<CodeBlock
@@ -3353,7 +3590,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/webhooks/"
code="GET /tenant-api/v1/webhooks/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getWebhooksCode} />
<CodeBlock
@@ -3408,7 +3645,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/webhooks/{id}/"
code="GET /tenant-api/v1/webhooks/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getWebhookCode} />
<CodeBlock
@@ -3460,7 +3697,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="PATCH /v1/webhooks/{id}/"
code="PATCH /tenant-api/v1/webhooks/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={updateWebhookCode} />
<CodeBlock
@@ -3504,7 +3741,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="DELETE /v1/webhooks/{id}/"
code="DELETE /tenant-api/v1/webhooks/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={deleteWebhookCode} />
<CodeBlock
@@ -3540,7 +3777,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="POST /v1/webhooks/{id}/test/"
code="POST /tenant-api/v1/webhooks/{id}/test/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={testWebhookCode} />
<CodeBlock

View File

@@ -11,7 +11,8 @@ import {
StaffInvitation,
CreateInvitationData,
} from '../hooks/useInvitations';
import { useStaffRoles } from '../hooks/useStaffRoles';
import { useStaffRoles, useAvailablePermissions } from '../hooks/useStaffRoles';
import { RolePermissionsEditor } from '../components/staff/RolePermissions';
import {
Plus,
User as UserIcon,
@@ -37,7 +38,6 @@ import {
ArrowUpDown,
} from 'lucide-react';
import Portal from '../components/Portal';
import StaffPermissions from '../components/StaffPermissions';
interface StaffProps {
onMasquerade: (user: User) => void;
@@ -50,6 +50,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const { data: resources = [] } = useResources();
const { data: invitations = [], isLoading: invitationsLoading } = useInvitations();
const { data: staffRoles = [] } = useStaffRoles();
const { data: availablePermissions } = useAvailablePermissions();
const createResourceMutation = useCreateResource();
const createInvitationMutation = useCreateInvitation();
const cancelInvitationMutation = useCancelInvitation();
@@ -66,9 +67,9 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const [inviteStaffRoleId, setInviteStaffRoleId] = useState<number | null>(null);
const [createBookableResource, setCreateBookableResource] = useState(false);
const [resourceName, setResourceName] = useState('');
const [invitePermissions, setInvitePermissions] = useState<Record<string, boolean>>({});
const [inviteError, setInviteError] = useState('');
const [inviteSuccess, setInviteSuccess] = useState('');
const [showInvitePermissions, setShowInvitePermissions] = useState(false);
const [showInactiveStaff, setShowInactiveStaff] = useState(false);
// Edit modal state
@@ -97,6 +98,21 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
}));
};
// Get the selected role's permissions for the invite modal
const selectedInviteRole = useMemo(() => {
return staffRoles.find(r => r.id === inviteStaffRoleId);
}, [staffRoles, inviteStaffRoleId]);
// Format available permissions for the permission editor
const formattedAvailablePermissions = useMemo(() => {
if (!availablePermissions) return { menu: {}, settings: {}, dangerous: {} };
return {
menu: availablePermissions.menu_permissions || {},
settings: availablePermissions.settings_permissions || {},
dangerous: availablePermissions.dangerous_permissions || {},
};
}, [availablePermissions]);
// Separate active and inactive staff, then sort
const activeStaff = useMemo(() => {
const active = staffMembers.filter((s) => s.is_active);
@@ -156,8 +172,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
role: inviteRole,
create_bookable_resource: createBookableResource,
resource_name: resourceName.trim(),
permissions: invitePermissions,
staff_role_id: inviteRole === 'TENANT_STAFF' ? inviteStaffRoleId : null,
staff_role_id: inviteStaffRoleId,
};
await createInvitationMutation.mutateAsync(invitationData);
@@ -165,7 +180,6 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setInviteEmail('');
setCreateBookableResource(false);
setResourceName('');
setInvitePermissions({});
setInviteStaffRoleId(null);
// Close modal after short delay
setTimeout(() => {
@@ -201,9 +215,9 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setInviteStaffRoleId(null);
setCreateBookableResource(false);
setResourceName('');
setInvitePermissions({});
setInviteError('');
setInviteSuccess('');
setShowInvitePermissions(false);
setIsInviteModalOpen(true);
};
@@ -657,16 +671,39 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('staff.staffRoleSelectHint')}
</p>
{/* Show/Hide Permissions Button */}
{selectedInviteRole && (
<button
type="button"
onClick={() => setShowInvitePermissions(!showInvitePermissions)}
className="mt-2 text-xs text-brand-600 dark:text-brand-400 hover:underline flex items-center gap-1"
>
{showInvitePermissions ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{t('staff.viewPermissions', 'View role permissions')}
</button>
)}
</div>
)}
{/* Permissions - Using shared component */}
<StaffPermissions
role="staff"
permissions={invitePermissions}
onChange={setInvitePermissions}
variant="invite"
{/* Role Permissions (Read-only) */}
{selectedInviteRole && showInvitePermissions && availablePermissions && (
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Shield size={16} className="text-brand-600" />
{t('staff.rolePermissionsTitle', '{{role}} Permissions', { role: selectedInviteRole.name })}
</h4>
</div>
<RolePermissionsEditor
permissions={selectedInviteRole.permissions || {}}
onChange={() => {}}
availablePermissions={formattedAvailablePermissions}
readOnly
columns={1}
/>
</div>
)}
{/* Make Bookable Option */}
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">

View File

@@ -82,7 +82,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 text-xs font-semibold rounded">GET</span>
<code className="text-sm text-gray-900 dark:text-white font-mono">/api/v1/appointments/</code>
<code className="text-sm text-gray-900 dark:text-white font-mono">/tenant-api/v1/bookings/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">Retrieve a list of appointments with optional filtering.</p>
@@ -90,13 +90,18 @@ const HelpApiAppointments: React.FC = () => {
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 mb-4">
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">start_date</code> - Filter by start date (YYYY-MM-DD)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">end_date</code> - Filter by end date (YYYY-MM-DD)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">status</code> - Filter by status (scheduled, confirmed, completed, etc.)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">customer_id</code> - Filter by customer UUID</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">start_datetime</code> - Filter by start datetime (ISO 8601, e.g., 2025-12-01T09:00:00Z)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">end_datetime</code> - Filter by end datetime (ISO 8601)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">status</code> - Filter by status (SCHEDULED, CONFIRMED, COMPLETED, CANCELLED)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">customer_id</code> - Filter by customer ID</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">service_id</code> - Filter by service ID</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">resource_id</code> - Filter by resource ID</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">ordering</code> - Sort order: <code>start_time</code>, <code>-start_time</code> (default, newest first), <code>created_at</code>, <code>-created_at</code></li>
</ul>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X GET "https://api.smoothschedule.com/api/v1/appointments/?start_date=2025-12-01" \\
{`curl -X GET "https://api.smoothschedule.com/tenant-api/v1/bookings/?start_date=2025-12-01" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`}
</pre>
</div>
@@ -106,13 +111,13 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 text-xs font-semibold rounded">GET</span>
<code className="text-sm text-gray-900 dark:text-white font-mono">/api/v1/appointments/&#123;id&#125;/</code>
<code className="text-sm text-gray-900 dark:text-white font-mono">/tenant-api/v1/bookings/&#123;id&#125;/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">Retrieve a single appointment by ID.</p>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X GET "https://api.smoothschedule.com/api/v1/appointments/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
{`curl -X GET "https://api.smoothschedule.com/tenant-api/v1/bookings/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`}
</pre>
</div>
@@ -122,7 +127,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 text-xs font-semibold rounded">POST</span>
<code className="text-sm text-gray-900 dark:text-white font-mono">/api/v1/appointments/</code>
<code className="text-sm text-gray-900 dark:text-white font-mono">/tenant-api/v1/bookings/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">Create a new appointment.</p>
@@ -144,7 +149,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X POST "https://api.smoothschedule.com/api/v1/appointments/" \\
{`curl -X POST "https://api.smoothschedule.com/tenant-api/v1/bookings/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
@@ -162,7 +167,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-semibold rounded">PATCH</span>
<code className="text-sm text-gray-900 dark:text-white font-mono">/api/v1/appointments/&#123;id&#125;/</code>
<code className="text-sm text-gray-900 dark:text-white font-mono">/tenant-api/v1/bookings/&#123;id&#125;/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">Update an existing appointment.</p>
@@ -179,7 +184,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X PATCH "https://api.smoothschedule.com/api/v1/appointments/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
{`curl -X PATCH "https://api.smoothschedule.com/tenant-api/v1/bookings/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
@@ -194,7 +199,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 text-xs font-semibold rounded">DELETE</span>
<code className="text-sm text-gray-900 dark:text-white font-mono">/api/v1/appointments/&#123;id&#125;/</code>
<code className="text-sm text-gray-900 dark:text-white font-mono">/tenant-api/v1/bookings/&#123;id&#125;/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">Cancel an appointment. Optionally provide a cancellation reason.</p>
@@ -209,7 +214,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X DELETE "https://api.smoothschedule.com/api/v1/appointments/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
{`curl -X DELETE "https://api.smoothschedule.com/tenant-api/v1/bookings/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"reason": "Customer requested cancellation"}'`}

View File

@@ -82,7 +82,7 @@ const HelpApiCustomers: React.FC = () => {
List Customers
</h3>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4">
<code className="text-green-600 dark:text-green-400 font-mono">GET /api/v1/customers/</code>
<code className="text-green-600 dark:text-green-400 font-mono">GET /tenant-api/v1/customers/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-3">
Retrieve a paginated list of customers. Results are limited to 100 customers per request.
@@ -92,6 +92,7 @@ const HelpApiCustomers: React.FC = () => {
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<li><code className="bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">email</code> - Filter by exact email address</li>
<li><code className="bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">search</code> - Search by name or email (partial match)</li>
<li><code className="bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">ordering</code> - Sort order: last_name, -last_name, first_name, -first_name, email, -email, date_joined, -date_joined</li>
</ul>
</div>
</div>
@@ -102,7 +103,7 @@ const HelpApiCustomers: React.FC = () => {
Get Customer
</h3>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4">
<code className="text-green-600 dark:text-green-400 font-mono">GET /api/v1/customers/&#123;id&#125;/</code>
<code className="text-green-600 dark:text-green-400 font-mono">GET /tenant-api/v1/customers/&#123;id&#125;/</code>
</div>
<p className="text-gray-600 dark:text-gray-300">
Retrieve a specific customer by their UUID.
@@ -115,7 +116,7 @@ const HelpApiCustomers: React.FC = () => {
Create Customer
</h3>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4">
<code className="text-orange-600 dark:text-orange-400 font-mono">POST /api/v1/customers/</code>
<code className="text-orange-600 dark:text-orange-400 font-mono">POST /tenant-api/v1/customers/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-3">
Create a new customer record.
@@ -141,7 +142,7 @@ const HelpApiCustomers: React.FC = () => {
Update Customer
</h3>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4">
<code className="text-blue-600 dark:text-blue-400 font-mono">PATCH /api/v1/customers/&#123;id&#125;/</code>
<code className="text-blue-600 dark:text-blue-400 font-mono">PATCH /tenant-api/v1/customers/&#123;id&#125;/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-3">
Update an existing customer's information.
@@ -304,7 +305,7 @@ const HelpApiCustomers: React.FC = () => {
</h3>
<pre className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 overflow-x-auto">
<code className="text-sm text-gray-800 dark:text-gray-200">
{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/customers/" \\
{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/customers/" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json"`}
</code>
@@ -318,7 +319,7 @@ const HelpApiCustomers: React.FC = () => {
</h3>
<pre className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 overflow-x-auto">
<code className="text-sm text-gray-800 dark:text-gray-200">
{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/customers/?search=jane" \\
{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/customers/?search=jane" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json"`}
</code>
@@ -332,7 +333,7 @@ const HelpApiCustomers: React.FC = () => {
</h3>
<pre className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 overflow-x-auto">
<code className="text-sm text-gray-800 dark:text-gray-200">
{`curl -X POST "https://yourbusiness.smoothschedule.com/api/v1/customers/" \\
{`curl -X POST "https://yourbusiness.smoothschedule.com/tenant-api/v1/customers/" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
@@ -351,7 +352,7 @@ const HelpApiCustomers: React.FC = () => {
</h3>
<pre className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 overflow-x-auto">
<code className="text-sm text-gray-800 dark:text-gray-200">
{`curl -X PATCH "https://yourbusiness.smoothschedule.com/api/v1/customers/123e4567-e89b-12d3-a456-426614174000/" \\
{`curl -X PATCH "https://yourbusiness.smoothschedule.com/tenant-api/v1/customers/123e4567-e89b-12d3-a456-426614174000/" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{

View File

@@ -71,7 +71,7 @@ const HelpApiOverview: React.FC = () => {
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Base URL</h4>
<code className="text-sm text-brand-600 dark:text-brand-400">
https://your-subdomain.smoothschedule.com/api/v1/
https://your-subdomain.smoothschedule.com/tenant-api/v1/
</code>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
@@ -79,15 +79,8 @@ const HelpApiOverview: React.FC = () => {
<Book size={18} className="text-blue-600 dark:text-blue-400 mt-0.5" />
<div>
<p className="text-sm text-blue-800 dark:text-blue-300">
<strong>Interactive Documentation:</strong> Explore and test API endpoints at{' '}
<a
href="/api/v1/docs/"
target="_blank"
rel="noopener noreferrer"
className="bg-blue-100 dark:bg-blue-900/40 px-1 rounded font-mono hover:underline"
>
/api/v1/docs/
</a>
<strong>Tenant Remote API:</strong> This API is designed for third-party integrations.
All requests require Bearer token authentication with appropriate scopes.
</p>
</div>
</div>
@@ -130,7 +123,7 @@ const HelpApiOverview: React.FC = () => {
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Example Request</h4>
<div className="bg-gray-900 dark:bg-black rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X GET "https://demo.smoothschedule.com/api/v1/services/" \\
{`curl -X GET "https://demo.smoothschedule.com/tenant-api/v1/services/" \\
-H "Authorization: Bearer ss_live_xxxxxxxxx" \\
-H "Content-Type: application/json"`}
</pre>
@@ -230,6 +223,216 @@ const HelpApiOverview: React.FC = () => {
</div>
</section>
{/* Filtering & Sorting Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Code size={20} className="text-brand-500" />
Filtering & Sorting
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
All list endpoints support filtering and sorting via query parameters.
</p>
{/* Comparison Operators */}
<div className="mb-6">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Comparison Operators</h4>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
For numeric fields, append these suffixes to filter by comparison:
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Suffix</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Meaning</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Example</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">(none)</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Equals</td>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price=5000</code></td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">__lt</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Less than</td>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__lt=5000</code></td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">__lte</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Less than or equal</td>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__lte=5000</code></td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">__gt</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Greater than</td>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__gt=5000</code></td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">__gte</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Greater than or equal</td>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__gte=5000</code></td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Sorting */}
<div className="mb-6">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Sorting</h4>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
Use the <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">ordering</code> parameter to sort results.
Prefix with <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">-</code> for descending order.
</p>
<div className="bg-gray-900 dark:bg-black rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`# Ascending (oldest first)
GET /tenant-api/v1/bookings/?ordering=start_time
# Descending (newest first)
GET /tenant-api/v1/bookings/?ordering=-start_time`}
</pre>
</div>
</div>
{/* Services Filters */}
<div className="mb-6">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Services Endpoint Filters</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Parameter</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Type</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Description</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">search</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">string</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Search by name (partial match)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">name</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">string</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Exact name match</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">integer</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Price in cents (supports __lt, __lte, __gt, __gte)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">integer</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Duration in minutes (supports __lt, __lte, __gt, __gte)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">variable_pricing</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">boolean</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter by variable pricing (true/false)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">requires_manual_scheduling</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">boolean</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter by manual scheduling requirement</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">ordering</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">string</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">name, price, duration, display_order (prefix - for desc)</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Bookings Filters */}
<div className="mb-6">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Bookings Endpoint Filters</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Parameter</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Type</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Description</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">start_date</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">date</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter from date (YYYY-MM-DD)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">end_date</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">date</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter to date (YYYY-MM-DD)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">start_datetime</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">datetime</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter from datetime (ISO 8601)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">end_datetime</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">datetime</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter to datetime (ISO 8601)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">status</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">string</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">SCHEDULED, CONFIRMED, COMPLETED, CANCELLED</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">service_id</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">integer</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter by service</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">customer_id</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">integer</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter by customer</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">resource_id</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">integer</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter by resource</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">ordering</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">string</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">start_time, end_time, created_at, updated_at</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Example */}
<div>
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Examples</h4>
<div className="bg-gray-900 dark:bg-black rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`# Services under $50 with duration over 30 minutes
GET /tenant-api/v1/services/?price__lt=5000&duration__gt=30&ordering=price
# Bookings between two dates, newest first
GET /tenant-api/v1/bookings/?start_date=2025-01-01&end_date=2025-01-31&ordering=-start_time
# Customers search with sorting by last name
GET /tenant-api/v1/customers/?search=john&ordering=last_name`}
</pre>
</div>
</div>
</div>
</section>
{/* Rate Limiting Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">

View File

@@ -92,7 +92,7 @@ const HelpApiResources: React.FC = () => {
</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 mb-4 font-mono text-sm">
<span className="text-green-600 dark:text-green-400 font-semibold">GET</span>{' '}
<span className="text-gray-900 dark:text-gray-100">/api/v1/resources/</span>
<span className="text-gray-900 dark:text-gray-100">/tenant-api/v1/resources/</span>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-3">
Returns a list of all active resources in your account.
@@ -114,6 +114,16 @@ const HelpApiResources: React.FC = () => {
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">string</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Filter by resource type (STAFF, ROOM, EQUIPMENT)</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100">search</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">string</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Search by resource name</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100">ordering</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">string</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Sort order: name, -name, type, -type (prefix with - for descending)</td>
</tr>
</tbody>
</table>
</div>
@@ -128,7 +138,7 @@ const HelpApiResources: React.FC = () => {
</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 mb-4 font-mono text-sm">
<span className="text-green-600 dark:text-green-400 font-semibold">GET</span>{' '}
<span className="text-gray-900 dark:text-gray-100">/api/v1/resources/</span>
<span className="text-gray-900 dark:text-gray-100">/tenant-api/v1/resources/</span>
<span className="text-blue-600 dark:text-blue-400">{'{id}'}</span>
<span className="text-gray-900 dark:text-gray-100">/</span>
</div>
@@ -261,7 +271,7 @@ const HelpApiResources: React.FC = () => {
List All Resources
</h3>
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm">
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/resources/" \\
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/resources/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`}</code>
</pre>
</div>
@@ -272,7 +282,7 @@ const HelpApiResources: React.FC = () => {
Filter by Type
</h3>
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm">
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/resources/?type=STAFF" \\
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/resources/?type=STAFF" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`}</code>
</pre>
</div>
@@ -283,7 +293,7 @@ const HelpApiResources: React.FC = () => {
Get Specific Resource
</h3>
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm">
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/resources/550e8400-e29b-41d4-a716-446655440000/" \\
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/resources/550e8400-e29b-41d4-a716-446655440000/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`}</code>
</pre>
</div>

View File

@@ -88,12 +88,51 @@ const HelpApiServices: React.FC = () => {
GET
</span>
<code className="text-sm text-gray-900 dark:text-gray-100 font-mono">
/api/v1/services/
/tenant-api/v1/services/
</code>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
Returns all active services ordered by <code className="text-xs px-1 bg-gray-100 dark:bg-gray-700 rounded">display_order</code>.
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">
Returns all active services with optional filtering and sorting.
</p>
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-2">Text Filters:</h4>
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 mb-4">
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">search</code> - Search by service name (partial match)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">name</code> - Filter by exact name (case-insensitive)</li>
</ul>
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-2">Numeric Filters (with comparison operators):</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Use <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">__lt</code> (less than),
<code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">__lte</code> (less than or equal),
<code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">__gt</code> (greater than),
<code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">__gte</code> (greater than or equal) suffixes.
</p>
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 mb-4">
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price</code> - Exact price in cents (e.g., <code>price=1000</code> for $10.00)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__lt</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__lte</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__gt</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__gte</code> - Price comparisons</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration</code> - Exact duration in minutes</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration__lt</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration__lte</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration__gt</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration__gte</code> - Duration comparisons</li>
</ul>
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-2">Boolean Filters:</h4>
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 mb-4">
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">variable_pricing</code> - Filter by variable pricing (<code>true</code>/<code>false</code>)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">requires_manual_scheduling</code> - Filter by manual scheduling requirement</li>
</ul>
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-2">Sorting:</h4>
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 mb-4">
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">ordering</code> - Sort field (prefix with <code>-</code> for descending): <code>name</code>, <code>price</code>, <code>duration</code>, <code>display_order</code></li>
</ul>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<p className="text-xs text-gray-400 mb-2"># Get services under $50 with duration over 30 minutes, sorted by price</p>
<pre className="text-sm text-gray-100 font-mono">
{`curl "https://demo.smoothschedule.com/tenant-api/v1/services/?price__lt=5000&duration__gt=30&ordering=price" \\
-H "Authorization: Bearer ss_live_xxxxxxxxx"`}
</pre>
</div>
</div>
{/* Get Service */}
@@ -103,7 +142,7 @@ const HelpApiServices: React.FC = () => {
GET
</span>
<code className="text-sm text-gray-900 dark:text-gray-100 font-mono">
/api/v1/services/{'{id}'}/
/tenant-api/v1/services/{'{id}'}/
</code>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
@@ -191,10 +230,10 @@ const HelpApiServices: React.FC = () => {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
decimal | null
integer
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
Price in dollars (null for variable pricing)
Price in cents (e.g., 4500 = $45.00)
</td>
</tr>
<tr>
@@ -222,6 +261,39 @@ const HelpApiServices: React.FC = () => {
Whether the service is currently active
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<code className="text-sm font-mono text-purple-600 dark:text-purple-400">variable_pricing</code>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
boolean
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
If true, final price is determined after service completion
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<code className="text-sm font-mono text-purple-600 dark:text-purple-400">requires_manual_scheduling</code>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
boolean
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
If true, bookings go to Pending Requests instead of auto-scheduling
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<code className="text-sm font-mono text-purple-600 dark:text-purple-400">display_order</code>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
integer
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
Order in which services appear in menus (lower = first)
</td>
</tr>
</tbody>
</table>
</div>
@@ -237,17 +309,23 @@ const HelpApiServices: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<pre className="bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-lg overflow-x-auto text-sm font-mono">
{`{
"id": "550e8400-e29b-41d4-a716-446655440000",
"id": 1,
"name": "Haircut",
"description": "Professional haircut service",
"duration": 30,
"price": "45.00",
"price": 4500,
"photos": [
"https://smoothschedule.nyc3.digitaloceanspaces.com/..."
],
"is_active": true
"is_active": true,
"variable_pricing": false,
"requires_manual_scheduling": false,
"display_order": 0
}`}
</pre>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Note: <code>price</code> is in cents (4500 = $45.00)
</p>
</div>
</section>
@@ -264,7 +342,7 @@ const HelpApiServices: React.FC = () => {
List All Services
</h3>
<pre className="bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-lg overflow-x-auto text-sm font-mono">
{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/services/" \\
{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/services/" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json"`}
</pre>
@@ -276,7 +354,7 @@ const HelpApiServices: React.FC = () => {
Get Specific Service
</h3>
<pre className="bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-lg overflow-x-auto text-sm font-mono">
{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/services/550e8400-e29b-41d4-a716-446655440000/" \\
{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/services/550e8400-e29b-41d4-a716-446655440000/" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json"`}
</pre>

View File

@@ -98,7 +98,7 @@ const HelpApiWebhooks: React.FC = () => {
<List size={18} className="text-blue-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">List Subscriptions</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /api/v1/webhooks/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /tenant-api/v1/webhooks/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Returns a list of all webhook subscriptions for your account.
</p>
@@ -112,7 +112,7 @@ const HelpApiWebhooks: React.FC = () => {
<Plus size={18} className="text-green-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">Create Subscription</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">POST /api/v1/webhooks/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">POST /tenant-api/v1/webhooks/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2 mb-3">
Create a new webhook subscription. Returns the subscription including a <strong>secret</strong> for signature verification.
</p>
@@ -141,7 +141,7 @@ const HelpApiWebhooks: React.FC = () => {
<LinkIcon size={18} className="text-purple-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">Get Subscription</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /api/v1/webhooks/{`{id}`}/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /tenant-api/v1/webhooks/{`{id}`}/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Retrieve details of a specific webhook subscription.
</p>
@@ -155,7 +155,7 @@ const HelpApiWebhooks: React.FC = () => {
<Edit size={18} className="text-orange-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">Update Subscription</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">PATCH /api/v1/webhooks/{`{id}`}/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">PATCH /tenant-api/v1/webhooks/{`{id}`}/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Update an existing webhook subscription (URL, events, or description).
</p>
@@ -169,7 +169,7 @@ const HelpApiWebhooks: React.FC = () => {
<Trash2 size={18} className="text-red-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">Delete Subscription</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">DELETE /api/v1/webhooks/{`{id}`}/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">DELETE /tenant-api/v1/webhooks/{`{id}`}/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Delete a webhook subscription permanently.
</p>
@@ -183,7 +183,7 @@ const HelpApiWebhooks: React.FC = () => {
<Bell size={18} className="text-blue-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">List Event Types</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /api/v1/webhooks/events/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /tenant-api/v1/webhooks/events/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Get a list of all available webhook event types.
</p>
@@ -197,7 +197,7 @@ const HelpApiWebhooks: React.FC = () => {
<Send size={18} className="text-green-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">Send Test Webhook</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">POST /api/v1/webhooks/{`{id}`}/test/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">POST /tenant-api/v1/webhooks/{`{id}`}/test/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Send a test webhook to verify your endpoint is working correctly.
</p>
@@ -211,7 +211,7 @@ const HelpApiWebhooks: React.FC = () => {
<Clock size={18} className="text-purple-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">View Delivery History</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /api/v1/webhooks/{`{id}`}/deliveries/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /tenant-api/v1/webhooks/{`{id}`}/deliveries/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
View delivery history and status for a webhook subscription.
</p>
@@ -425,7 +425,7 @@ function verifyWebhook(request, secret) {
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<pre className="text-xs overflow-x-auto text-gray-700 dark:text-gray-300">
{`# Using curl
curl -X POST https://api.smoothschedule.com/api/v1/webhooks/ \\
curl -X POST https://api.smoothschedule.com/tenant-api/v1/webhooks/ \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import {
ArrowLeft, FileSignature, FileText, Send, CheckCircle, ChevronRight,
HelpCircle, Shield, Clock, Users, AlertCircle, Copy, Eye, Download,
Plus, Pencil, Trash2, ChevronDown, Ban, ExternalLink,
Plus, Pencil, Trash2, ChevronDown, Ban, ExternalLink, Mail,
LayoutGrid,
} from 'lucide-react';
@@ -50,6 +50,112 @@ const HelpContracts: React.FC = () => {
</div>
</section>
{/* Contract Lifecycle Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Clock size={20} className="text-brand-500" /> Contract Lifecycle
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Contracts flow through a three-stage lifecycle from creation to completion:
</p>
{/* Visual Flow */}
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex-1 text-center p-4 bg-blue-100 dark:bg-blue-900/30 rounded-lg border-2 border-blue-300 dark:border-blue-700">
<FileText size={28} className="mx-auto mb-2 text-blue-600 dark:text-blue-400" />
<div className="font-semibold text-gray-900 dark:text-white">Template</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Reusable document</div>
</div>
<ChevronRight size={24} className="hidden md:block text-gray-400" />
<div className="w-full md:w-auto text-center text-gray-400 md:hidden"></div>
<div className="flex-1 text-center p-4 bg-yellow-100 dark:bg-yellow-900/30 rounded-lg border-2 border-yellow-300 dark:border-yellow-700">
<Send size={28} className="mx-auto mb-2 text-yellow-600 dark:text-yellow-400" />
<div className="font-semibold text-gray-900 dark:text-white">Contract</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Sent to customer</div>
</div>
<ChevronRight size={24} className="hidden md:block text-gray-400" />
<div className="w-full md:w-auto text-center text-gray-400 md:hidden"></div>
<div className="flex-1 text-center p-4 bg-green-100 dark:bg-green-900/30 rounded-lg border-2 border-green-300 dark:border-green-700">
<CheckCircle size={28} className="mx-auto mb-2 text-green-600 dark:text-green-400" />
<div className="font-semibold text-gray-900 dark:text-white">Signature</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Legally binding</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold shrink-0">1</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Create Template</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Design reusable contract templates with placeholders like {"{{CUSTOMER_NAME}}"} that get filled automatically. Templates can be versioned and updated without affecting already-sent contracts.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div className="w-8 h-8 rounded-full bg-yellow-500 text-white flex items-center justify-center text-sm font-bold shrink-0">2</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Send Contract</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
When you send a contract, the system creates a <strong>snapshot</strong> of the template content with all variables filled in. This frozen copy is what the customer signseven if you update the template later, their contract remains unchanged.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="w-8 h-8 rounded-full bg-green-500 text-white flex items-center justify-center text-sm font-bold shrink-0">3</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Customer Signs</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Customer receives an email with a unique signing link. They review the contract, check consent boxes, and click "Sign". The system captures a complete audit trail including timestamp, IP address, device info, and optional geolocation.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Content Snapshotting Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-brand-500" /> Content Snapshotting & Integrity
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
For legal validity, the contract content is frozen at the moment of sending:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<FileText size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Immutable Content</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
When a contract is sent, the rendered HTML (with all variables replaced) is saved as a permanent snapshot. This is what the customer sees and signsit cannot be changed after sending.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Shield size={20} className="text-green-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">SHA-256 Hash</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
A cryptographic hash of the content is computed when the contract is created and again when signed. If these hashes match, it proves the document wasn't altered between sending and signing.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Copy size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Template Version Tracking</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Each contract records which version of the template was used. You can update templates freely—existing contracts keep their original content, and new contracts use the latest version.
</p>
</div>
</div>
</div>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<LayoutGrid size={20} className="text-brand-500" /> {t('help.contracts.pageLayout.title', 'Page Layout')}
@@ -339,6 +445,162 @@ const HelpContracts: React.FC = () => {
</div>
</section>
{/* Customer Signing Experience Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<ExternalLink size={20} className="text-brand-500" /> Customer Signing Experience
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
When you send a contract, the customer receives a professional signing experience:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<Mail size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Email Notification</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Customer receives an email with your business name, the contract title, and a prominent "Sign Contract" button. The email includes a unique, secure signing link.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<Eye size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Public Signing Page</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
The signing link opens a clean, branded page showing the full contract content. The customer can scroll through and read the entire document before signing.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<CheckCircle size={20} className="text-green-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Consent & Signature</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
At the bottom, the customer must check two consent boxes: one agreeing to the contract terms, and one consenting to electronic signatures (ESIGN Act requirement). Then they enter their name and click "Sign Contract".
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<Download size={20} className="text-amber-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Confirmation & PDF</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
After signing, the customer sees a success message and can download a PDF copy of the signed contract. They also receive a confirmation email with the PDF attached.
</p>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-300">
<strong>Security:</strong> Each signing link is a unique, 64-character token that expires after use or when the contract expires. Links cannot be guessed or reused.
</p>
</div>
</div>
</section>
{/* Service Contract Requirements Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<FileText size={20} className="text-brand-500" /> Requiring Contracts for Services
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
You can configure services to automatically require customers to sign specific contracts before or after booking:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
<FileSignature size={20} className="text-indigo-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Per-Service Requirements</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
In <strong>Settings → Services</strong>, edit any service and scroll to the "Contracts" section. Add templates that customers must sign when booking this service.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Users size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Customer-Level vs Per-Appointment</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
<strong>Customer-Level:</strong> Sign once and it applies to all future bookings (e.g., terms of service)<br />
<strong>Per-Appointment:</strong> Sign for each booking (e.g., liability waivers specific to each session)
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<AlertCircle size={20} className="text-amber-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Required vs Optional</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Mark contracts as <strong>Required</strong> to block booking until signed, or <strong>Optional</strong> to send but allow booking regardless of signature status.
</p>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-blue-500 mt-0.5 shrink-0" />
<p className="text-sm text-blue-700 dark:text-blue-300">
<strong>Tip:</strong> Use customer-level contracts for general agreements (privacy policy, general terms) and per-appointment contracts for service-specific waivers or acknowledgments.
</p>
</div>
</div>
</div>
</section>
{/* What Happens After Signing Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<CheckCircle size={20} className="text-brand-500" /> What Happens After Signing
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Once a customer signs, several things happen automatically:
</p>
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-xs font-bold">✓</div>
<span className="text-gray-700 dark:text-gray-300"><strong>Status Updated:</strong> Contract status changes from "Pending" to "Signed"</span>
</div>
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-xs font-bold">✓</div>
<span className="text-gray-700 dark:text-gray-300"><strong>Audit Trail Created:</strong> Full signature record with timestamp, IP, device, and geolocation</span>
</div>
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-xs font-bold">✓</div>
<span className="text-gray-700 dark:text-gray-300"><strong>PDF Generated:</strong> Legally compliant PDF with contract content, signature, and audit trail</span>
</div>
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-xs font-bold">✓</div>
<span className="text-gray-700 dark:text-gray-300"><strong>Customer Email:</strong> Confirmation email sent with PDF attachment</span>
</div>
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-xs font-bold">✓</div>
<span className="text-gray-700 dark:text-gray-300"><strong>Business Notification:</strong> You receive an email confirming the signature</span>
</div>
</div>
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Download Options</h4>
<p className="text-sm text-gray-600 dark:text-gray-300">
For signed contracts, you can download a complete evidence package containing the signed PDF, an official audit certificate, and a machine-readable JSON signature record for archival purposes.
</p>
</div>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-brand-500" /> {t('help.contracts.legalCompliance.title', 'Legal Compliance')}

View File

@@ -26,6 +26,11 @@ import {
Eye,
AlertCircle,
MoreHorizontal,
Globe,
Send,
Key,
FileSignature,
Link as LinkIcon,
} from 'lucide-react';
const HelpCustomers: React.FC = () => {
@@ -178,6 +183,52 @@ const HelpCustomers: React.FC = () => {
</div>
</section>
{/* How Customers Are Created Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Users size={20} className="text-brand-500" /> How Customers Are Created
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Customer records can be created in three ways, each serving different business scenarios:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<Globe size={20} className="text-green-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Online Booking</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
When customers book through your public booking page, they enter their contact information and a customer record is automatically created. If the customer already exists (matched by email), the booking is linked to their existing record.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<UserPlus size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Manual Creation</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use the "Add Customer" button to manually create customers. This is ideal for walk-in clients, phone bookings, or importing existing customers from another system.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<Calendar size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">During Appointment Scheduling</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
When scheduling an appointment in the dashboard, you can create a new customer inline by entering their details. This streamlines the booking process without leaving the scheduler.
</p>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-300">
<strong>Customer Record vs. Account:</strong> A customer record stores contact information and booking history. A customer account allows the customer to log in and manage their own appointments. Customers can exist as records without having login access.
</p>
</div>
</div>
</section>
{/* Adding a Customer Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
@@ -280,6 +331,143 @@ const HelpCustomers: React.FC = () => {
</div>
</section>
{/* Customer Onboarding Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Send size={20} className="text-brand-500" /> Customer Onboarding
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Customer onboarding is the process of bringing new customers into your system and enabling them to self-service their appointments. There are several pathways depending on how the customer relationship begins.
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Self-Registration (Online Booking)</h3>
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
When customers book through your public booking page, they go through a streamlined registration process:
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-sm font-semibold text-brand-600 dark:text-brand-400">1</div>
<span className="text-sm text-gray-600 dark:text-gray-300">Customer selects a service and time slot</span>
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-sm font-semibold text-brand-600 dark:text-brand-400">2</div>
<span className="text-sm text-gray-600 dark:text-gray-300">Customer enters contact information (name, email, phone)</span>
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-sm font-semibold text-brand-600 dark:text-brand-400">3</div>
<span className="text-sm text-gray-600 dark:text-gray-300">Customer creates a password (optional, for account access)</span>
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-sm font-semibold text-brand-600 dark:text-brand-400">4</div>
<span className="text-sm text-gray-600 dark:text-gray-300">Booking is confirmed and customer record is created</span>
</div>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Manual Customer Creation</h3>
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
For customers you add manually (phone bookings, walk-ins, imports), you control their onboarding:
</p>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600">
<Users size={18} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Record Only</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Customer exists in your database but cannot log in. You manage all bookings for them.</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600">
<Send size={18} className="text-green-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Invite to Create Account</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Send an invitation email so the customer can set a password and manage their own appointments.</p>
</div>
</div>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">What Customers Can Do With an Account</h3>
<ul className="space-y-2 mb-4">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 shrink-0" />
<span className="text-gray-600 dark:text-gray-300"><strong>View Appointments:</strong> See past and upcoming bookings in one place</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 shrink-0" />
<span className="text-gray-600 dark:text-gray-300"><strong>Book Online:</strong> Schedule new appointments without entering contact info each time</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 shrink-0" />
<span className="text-gray-600 dark:text-gray-300"><strong>Cancel/Reschedule:</strong> Modify appointments within your cancellation policy</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 shrink-0" />
<span className="text-gray-600 dark:text-gray-300"><strong>Sign Contracts:</strong> Review and sign required contracts digitally</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 shrink-0" />
<span className="text-gray-600 dark:text-gray-300"><strong>Update Profile:</strong> Keep contact information current</span>
</li>
</ul>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-blue-600 dark:text-blue-400 mt-0.5 shrink-0" />
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Tip:</strong> Customers who create accounts reduce your administrative workload by managing their own bookings. Encourage account creation by sending invitation emails to manually-added customers.
</p>
</div>
</div>
</div>
</section>
{/* Contract Requirements Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<FileSignature size={20} className="text-brand-500" /> Customer Contracts
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
For services that require contracts (waivers, agreements, terms of service), the customer onboarding process includes contract signing.
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<FileSignature size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Automatic Contract Requests</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
When a customer books a service with required contracts, they receive an email with a link to review and sign the documents before their appointment.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Key size={20} className="text-orange-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Scope Options</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Contracts can be configured to apply once per customer (sign once, valid for all future appointments) or per appointment (must sign before each visit).
</p>
</div>
</div>
</div>
<div className="mt-4">
<Link
to="/dashboard/help/contracts"
className="inline-flex items-center gap-2 text-brand-600 hover:text-brand-700 dark:text-brand-400 text-sm font-medium"
>
<LinkIcon size={14} />
Learn more about contracts
<ChevronRight size={14} />
</Link>
</div>
</div>
</section>
{/* Tags Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">

View File

@@ -188,23 +188,56 @@ const HelpSettingsAppearance: React.FC = () => {
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Choose from 10 professionally designed color palettes. Each palette includes a primary
and secondary color that work well together. Click any palette to instantly preview how
Choose from 20 professionally designed color palettes organized into two categories. Each palette includes
a primary and secondary color that work well together. Click any palette to instantly preview how
it looks throughout the interface.
</p>
{/* Bold/Dark Themes */}
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Bold Themes</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
Deep, vibrant colors with white text - ideal for a professional, bold look.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
{[
{ name: 'Ocean Blue', colors: ['#2563eb', '#0ea5e9'] },
{ name: 'Sky Blue', colors: ['#0ea5e9', '#38bdf8'] },
{ name: 'Mint Green', colors: ['#10b981', '#34d399'] },
{ name: 'Coral Reef', colors: ['#f97316', '#fb923c'] },
{ name: 'Lavender', colors: ['#a78bfa', '#c4b5fd'] },
{ name: 'Rose Pink', colors: ['#ec4899', '#f472b6'] },
{ name: 'Forest Green', colors: ['#059669', '#10b981'] },
{ name: 'Royal Purple', colors: ['#7c3aed', '#a78bfa'] },
{ name: 'Slate Gray', colors: ['#475569', '#64748b'] },
{ name: 'Crimson Red', colors: ['#dc2626', '#ef4444'] },
{ name: 'Emerald', colors: ['#10b981', '#34d399'] },
{ name: 'Coral', colors: ['#f97316', '#fb923c'] },
{ name: 'Rose', colors: ['#ec4899', '#f472b6'] },
{ name: 'Forest', colors: ['#059669', '#10b981'] },
{ name: 'Violet', colors: ['#7c3aed', '#a78bfa'] },
{ name: 'Slate', colors: ['#475569', '#64748b'] },
{ name: 'Crimson', colors: ['#dc2626', '#ef4444'] },
{ name: 'Indigo', colors: ['#4f46e5', '#6366f1'] },
].map((palette) => (
<div key={palette.name} className="text-center">
<div
className="h-6 rounded-md mb-1"
style={{ background: `linear-gradient(to right, ${palette.colors[0]}, ${palette.colors[1]})` }}
/>
<span className="text-xs text-gray-600 dark:text-gray-400">{palette.name}</span>
</div>
))}
</div>
{/* Light/Pastel Themes */}
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Light & Pastel Themes</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
Soft, airy colors with dark text - perfect for a friendly, approachable feel.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
{[
{ name: 'Soft Mint', colors: ['#6ee7b7', '#a7f3d0'] },
{ name: 'Lavender', colors: ['#c4b5fd', '#ddd6fe'] },
{ name: 'Peach', colors: ['#fdba74', '#fed7aa'] },
{ name: 'Baby Blue', colors: ['#7dd3fc', '#bae6fd'] },
{ name: 'Blush', colors: ['#fda4af', '#fecdd3'] },
{ name: 'Lemon', colors: ['#fde047', '#fef08a'] },
{ name: 'Aqua', colors: ['#5eead4', '#99f6e4'] },
{ name: 'Lilac', colors: ['#d8b4fe', '#e9d5ff'] },
{ name: 'Apricot', colors: ['#fcd34d', '#fde68a'] },
{ name: 'Cloud', colors: ['#94a3b8', '#cbd5e1'] },
].map((palette) => (
<div key={palette.name} className="text-center">
<div
@@ -243,7 +276,7 @@ const HelpSettingsAppearance: React.FC = () => {
</p>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Primary Color</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
@@ -256,6 +289,26 @@ const HelpSettingsAppearance: React.FC = () => {
Used for accents, hover states, and gradients. Should complement the primary color.
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Navigation Text</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Text color for the sidebar navigation. Auto-calculated for contrast, but customizable.
</p>
</div>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<AlertCircle size={18} className="text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Automatic Text Color</h4>
<p className="text-xs text-gray-600 dark:text-gray-400">
When you select a palette, the navigation text color is automatically calculated for optimal
readability. Dark backgrounds get white text; light backgrounds get dark text. Click "Auto"
next to the Navigation Text picker to reset to the calculated value.
</p>
</div>
</div>
</div>
<div className="p-4 bg-brand-50 dark:bg-brand-900/20 rounded-lg border border-brand-200 dark:border-brand-800">
@@ -263,7 +316,7 @@ const HelpSettingsAppearance: React.FC = () => {
<ol className="text-sm text-gray-600 dark:text-gray-300 space-y-1 list-decimal list-inside">
<li>Click the color swatch to open the color picker</li>
<li>Select a color visually or enter a hex code (e.g., #3b82f6)</li>
<li>The preview bar shows the gradient of your primary and secondary colors</li>
<li>The Navigation Preview shows exactly how your sidebar will look</li>
<li>Click <strong>Save Changes</strong> to apply your custom colors</li>
</ol>
</div>
@@ -271,6 +324,59 @@ const HelpSettingsAppearance: React.FC = () => {
</div>
</section>
{/* Navigation Preview */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Eye size={20} className="text-brand-500" /> Navigation Preview
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
As you adjust colors, a live preview shows exactly how your sidebar navigation will appear.
The preview includes:
</p>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={18} className="text-green-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Brand Gradient Background</h4>
<p className="text-xs text-gray-600 dark:text-gray-400">
Shows the gradient from your primary to secondary color
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={18} className="text-green-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Business Name & Logo Area</h4>
<p className="text-xs text-gray-600 dark:text-gray-400">
Displays your business initials and subdomain with the navigation text color
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={18} className="text-green-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Menu Item States</h4>
<p className="text-xs text-gray-600 dark:text-gray-400">
Shows active, hover, and inactive menu item styles
</p>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div className="flex items-start gap-3">
<Sparkles size={18} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-600 dark:text-gray-400">
<strong>Real-time Updates:</strong> The preview updates instantly as you change colors.
Look at your actual sidebar too—it updates in real-time so you can see exactly how
it looks in context before saving.
</p>
</div>
</div>
</div>
</section>
{/* Saving Changes */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">

View File

@@ -78,37 +78,52 @@ const HelpSettingsStaffRoles: React.FC = () => {
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
SmoothSchedule comes with three built-in roles to get you started:
SmoothSchedule comes with two built-in roles to get you started:
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex flex-col gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2">
<Shield size={20} className="text-green-500" />
<h4 className="font-medium text-gray-900 dark:text-white">Manager</h4>
<span className="text-xs px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full">Full Access</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Full access to all features and settings. Best for supervisors and team leads.
<p className="text-sm text-gray-600 dark:text-gray-400">
Complete access to all features, settings, and operations. Managers can:
</p>
<ul className="text-xs text-gray-500 dark:text-gray-400 space-y-1 mt-2">
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-green-500" /> Access all menu items</li>
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-green-500" /> View and modify all settings</li>
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-green-500" /> Perform all operations including deletions</li>
</ul>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 italic">
Best for: Supervisors, team leads, office managers
</p>
</div>
<div className="flex flex-col gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-2">
<Shield size={20} className="text-purple-500" />
<h4 className="font-medium text-gray-900 dark:text-white">Support Staff</h4>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Customer-facing operations including scheduling, customers, tickets, messages, and payments.
</p>
</div>
<div className="flex flex-col gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex flex-col gap-2 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2">
<Shield size={20} className="text-blue-500" />
<h4 className="font-medium text-gray-900 dark:text-white">Staff</h4>
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">Limited Access</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Basic access to own schedule and availability. Best for general staff members.
<p className="text-sm text-gray-600 dark:text-gray-400">
Basic access focused on personal schedule management. Staff can:
</p>
<ul className="text-xs text-gray-500 dark:text-gray-400 space-y-1 mt-2">
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-blue-500" /> View and manage their own schedule</li>
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-blue-500" /> Set their availability</li>
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-blue-500" /> Handle assigned appointments</li>
</ul>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 italic">
Best for: General employees, technicians, service providers
</p>
</div>
</div>
<div className="mt-4 p-4 bg-brand-50 dark:bg-brand-900/20 rounded-lg border border-brand-200 dark:border-brand-800">
<p className="text-sm text-brand-700 dark:text-brand-300">
<strong>Need something in between?</strong> Create a custom role with exactly the permissions you need.
For example, a "Front Desk" role with customer and scheduling access but no settings access.
</p>
</div>
</div>
</section>
@@ -280,6 +295,85 @@ const HelpSettingsStaffRoles: React.FC = () => {
</div>
</section>
{/* Settings Access Permissions */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Settings size={20} className="text-brand-500" />
Settings Access Permissions
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Settings permissions control which Settings pages staff can access. This allows you to grant
access to specific configuration areas without giving full settings access.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">General Settings</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Business name, timezone, contact</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Business Hours</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Operating hours and schedules</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Booking Settings</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Booking URL and redirect settings</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Branding</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Logo, colors, appearance</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Email Templates</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Automated email customization</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Custom Domains</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Domain configuration</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Embed Widget</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Website integration code</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">API & Webhooks</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">API tokens and webhook config</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Staff Roles</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Role management (this page)</p>
</div>
</div>
</div>
</div>
</section>
{/* Dangerous Permissions */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
@@ -294,8 +388,8 @@ const HelpSettingsStaffRoles: React.FC = () => {
Exercise Caution with These Permissions
</h3>
<p className="text-red-700 dark:text-red-300 text-sm mb-4">
Dangerous permissions allow staff to perform irreversible delete operations.
Only grant these permissions to trusted staff members.
Dangerous permissions allow staff to perform high-impact or irreversible operations.
Only grant these permissions to trusted staff members who need them for their role.
</p>
</div>
</div>
@@ -305,7 +399,16 @@ const HelpSettingsStaffRoles: React.FC = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Delete Customers</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Allows staff to permanently delete customer records and all associated data
Permanently delete customer records and all associated data
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<AlertTriangle size={18} className="text-red-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Cancel Appointments</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Cancel scheduled appointments, triggering cancellation notifications
</p>
</div>
</div>
@@ -314,7 +417,7 @@ const HelpSettingsStaffRoles: React.FC = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Delete Appointments</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Allows staff to permanently delete scheduled appointments
Permanently delete appointments from the system
</p>
</div>
</div>
@@ -323,7 +426,7 @@ const HelpSettingsStaffRoles: React.FC = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Delete Services</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Allows staff to permanently delete services from your offerings
Permanently delete services from your offerings
</p>
</div>
</div>
@@ -332,7 +435,34 @@ const HelpSettingsStaffRoles: React.FC = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Delete Resources</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Allows staff to permanently delete resources (staff members, rooms, equipment)
Permanently delete resources (staff, rooms, equipment)
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<AlertTriangle size={18} className="text-red-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Process Refunds</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Issue refunds to customers through Stripe
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<AlertTriangle size={18} className="text-red-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Invite Staff</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Send invitations to new staff members
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<AlertTriangle size={18} className="text-red-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Self-Approve Time Off</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Approve their own time-off requests without manager review
</p>
</div>
</div>
@@ -406,7 +536,7 @@ const HelpSettingsStaffRoles: React.FC = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Start with Default Roles</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use the built-in roles (Manager, Support Staff, Staff) as templates when creating custom roles
Use the built-in roles (Manager, Staff) as templates when creating custom roles
</p>
</div>
</li>

View File

@@ -75,16 +75,22 @@ const HelpStaff: React.FC = () => {
</div>
</section>
{/* Staff Roles Section */}
{/* User Roles vs Staff Roles Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-brand-500" /> Staff Roles
<Shield size={20} className="text-brand-500" /> Understanding Roles
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Each staff member has a role that determines their base level of access:
SmoothSchedule uses a two-tier permission system to give you flexible control over what each team member can access:
</p>
<div className="space-y-4">
{/* User Role: Owner vs Staff */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 mt-6">User Role: Owner vs Staff</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">
Every team member has a <strong>user role</strong> that determines their base access level:
</p>
<div className="space-y-4 mb-6">
<div className="flex items-start gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<Key size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
@@ -95,21 +101,43 @@ const HelpStaff: React.FC = () => {
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Full access to all features including billing, business settings, staff management, and all operational features. Owners cannot be deactivated or have their permissions restricted. Every business has at least one owner.
<strong>Unrestricted access</strong> to all features including billing, business settings, staff management, and all operational features. Owners cannot be deactivated or have their permissions restricted. Every business has at least one owner.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<Briefcase size={20} className="text-blue-500 mt-0.5 shrink-0" />
<Users size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">Manager</h4>
<h4 className="font-medium text-gray-900 dark:text-white">Staff</h4>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
<Briefcase size={10} /> manager
<Users size={10} /> staff
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Can manage scheduling, customers, services, and resources. Has access to view reports. Limited access to settings. Permissions can be customized to expand or restrict access to specific features.
<strong>Configurable access</strong> controlled by their assigned <em>Staff Role</em>. Staff members can have anything from full manager-level access to minimal view-only permissions, depending on which Staff Role they're assigned.
</p>
</div>
</div>
</div>
{/* Staff Roles */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Staff Roles: Permission Templates</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">
<strong>Staff Roles</strong> are permission templates you assign to staff members. They define exactly what menu items, settings, and operations that person can access. Two default roles are provided:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<Briefcase size={20} className="text-green-500 mt-0.5 shrink-0" />
<div>
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">Manager</h4>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
default role
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Full access to all features and settings. Ideal for supervisors, team leads, and trusted employees who need to manage day-to-day operations including scheduling, customers, services, resources, and settings.
</p>
</div>
</div>
@@ -118,12 +146,66 @@ const HelpStaff: React.FC = () => {
<div>
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">Staff</h4>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
staff
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
default role
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Basic access to view schedules and manage their own appointments. Read-only access to most areas. Ideal for employees who only need to see their calendar and handle their assigned bookings.
Basic access to own schedule and availability only. Perfect for employees who just need to see their calendar, manage their availability, and handle their assigned appointments.
</p>
</div>
</div>
</div>
{/* Custom Roles */}
<div className="mt-6 p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
<div className="flex items-start gap-2">
<Settings size={16} className="text-indigo-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-indigo-900 dark:text-indigo-100">Create Custom Roles</h4>
<p className="text-sm text-indigo-700 dark:text-indigo-300 mt-1">
Need something between Manager and Staff? Create custom Staff Roles with exactly the permissions you need in <strong>Settings → Staff Roles</strong>. For example: "Front Desk" with customer and scheduling access, or "Technician" with limited operational access.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Permission Categories Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-brand-500" /> Permission Categories
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Staff Role permissions are organized into three categories:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<Calendar size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Menu Access</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Controls which sidebar items are visible: Scheduler, Customers, Services, Resources, Staff, Messages, Payments, Contracts, Time Blocks, Site Builder, and more. Hidden items are completely inaccessible.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<Settings size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Settings Access</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Controls which Settings pages staff can access: General, Business Hours, Branding, Booking, Email Templates, Staff Roles, API & Integrations, and more. Grant settings access selectively.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<AlertCircle size={20} className="text-red-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Dangerous Operations</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Controls sensitive actions: Delete Customers, Cancel/Delete Appointments, Delete Services, Delete Resources, Process Refunds, Invite Staff, and Self-Approve Time Off. Only grant these to trusted team members.
</p>
</div>
</div>

View File

@@ -5,18 +5,20 @@
* Roles control what menu items and features are accessible to staff members.
*/
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Shield, Plus, X, Pencil, Trash2, Users, Lock, Check } from 'lucide-react';
import { Business, User, StaffRole, PermissionDefinition } from '../../types';
import { Shield, Plus, X, Pencil, Trash2, Users, Lock, Check, GripVertical } from 'lucide-react';
import { Business, User, StaffRole } from '../../types';
import {
useStaffRoles,
useAvailablePermissions,
useCreateStaffRole,
useUpdateStaffRole,
useDeleteStaffRole,
useReorderStaffRoles,
} from '../../hooks/useStaffRoles';
import { RolePermissionsEditor } from '../../components/staff/RolePermissions';
const StaffRolesSettings: React.FC = () => {
const { t } = useTranslation();
@@ -30,8 +32,12 @@ const StaffRolesSettings: React.FC = () => {
const createStaffRole = useCreateStaffRole();
const updateStaffRole = useUpdateStaffRole();
const deleteStaffRole = useDeleteStaffRole();
const reorderStaffRoles = useReorderStaffRoles();
const [isModalOpen, setIsModalOpen] = useState(false);
const [draggedRoleId, setDraggedRoleId] = useState<number | null>(null);
const [dragOverRoleId, setDragOverRoleId] = useState<number | null>(null);
const draggedRef = useRef<number | null>(null);
const [editingRole, setEditingRole] = useState<StaffRole | null>(null);
const [formData, setFormData] = useState({
name: '',
@@ -82,57 +88,10 @@ const StaffRolesSettings: React.FC = () => {
setError(null);
};
const togglePermission = (key: string) => {
setFormData((prev) => {
const newValue = !prev.permissions[key];
const updates: Record<string, boolean> = { [key]: newValue };
// If enabling any settings sub-permission, also enable the main settings access
if (newValue && key.startsWith('can_access_settings_')) {
updates['can_access_settings'] = true;
}
// If disabling the main settings access, disable all sub-permissions
if (!newValue && key === 'can_access_settings') {
Object.keys(allPermissions.settings).forEach((settingKey) => {
if (settingKey !== 'can_access_settings') {
updates[settingKey] = false;
}
});
}
return {
...prev,
permissions: {
...prev.permissions,
...updates,
},
};
});
};
const toggleAllPermissions = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
const permissions = category === 'menu'
? allPermissions.menu
: category === 'settings'
? allPermissions.settings
: allPermissions.dangerous;
const updates: Record<string, boolean> = {};
Object.keys(permissions).forEach((key) => {
updates[key] = enable;
});
// If enabling any settings permissions, ensure main settings access is also enabled
if (category === 'settings' && enable) {
updates['can_access_settings'] = true;
}
const handlePermissionsChange = (newPermissions: Record<string, boolean>) => {
setFormData((prev) => ({
...prev,
permissions: {
...prev.permissions,
...updates,
},
permissions: newPermissions,
}));
};
@@ -185,6 +144,67 @@ const StaffRolesSettings: React.FC = () => {
return Object.values(permissions).filter(Boolean).length;
};
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, roleId: number) => {
e.dataTransfer.setData('text/plain', String(roleId));
e.dataTransfer.effectAllowed = 'move';
draggedRef.current = roleId;
setDraggedRoleId(roleId);
};
const handleDragEnd = () => {
setDraggedRoleId(null);
setDragOverRoleId(null);
draggedRef.current = null;
};
const handleDragOver = (e: React.DragEvent, roleId: number) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
if (draggedRef.current && draggedRef.current !== roleId) {
setDragOverRoleId(roleId);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOverRoleId(null);
};
const handleDrop = async (e: React.DragEvent, targetRoleId: number) => {
e.preventDefault();
e.stopPropagation();
const draggedId = draggedRef.current;
setDragOverRoleId(null);
if (!draggedId || draggedId === targetRoleId) {
return;
}
// Reorder the roles
const currentOrder = staffRoles.map(r => r.id);
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetRoleId);
if (draggedIndex === -1 || targetIndex === -1) {
return;
}
// Remove dragged item and insert at target position
const newOrder = [...currentOrder];
newOrder.splice(draggedIndex, 1);
newOrder.splice(targetIndex, 0, draggedId);
try {
await reorderStaffRoles.mutateAsync(newOrder);
} catch (err) {
// Silently fail - the UI will remain unchanged
}
};
if (!canManageRoles) {
return (
<div className="text-center py-12">
@@ -240,14 +260,36 @@ const StaffRolesSettings: React.FC = () => {
<p className="text-sm mt-1">{t('settings.staffRoles.createFirst', 'Create your first role to manage staff permissions.')}</p>
</div>
) : (
<div className="space-y-3">
<div
className="space-y-3"
onDragOver={(e) => e.preventDefault()}
>
{staffRoles.map((role) => (
<div
key={role.id}
className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
draggable="true"
onDragStart={(e) => handleDragStart(e, role.id)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, role.id)}
onDragLeave={(e) => handleDragLeave(e)}
onDrop={(e) => handleDrop(e, role.id)}
className={`p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border transition-all select-none ${
dragOverRoleId === role.id
? 'border-brand-500 border-2 bg-brand-50 dark:bg-brand-900/20'
: draggedRoleId === role.id
? 'border-gray-300 dark:border-gray-600 opacity-50'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Drag Handle */}
<div
className="w-6 h-10 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
title={t('settings.staffRoles.dragToReorder', 'Drag to reorder')}
>
<GripVertical size={18} />
</div>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
role.is_default
? 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400'
@@ -283,9 +325,10 @@ const StaffRolesSettings: React.FC = () => {
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<div className="flex items-center gap-2 shrink-0 ml-2" onDragStart={(e) => e.preventDefault()}>
<button
onClick={() => openEditModal(role)}
draggable="false"
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
@@ -293,6 +336,7 @@ const StaffRolesSettings: React.FC = () => {
</button>
<button
onClick={() => handleDelete(role)}
draggable="false"
disabled={deleteStaffRole.isPending || !role.can_delete}
className={`p-2 transition-colors disabled:opacity-50 ${
role.can_delete
@@ -373,170 +417,12 @@ const StaffRolesSettings: React.FC = () => {
</div>
</div>
{/* Menu Permissions */}
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('settings.staffRoles.menuPermissions', 'Menu Access')}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.menuPermissionsDescription', 'Control which pages staff can see in the sidebar.')}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => toggleAllPermissions('menu', true)}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={() => toggleAllPermissions('menu', false)}
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
{t('common.clearAll', 'Clear All')}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(allPermissions.menu).map(([key, def]: [string, PermissionDefinition]) => (
<label
key={key}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={formData.permissions[key] || false}
onChange={() => togglePermission(key)}
className="w-4 h-4 text-brand-600 border-gray-300 dark:border-gray-600 rounded focus:ring-brand-500"
{/* Permissions Editor */}
<RolePermissionsEditor
permissions={formData.permissions}
onChange={handlePermissionsChange}
availablePermissions={allPermissions}
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{def.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{def.description}
</div>
</div>
</label>
))}
</div>
</div>
{/* Business Settings Permissions */}
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('settings.staffRoles.settingsPermissions', 'Business Settings Access')}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => toggleAllPermissions('settings', true)}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={() => toggleAllPermissions('settings', false)}
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
{t('common.clearAll', 'Clear All')}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 p-3 bg-blue-50/50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-900/30">
{Object.entries(allPermissions.settings).map(([key, def]: [string, PermissionDefinition]) => (
<label
key={key}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-blue-100/50 dark:hover:bg-blue-900/20 cursor-pointer"
>
<input
type="checkbox"
checked={formData.permissions[key] || false}
onChange={() => togglePermission(key)}
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{def.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{def.description}
</div>
</div>
</label>
))}
</div>
</div>
{/* Dangerous Permissions */}
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
{t('settings.staffRoles.dangerousPermissions', 'Dangerous Operations')}
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">
{t('common.caution', 'Caution')}
</span>
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.dangerousPermissionsDescription', 'Allow staff to perform destructive or sensitive actions.')}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => toggleAllPermissions('dangerous', true)}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={() => toggleAllPermissions('dangerous', false)}
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
{t('common.clearAll', 'Clear All')}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 p-3 bg-red-50/50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-900/30">
{Object.entries(allPermissions.dangerous).map(([key, def]: [string, PermissionDefinition]) => (
<label
key={key}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-red-100/50 dark:hover:bg-red-900/20 cursor-pointer"
>
<input
type="checkbox"
checked={formData.permissions[key] || false}
onChange={() => togglePermission(key)}
className="w-4 h-4 text-red-600 border-gray-300 dark:border-gray-600 rounded focus:ring-red-500"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{def.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{def.description}
</div>
</div>
</label>
))}
</div>
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">

View File

@@ -150,6 +150,7 @@ export interface StaffRole {
description: string;
permissions: Record<string, boolean>;
is_default: boolean;
position: number;
staff_count: number;
can_delete: boolean;
created_at: string;

View File

@@ -30,7 +30,7 @@ TWILIO_PHONE_NUMBER=
# ------------------------------------------------------------------------------
STRIPE_PUBLISHABLE_KEY=pk_test_51SdeoF5LKpRprAbuX9NpM0MJ1Sblr5qY5bNjozrirDWZXZub8XhJ6wf4VA3jfNhf5dXuWP8SPW1Cn5ZrZaMo2wg500QonC8D56
STRIPE_SECRET_KEY=sk_test_51SdeoF5LKpRprAbuT338ZzLkIrOPi6W4fy4fRvY8jR9zIiTdSlYPCvM8ClS5Qy4z4pY11mVLjmlAw4aB5rapu4g8001OItHIYv
STRIPE_WEBHOOK_SECRET=whsec_pP4vgQlBaDRc5Amm7lnMxvq7x6kcraYU
STRIPE_WEBHOOK_SECRET=whsec_tpz31bfvxiv22Bv9DABnuRsMupk2KO6m
# Mail Server Configuration
# ------------------------------------------------------------------------------

View File

@@ -119,7 +119,10 @@ LOCAL_APPS = [
# Platform Domain
"smoothschedule.platform.admin", # Platform settings (was platform_admin)
"smoothschedule.platform.api", # Public API v1 (was public_api)
"smoothschedule.platform.api", # Internal platform API
# Tenant API Domain
"smoothschedule.tenant_api", # Isolated tenant remote API
# Integrations Domain
"smoothschedule.integrations.activepieces", # Activepieces workflow automation

View File

@@ -10,6 +10,8 @@ from django.views.generic import TemplateView
from drf_spectacular.views import SpectacularAPIView
from drf_spectacular.views import SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token
from rest_framework.permissions import AllowAny
from smoothschedule.platform.admin.permissions import IsPlatformAdmin
import os
@@ -104,8 +106,10 @@ urlpatterns += [
path("staff/invitations/token/<str:token>/decline/", decline_invitation_view, name="decline_invitation"),
# Stripe Webhooks (dj-stripe built-in handler)
path("stripe/", include("djstripe.urls", namespace="djstripe")),
# Public API v1 (for third-party integrations)
# Public API v1 (for internal/Activepieces integrations)
path("v1/", include("smoothschedule.platform.api.urls", namespace="public_api")),
# Tenant Remote API (for third-party tenant integrations - token auth required)
path("tenant-api/v1/", include("smoothschedule.tenant_api.urls", namespace="tenant_api")),
# Tenant Sites API (Site Builder & Public Page)
path("", include("smoothschedule.platform.tenant_sites.urls")),
# Schedule API (internal)
@@ -188,11 +192,11 @@ urlpatterns += [
path("auth/mfa/devices/", list_trusted_devices, name="mfa_devices_list"),
path("auth/mfa/devices/<int:device_id>/", revoke_trusted_device, name="mfa_device_revoke"),
path("auth/mfa/devices/revoke-all/", revoke_all_trusted_devices, name="mfa_devices_revoke_all"),
# API Docs
path("schema/", SpectacularAPIView.as_view(), name="api-schema"),
# API Docs (platform admin only)
path("schema/", SpectacularAPIView.as_view(permission_classes=[IsPlatformAdmin]), name="api-schema"),
path(
"docs/",
SpectacularSwaggerView.as_view(url_name="api-schema"),
SpectacularSwaggerView.as_view(url_name="api-schema", permission_classes=[IsPlatformAdmin]),
name="api-docs",
),
]

View File

@@ -0,0 +1,389 @@
"""
Tests for Stripe Connect Celery tasks.
Tests cover:
- check_stripe_account_requirements task
- check_single_tenant_stripe_requirements task
- Helper functions (is_notifications_available, create_stripe_notification, etc.)
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime, timedelta, timezone as dt_timezone
class TestIsNotificationsAvailable:
"""Unit tests for is_notifications_available helper"""
def test_returns_true_when_notifications_available(self):
"""Returns True when notifications app is installed and working"""
from smoothschedule.commerce.payments.tasks import is_notifications_available
with patch('smoothschedule.communication.notifications.models.Notification') as mock_notification:
mock_notification.objects.exists.return_value = True
result = is_notifications_available()
assert result is True
def test_returns_false_when_import_fails(self):
"""Returns False when notifications cannot be imported"""
from smoothschedule.commerce.payments.tasks import is_notifications_available
with patch('smoothschedule.commerce.payments.tasks.is_notifications_available', return_value=False) as mock_fn:
result = mock_fn()
assert result is False
class TestCreateStripeNotification:
"""Unit tests for create_stripe_notification helper"""
def test_returns_none_when_notifications_unavailable(self):
"""Returns None when notifications app is not available"""
from smoothschedule.commerce.payments import tasks
with patch.object(tasks, 'is_notifications_available', return_value=False):
result = tasks.create_stripe_notification(Mock(), "Test", {})
assert result is None
def test_creates_notification_successfully(self):
"""Creates notification when notifications are available"""
from smoothschedule.commerce.payments import tasks
with patch.object(tasks, 'is_notifications_available', return_value=True):
with patch('smoothschedule.communication.notifications.models.Notification') as mock_notification:
mock_created_notification = Mock(id=1)
mock_notification.objects.create.return_value = mock_created_notification
recipient = Mock()
result = tasks.create_stripe_notification(recipient, "Test verb", {'key': 'value'})
assert result == mock_created_notification
mock_notification.objects.create.assert_called_once()
def test_returns_none_on_creation_error(self):
"""Returns None when notification creation fails"""
from smoothschedule.commerce.payments import tasks
with patch.object(tasks, 'is_notifications_available', return_value=True):
with patch('smoothschedule.communication.notifications.models.Notification') as mock_notification:
mock_notification.objects.create.side_effect = Exception("DB error")
result = tasks.create_stripe_notification(Mock(), "Test", {})
assert result is None
class TestGetTenantOwners:
"""Unit tests for get_tenant_owners helper"""
def test_returns_active_owners(self):
"""Returns list of active tenant owners"""
from smoothschedule.commerce.payments import tasks
with patch('smoothschedule.identity.users.models.User') as mock_user_model:
mock_owner1 = Mock(email='owner1@test.com')
mock_owner2 = Mock(email='owner2@test.com')
mock_user_model.objects.filter.return_value = [mock_owner1, mock_owner2]
mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER'
tenant = Mock()
result = tasks.get_tenant_owners(tenant)
assert len(result) == 2
def test_returns_empty_list_on_error(self):
"""Returns empty list when query fails"""
from smoothschedule.commerce.payments import tasks
with patch('smoothschedule.identity.users.models.User') as mock_user_model:
mock_user_model.objects.filter.side_effect = Exception("DB error")
result = tasks.get_tenant_owners(Mock())
assert result == []
class TestHasRecentStripeNotification:
"""Unit tests for has_recent_stripe_notification helper"""
def test_returns_false_when_notifications_unavailable(self):
"""Returns False when notifications app is not available"""
from smoothschedule.commerce.payments import tasks
with patch.object(tasks, 'is_notifications_available', return_value=False):
result = tasks.has_recent_stripe_notification(Mock())
assert result is False
def test_returns_true_when_recent_notification_exists(self):
"""Returns True when recipient has recent Stripe notification"""
from smoothschedule.commerce.payments import tasks
with patch.object(tasks, 'is_notifications_available', return_value=True):
with patch('smoothschedule.communication.notifications.models.Notification') as mock_notification:
mock_notification.objects.filter.return_value.exists.return_value = True
result = tasks.has_recent_stripe_notification(Mock(), hours=24)
assert result is True
def test_returns_false_when_no_recent_notification(self):
"""Returns False when no recent notification exists"""
from smoothschedule.commerce.payments import tasks
with patch.object(tasks, 'is_notifications_available', return_value=True):
with patch('smoothschedule.communication.notifications.models.Notification') as mock_notification:
mock_notification.objects.filter.return_value.exists.return_value = False
result = tasks.has_recent_stripe_notification(Mock(), hours=24)
assert result is False
class TestFormatRequirementDescription:
"""Unit tests for format_requirement_description helper"""
def test_formats_past_due_items(self):
"""Formats past due items count"""
from smoothschedule.commerce.payments.tasks import format_requirement_description
requirements = {
'past_due': ['id_document', 'bank_account'],
'currently_due': [],
}
result = format_requirement_description(requirements)
assert '2 overdue item(s)' in result
def test_formats_currently_due_items(self):
"""Formats currently due items count"""
from smoothschedule.commerce.payments.tasks import format_requirement_description
requirements = {
'past_due': [],
'currently_due': ['address', 'phone'],
}
result = format_requirement_description(requirements)
assert '2 item(s) needed' in result
def test_formats_disabled_reason(self):
"""Includes disabled reason if present"""
from smoothschedule.commerce.payments.tasks import format_requirement_description
requirements = {
'past_due': [],
'currently_due': [],
'disabled_reason': 'verification_required',
}
result = format_requirement_description(requirements)
assert 'verification_required' in result
def test_returns_default_when_empty(self):
"""Returns default message when no requirements"""
from smoothschedule.commerce.payments.tasks import format_requirement_description
requirements = {}
result = format_requirement_description(requirements)
assert result == "Action required"
class TestCheckStripeAccountRequirements:
"""Unit tests for check_stripe_account_requirements task"""
def test_returns_empty_results_when_no_connect_accounts(self):
"""Returns zero counts when no tenants have Stripe Connect"""
from smoothschedule.commerce.payments import tasks
with patch('smoothschedule.identity.core.models.Tenant') as mock_tenant_model:
with patch.object(tasks, 'stripe'):
mock_tenant_model.objects.filter.return_value.exclude.return_value = []
result = tasks.check_stripe_account_requirements()
assert result['tenants_checked'] == 0
def test_skips_accounts_with_no_issues(self):
"""Skips accounts that have no requirements"""
from smoothschedule.commerce.payments import tasks
with patch('smoothschedule.identity.core.models.Tenant') as mock_tenant_model:
with patch.object(tasks, 'stripe') as mock_stripe:
mock_tenant = Mock()
mock_tenant.stripe_connect_id = 'acct_123'
mock_tenant_model.objects.filter.return_value.exclude.return_value = [mock_tenant]
mock_account = Mock()
mock_account.requirements = {
'currently_due': [],
'past_due': [],
'disabled_reason': None,
}
mock_stripe.Account.retrieve.return_value = mock_account
result = tasks.check_stripe_account_requirements()
assert result['tenants_checked'] == 1
assert result['skipped_no_issues'] == 1
def test_skips_when_recent_notification_exists(self):
"""Skips owner who already received recent notification"""
from smoothschedule.commerce.payments import tasks
with patch('smoothschedule.identity.core.models.Tenant') as mock_tenant_model:
with patch.object(tasks, 'stripe') as mock_stripe:
with patch.object(tasks, 'get_tenant_owners') as mock_get_owners:
with patch.object(tasks, 'has_recent_stripe_notification', return_value=True):
mock_tenant = Mock()
mock_tenant.stripe_connect_id = 'acct_123'
mock_tenant_model.objects.filter.return_value.exclude.return_value = [mock_tenant]
mock_account = Mock()
mock_account.requirements = {
'currently_due': ['id_document'],
'past_due': [],
'disabled_reason': None,
'current_deadline': None,
}
mock_account.charges_enabled = True
mock_account.payouts_enabled = True
mock_stripe.Account.retrieve.return_value = mock_account
mock_owner = Mock()
mock_get_owners.return_value = [mock_owner]
result = tasks.check_stripe_account_requirements()
assert result['skipped_recent'] == 1
def test_creates_notification_for_requirements(self):
"""Creates notification when owner hasn't been notified recently"""
from smoothschedule.commerce.payments import tasks
with patch('smoothschedule.identity.core.models.Tenant') as mock_tenant_model:
with patch.object(tasks, 'stripe') as mock_stripe:
with patch.object(tasks, 'get_tenant_owners') as mock_get_owners:
with patch.object(tasks, 'has_recent_stripe_notification', return_value=False):
with patch.object(tasks, 'create_stripe_notification') as mock_create_notif:
mock_tenant = Mock()
mock_tenant.name = 'Test Business'
mock_tenant.stripe_connect_id = 'acct_123'
mock_tenant_model.objects.filter.return_value.exclude.return_value = [mock_tenant]
mock_account = Mock()
mock_account.requirements = {
'currently_due': ['id_document'],
'past_due': [],
'disabled_reason': None,
'current_deadline': None,
}
mock_account.charges_enabled = True
mock_account.payouts_enabled = True
mock_stripe.Account.retrieve.return_value = mock_account
mock_owner = Mock(email='owner@test.com')
mock_get_owners.return_value = [mock_owner]
mock_create_notif.return_value = Mock(id=1)
result = tasks.check_stripe_account_requirements()
assert result['notifications_created'] == 1
mock_create_notif.assert_called_once()
def test_handles_stripe_api_error(self):
"""Records error when Stripe API fails"""
from smoothschedule.commerce.payments import tasks
with patch('smoothschedule.identity.core.models.Tenant') as mock_tenant_model:
with patch.object(tasks, 'stripe') as mock_stripe:
mock_tenant = Mock()
mock_tenant.id = 1
mock_tenant.stripe_connect_id = 'acct_123'
mock_tenant_model.objects.filter.return_value.exclude.return_value = [mock_tenant]
mock_stripe.error.StripeError = type('StripeError', (Exception,), {})
mock_stripe.Account.retrieve.side_effect = mock_stripe.error.StripeError("API error")
result = tasks.check_stripe_account_requirements()
assert len(result['errors']) == 1
class TestCheckSingleTenantStripeRequirements:
"""Unit tests for check_single_tenant_stripe_requirements task"""
def test_returns_error_for_nonexistent_tenant(self):
"""Returns error when tenant doesn't exist"""
from smoothschedule.commerce.payments import tasks
from django.core.exceptions import ObjectDoesNotExist
with patch('smoothschedule.identity.core.models.Tenant') as mock_tenant_model:
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
mock_tenant_model.objects.get.side_effect = ObjectDoesNotExist()
result = tasks.check_single_tenant_stripe_requirements(999)
assert 'error' in result
assert '999' in result['error']
def test_returns_error_when_no_stripe_connect(self):
"""Returns error when tenant has no Stripe Connect account"""
from smoothschedule.commerce.payments import tasks
from django.core.exceptions import ObjectDoesNotExist
with patch('smoothschedule.identity.core.models.Tenant') as mock_tenant_model:
mock_tenant = Mock()
mock_tenant.stripe_connect_id = None
mock_tenant_model.objects.get.return_value = mock_tenant
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
result = tasks.check_single_tenant_stripe_requirements(1)
assert 'error' in result
assert 'no Stripe Connect' in result['error']
def test_returns_requirements_for_valid_tenant(self):
"""Returns requirements details for valid tenant"""
from smoothschedule.commerce.payments import tasks
from django.core.exceptions import ObjectDoesNotExist
with patch('smoothschedule.identity.core.models.Tenant') as mock_tenant_model:
with patch.object(tasks, 'stripe') as mock_stripe:
mock_tenant = Mock()
mock_tenant.id = 1
mock_tenant.name = 'Test Business'
mock_tenant.stripe_connect_id = 'acct_123'
mock_tenant_model.objects.get.return_value = mock_tenant
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
mock_account = Mock()
mock_account.requirements = {
'currently_due': ['id_document'],
'eventually_due': ['phone'],
'past_due': [],
'disabled_reason': None,
'current_deadline': 1735689600,
}
mock_account.charges_enabled = True
mock_account.payouts_enabled = False
mock_stripe.Account.retrieve.return_value = mock_account
result = tasks.check_single_tenant_stripe_requirements(1)
assert result['tenant_id'] == 1
assert result['tenant_name'] == 'Test Business'
assert result['currently_due'] == ['id_document']
assert result['charges_enabled'] is True
assert result['payouts_enabled'] is False
def test_handles_stripe_error(self):
"""Returns error when Stripe API fails"""
from smoothschedule.commerce.payments import tasks
from django.core.exceptions import ObjectDoesNotExist
with patch('smoothschedule.identity.core.models.Tenant') as mock_tenant_model:
with patch.object(tasks, 'stripe') as mock_stripe:
mock_tenant = Mock()
mock_tenant.stripe_connect_id = 'acct_123'
mock_tenant_model.objects.get.return_value = mock_tenant
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
mock_stripe.error.StripeError = type('StripeError', (Exception,), {})
mock_stripe.Account.retrieve.side_effect = mock_stripe.error.StripeError("Invalid account")
result = tasks.check_single_tenant_stripe_requirements(1)
assert 'error' in result

View File

@@ -0,0 +1,229 @@
"""
Tests for Ticket email processing Celery tasks.
Tests cover:
- fetch_incoming_emails task
- test_email_connection task
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
class TestFetchIncomingEmails:
"""Unit tests for fetch_incoming_emails task"""
@patch('smoothschedule.commerce.tickets.models.TicketEmailAddress')
@patch('smoothschedule.platform.admin.models.PlatformEmailAddress')
def test_returns_success_with_no_email_addresses(
self, mock_platform_email, mock_tenant_email
):
"""Returns success with zero processed when no email addresses exist"""
from smoothschedule.commerce.tickets.tasks import fetch_incoming_emails
mock_platform_email.objects.filter.return_value.first.return_value = None
mock_tenant_email.objects.filter.return_value = []
result = fetch_incoming_emails()
assert result['status'] == 'success'
assert result['processed'] == 0
assert result['results'] == []
@patch('smoothschedule.commerce.tickets.email_receiver.PlatformEmailReceiver')
@patch('smoothschedule.commerce.tickets.models.TicketEmailAddress')
@patch('smoothschedule.platform.admin.models.PlatformEmailAddress')
def test_processes_platform_email_successfully(
self, mock_platform_model, mock_tenant_model, mock_receiver
):
"""Successfully processes platform email address"""
from smoothschedule.commerce.tickets.tasks import fetch_incoming_emails
mock_platform_addr = Mock()
mock_platform_addr.display_name = 'Support'
mock_platform_model.objects.filter.return_value.first.return_value = mock_platform_addr
mock_tenant_model.objects.filter.return_value = []
mock_receiver_instance = Mock()
mock_receiver_instance.fetch_and_process_emails.return_value = 5
mock_receiver.return_value = mock_receiver_instance
result = fetch_incoming_emails()
assert result['status'] == 'success'
assert result['processed'] == 5
assert len(result['results']) == 1
assert result['results'][0]['type'] == 'platform'
assert result['results'][0]['processed'] == 5
assert result['results'][0]['status'] == 'success'
@patch('smoothschedule.commerce.tickets.email_receiver.PlatformEmailReceiver')
@patch('smoothschedule.commerce.tickets.models.TicketEmailAddress')
@patch('smoothschedule.platform.admin.models.PlatformEmailAddress')
def test_handles_platform_email_error(
self, mock_platform_model, mock_tenant_model, mock_receiver
):
"""Records error when platform email processing fails"""
from smoothschedule.commerce.tickets.tasks import fetch_incoming_emails
mock_platform_addr = Mock()
mock_platform_addr.display_name = 'Support'
mock_platform_model.objects.filter.return_value.first.return_value = mock_platform_addr
mock_tenant_model.objects.filter.return_value = []
mock_receiver_instance = Mock()
mock_receiver_instance.fetch_and_process_emails.side_effect = Exception("IMAP error")
mock_receiver.return_value = mock_receiver_instance
result = fetch_incoming_emails()
assert result['status'] == 'success' # Task itself succeeds
assert result['processed'] == 0
assert result['results'][0]['status'] == 'error'
assert 'IMAP error' in result['results'][0]['error']
@patch('smoothschedule.commerce.tickets.email_receiver.TicketEmailReceiver')
@patch('smoothschedule.commerce.tickets.models.TicketEmailAddress')
@patch('smoothschedule.platform.admin.models.PlatformEmailAddress')
def test_processes_tenant_email_addresses(
self, mock_platform_model, mock_tenant_model, mock_receiver
):
"""Successfully processes tenant email addresses"""
from smoothschedule.commerce.tickets.tasks import fetch_incoming_emails
mock_platform_model.objects.filter.return_value.first.return_value = None
mock_tenant_addr1 = Mock()
mock_tenant_addr1.display_name = 'Tenant1 Support'
mock_tenant_addr2 = Mock()
mock_tenant_addr2.display_name = 'Tenant2 Support'
mock_tenant_model.objects.filter.return_value = [mock_tenant_addr1, mock_tenant_addr2]
mock_receiver_instance = Mock()
mock_receiver_instance.fetch_and_process_emails.return_value = 3
mock_receiver.return_value = mock_receiver_instance
result = fetch_incoming_emails()
assert result['status'] == 'success'
assert result['processed'] == 6 # 3 per tenant
assert len(result['results']) == 2
assert all(r['type'] == 'tenant' for r in result['results'])
@patch('smoothschedule.commerce.tickets.email_receiver.TicketEmailReceiver')
@patch('smoothschedule.commerce.tickets.models.TicketEmailAddress')
@patch('smoothschedule.platform.admin.models.PlatformEmailAddress')
def test_handles_tenant_email_error(
self, mock_platform_model, mock_tenant_model, mock_receiver
):
"""Records error for individual tenant email failures"""
from smoothschedule.commerce.tickets.tasks import fetch_incoming_emails
mock_platform_model.objects.filter.return_value.first.return_value = None
mock_tenant_addr = Mock()
mock_tenant_addr.display_name = 'Tenant Support'
mock_tenant_model.objects.filter.return_value = [mock_tenant_addr]
mock_receiver_instance = Mock()
mock_receiver_instance.fetch_and_process_emails.side_effect = Exception("Connection timeout")
mock_receiver.return_value = mock_receiver_instance
result = fetch_incoming_emails()
assert result['status'] == 'success'
assert result['processed'] == 0
assert result['results'][0]['status'] == 'error'
assert 'Connection timeout' in result['results'][0]['error']
@patch('smoothschedule.commerce.tickets.email_receiver.TicketEmailReceiver')
@patch('smoothschedule.commerce.tickets.email_receiver.PlatformEmailReceiver')
@patch('smoothschedule.commerce.tickets.models.TicketEmailAddress')
@patch('smoothschedule.platform.admin.models.PlatformEmailAddress')
def test_processes_both_platform_and_tenant_emails(
self, mock_platform_model, mock_tenant_model, mock_platform_receiver, mock_tenant_receiver
):
"""Processes both platform and tenant email addresses"""
from smoothschedule.commerce.tickets.tasks import fetch_incoming_emails
# Platform email
mock_platform_addr = Mock()
mock_platform_addr.display_name = 'Platform Support'
mock_platform_model.objects.filter.return_value.first.return_value = mock_platform_addr
mock_platform_instance = Mock()
mock_platform_instance.fetch_and_process_emails.return_value = 2
mock_platform_receiver.return_value = mock_platform_instance
# Tenant email
mock_tenant_addr = Mock()
mock_tenant_addr.display_name = 'Tenant Support'
mock_tenant_model.objects.filter.return_value = [mock_tenant_addr]
mock_tenant_instance = Mock()
mock_tenant_instance.fetch_and_process_emails.return_value = 4
mock_tenant_receiver.return_value = mock_tenant_instance
result = fetch_incoming_emails()
assert result['status'] == 'success'
assert result['processed'] == 6 # 2 + 4
assert len(result['results']) == 2
@patch('smoothschedule.commerce.tickets.models.TicketEmailAddress')
@patch('smoothschedule.platform.admin.models.PlatformEmailAddress')
def test_handles_platform_model_query_error(
self, mock_platform_model, mock_tenant_model
):
"""Handles error when querying platform email addresses"""
from smoothschedule.commerce.tickets.tasks import fetch_incoming_emails
mock_platform_model.objects.filter.side_effect = Exception("DB connection error")
mock_tenant_model.objects.filter.return_value = []
# Should not raise, just log and continue
result = fetch_incoming_emails()
assert result['status'] == 'success'
assert result['processed'] == 0
class TestTestEmailConnection:
"""Unit tests for test_email_connection task"""
@patch('smoothschedule.commerce.tickets.email_receiver.test_imap_connection')
def test_returns_success_when_connection_successful(self, mock_test_imap):
"""Returns success when IMAP connection test passes"""
from smoothschedule.commerce.tickets.tasks import test_email_connection
mock_test_imap.return_value = (True, "Connection successful")
result = test_email_connection()
assert result['success'] is True
assert result['message'] == "Connection successful"
@patch('smoothschedule.commerce.tickets.email_receiver.test_imap_connection')
def test_returns_failure_when_connection_fails(self, mock_test_imap):
"""Returns failure when IMAP connection test fails"""
from smoothschedule.commerce.tickets.tasks import test_email_connection
mock_test_imap.return_value = (False, "Authentication failed")
result = test_email_connection()
assert result['success'] is False
assert result['message'] == "Authentication failed"
@patch('smoothschedule.commerce.tickets.email_receiver.test_imap_connection')
def test_returns_connection_timeout_message(self, mock_test_imap):
"""Returns appropriate message on connection timeout"""
from smoothschedule.commerce.tickets.tasks import test_email_connection
mock_test_imap.return_value = (False, "Connection timed out after 30 seconds")
result = test_email_connection()
assert result['success'] is False
assert 'timed out' in result['message']

View File

@@ -270,8 +270,11 @@ class TestBroadcastMessageViewSet:
mock_message.individual_recipients.all.return_value = []
with patch('smoothschedule.communication.messaging.views.User') as MockUser:
mock_qs = Mock()
mock_qs.filter.return_value.exclude.return_value = []
mock_user1 = Mock(id=2)
mock_user2 = Mock(id=3)
mock_qs = MagicMock()
# The code does .filter().filter() and then iterates, so make the final result iterable
mock_qs.filter.return_value = [mock_user1, mock_user2]
MockUser.objects.filter.return_value = mock_qs
MockUser.Role = User.Role
@@ -514,8 +517,9 @@ class TestBroadcastMessageSendAction:
mock_message.individual_recipients.all.return_value = []
with patch('smoothschedule.communication.messaging.views.User') as MockUser:
mock_qs = Mock()
mock_qs.filter.return_value.exclude.return_value = [Mock(id=2), Mock(id=3)]
mock_qs = MagicMock()
# Make .filter() return an iterable list
mock_qs.filter.return_value = [Mock(id=2), Mock(id=3)]
MockUser.objects.filter.return_value = mock_qs
MockUser.Role = User.Role
@@ -541,8 +545,9 @@ class TestBroadcastMessageSendAction:
mock_message.individual_recipients.all.return_value = []
with patch('smoothschedule.communication.messaging.views.User') as MockUser:
mock_qs = Mock()
mock_qs.filter.return_value.exclude.return_value = [Mock(id=4), Mock(id=5)]
mock_qs = MagicMock()
# Make .filter() return an iterable list
mock_qs.filter.return_value = [Mock(id=4), Mock(id=5)]
MockUser.objects.filter.return_value = mock_qs
MockUser.Role = User.Role
@@ -551,14 +556,14 @@ class TestBroadcastMessageSendAction:
assert len(result) > 0
def test_send_includes_individual_recipients(self):
"""Should include individual recipients and exclude self."""
"""Should include all individual recipients."""
viewset = BroadcastMessageViewSet()
viewset.request = Mock()
viewset.request.user = Mock(id=1)
viewset.request.user.tenant = Mock()
mock_individual1 = Mock(id=2)
mock_individual2 = Mock(id=1) # Same as sender, should be excluded
mock_individual2 = Mock(id=3)
mock_message = Mock()
mock_message.target_owners = False
@@ -572,9 +577,10 @@ class TestBroadcastMessageSendAction:
result = viewset._get_target_recipients(mock_message)
# Should only include individual1, not the sender
# Should include all individual recipients
assert mock_individual1 in result
assert mock_individual2 not in result
assert mock_individual2 in result
assert len(result) == 2
class TestIsOwnerOrManagerPermission:

View File

@@ -24,7 +24,7 @@ def check_all_tenant_quotas():
Returns:
dict: Summary of overages found and notifications sent
"""
from smoothschedule.tenants.models import Tenant
from smoothschedule.identity.core.models import Tenant
from .quota_service import check_tenant_quotas
results = {
@@ -152,7 +152,7 @@ def check_single_tenant_quotas(tenant_id: int):
Returns:
dict: Overages found for this tenant
"""
from smoothschedule.tenants.models import Tenant
from smoothschedule.identity.core.models import Tenant
from .quota_service import check_tenant_quotas
try:

View File

@@ -0,0 +1,384 @@
"""
Tests for Core quota management Celery tasks.
Tests cover:
- check_all_tenant_quotas task
- send_quota_reminder_emails task
- process_expired_quotas task
- check_single_tenant_quotas task
- resolve_tenant_overage task
- cleanup_old_resolved_overages task
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime, timedelta
from django.utils import timezone
from django.core.exceptions import ObjectDoesNotExist
class TestCheckAllTenantQuotas:
"""Unit tests for check_all_tenant_quotas task"""
@patch('smoothschedule.identity.core.quota_service.check_tenant_quotas')
@patch('smoothschedule.identity.core.models.Tenant')
def test_returns_results_with_zero_counts_when_no_tenants(
self, mock_tenant_model, mock_check_quotas
):
"""Returns empty results when no active tenants exist"""
from smoothschedule.identity.core.tasks import check_all_tenant_quotas
mock_tenant_model.objects.filter.return_value = []
result = check_all_tenant_quotas()
assert result['tenants_checked'] == 0
assert result['new_overages'] == 0
assert result['notifications_sent'] == 0
assert result['errors'] == []
@patch('smoothschedule.identity.core.quota_service.check_tenant_quotas')
@patch('smoothschedule.identity.core.models.Tenant')
def test_counts_new_overages_correctly(self, mock_tenant_model, mock_check_quotas):
"""Correctly counts new overages from quota checks"""
from smoothschedule.identity.core.tasks import check_all_tenant_quotas
mock_tenant = Mock()
mock_tenant.id = 1
mock_tenant_model.objects.filter.return_value = [mock_tenant]
mock_overage1 = Mock()
mock_overage1.initial_email_sent_at = timezone.now()
mock_overage2 = Mock()
mock_overage2.initial_email_sent_at = None
mock_check_quotas.return_value = [mock_overage1, mock_overage2]
result = check_all_tenant_quotas()
assert result['tenants_checked'] == 1
assert result['new_overages'] == 2
assert result['notifications_sent'] == 1 # Only mock_overage1 has email sent
@patch('smoothschedule.identity.core.quota_service.check_tenant_quotas')
@patch('smoothschedule.identity.core.models.Tenant')
def test_handles_quota_check_exception(self, mock_tenant_model, mock_check_quotas):
"""Records error when quota check fails"""
from smoothschedule.identity.core.tasks import check_all_tenant_quotas
mock_tenant = Mock()
mock_tenant.id = 1
mock_tenant_model.objects.filter.return_value = [mock_tenant]
mock_check_quotas.side_effect = Exception("Quota service error")
result = check_all_tenant_quotas()
assert result['tenants_checked'] == 1
assert len(result['errors']) == 1
assert '1' in result['errors'][0]
@patch('smoothschedule.identity.core.quota_service.check_tenant_quotas')
@patch('smoothschedule.identity.core.models.Tenant')
def test_checks_multiple_tenants(self, mock_tenant_model, mock_check_quotas):
"""Checks all active tenants"""
from smoothschedule.identity.core.tasks import check_all_tenant_quotas
mock_tenant1 = Mock(id=1)
mock_tenant2 = Mock(id=2)
mock_tenant3 = Mock(id=3)
mock_tenant_model.objects.filter.return_value = [mock_tenant1, mock_tenant2, mock_tenant3]
mock_check_quotas.return_value = []
result = check_all_tenant_quotas()
assert result['tenants_checked'] == 3
assert mock_check_quotas.call_count == 3
class TestSendQuotaReminderEmails:
"""Unit tests for send_quota_reminder_emails task"""
@patch('smoothschedule.identity.core.quota_service.send_grace_period_reminders')
def test_returns_reminder_counts(self, mock_send_reminders):
"""Returns correct reminder counts"""
from smoothschedule.identity.core.tasks import send_quota_reminder_emails
mock_send_reminders.return_value = {
'week_reminders_sent': 5,
'day_reminders_sent': 2,
}
result = send_quota_reminder_emails()
assert result['week_reminders_sent'] == 5
assert result['day_reminders_sent'] == 2
assert result['errors'] == []
@patch('smoothschedule.identity.core.quota_service.send_grace_period_reminders')
def test_handles_reminder_exception(self, mock_send_reminders):
"""Records error when reminder service fails"""
from smoothschedule.identity.core.tasks import send_quota_reminder_emails
mock_send_reminders.side_effect = Exception("Email service down")
result = send_quota_reminder_emails()
assert result['week_reminders_sent'] == 0
assert result['day_reminders_sent'] == 0
assert len(result['errors']) == 1
assert 'Email service down' in result['errors'][0]
class TestProcessExpiredQuotas:
"""Unit tests for process_expired_quotas task"""
@patch('smoothschedule.identity.core.quota_service.process_expired_grace_periods')
def test_returns_archive_results(self, mock_process_expired):
"""Returns correct archiving results"""
from smoothschedule.identity.core.tasks import process_expired_quotas
mock_process_expired.return_value = {
'overages_processed': 3,
'total_archived': 7,
}
result = process_expired_quotas()
assert result['overages_processed'] == 3
assert result['resources_archived'] == 7
assert result['errors'] == []
@patch('smoothschedule.identity.core.quota_service.process_expired_grace_periods')
def test_handles_processing_exception(self, mock_process_expired):
"""Records error when processing fails"""
from smoothschedule.identity.core.tasks import process_expired_quotas
mock_process_expired.side_effect = Exception("Archive service error")
result = process_expired_quotas()
assert result['overages_processed'] == 0
assert result['resources_archived'] == 0
assert len(result['errors']) == 1
class TestCheckSingleTenantQuotas:
"""Unit tests for check_single_tenant_quotas task"""
@patch('smoothschedule.identity.core.quota_service.check_tenant_quotas')
@patch('smoothschedule.identity.core.models.Tenant')
def test_returns_error_for_nonexistent_tenant(
self, mock_tenant_model, mock_check_quotas
):
"""Returns error when tenant doesn't exist"""
from smoothschedule.identity.core.tasks import check_single_tenant_quotas
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
mock_tenant_model.objects.get.side_effect = ObjectDoesNotExist()
result = check_single_tenant_quotas(999)
assert 'error' in result
@patch('smoothschedule.identity.core.quota_service.check_tenant_quotas')
@patch('smoothschedule.identity.core.models.Tenant')
def test_returns_overages_for_valid_tenant(
self, mock_tenant_model, mock_check_quotas
):
"""Returns overage details for valid tenant"""
from smoothschedule.identity.core.tasks import check_single_tenant_quotas
mock_tenant = Mock()
mock_tenant.id = 1
mock_tenant.name = 'Test Business'
mock_tenant_model.objects.get.return_value = mock_tenant
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
mock_overage = Mock()
mock_overage.quota_type = 'MAX_STAFF'
mock_overage.current_usage = 10
mock_overage.allowed_limit = 5
mock_overage.overage_amount = 5
mock_overage.grace_period_ends_at = timezone.now() + timedelta(days=14)
mock_check_quotas.return_value = [mock_overage]
result = check_single_tenant_quotas(1)
assert result['tenant_id'] == 1
assert result['tenant_name'] == 'Test Business'
assert result['overages_found'] == 1
assert result['overages'][0]['quota_type'] == 'MAX_STAFF'
assert result['overages'][0]['overage_amount'] == 5
@patch('smoothschedule.identity.core.quota_service.check_tenant_quotas')
@patch('smoothschedule.identity.core.models.Tenant')
def test_returns_empty_overages_when_none_found(
self, mock_tenant_model, mock_check_quotas
):
"""Returns empty overages list when tenant is within limits"""
from smoothschedule.identity.core.tasks import check_single_tenant_quotas
mock_tenant = Mock()
mock_tenant.id = 1
mock_tenant.name = 'Test Business'
mock_tenant_model.objects.get.return_value = mock_tenant
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
mock_check_quotas.return_value = []
result = check_single_tenant_quotas(1)
assert result['overages_found'] == 0
assert result['overages'] == []
class TestResolveTenantOverage:
"""Unit tests for resolve_tenant_overage task"""
@patch('smoothschedule.identity.core.quota_service.QuotaService')
@patch('smoothschedule.identity.core.models.QuotaOverage')
def test_returns_error_for_nonexistent_overage(
self, mock_overage_model, mock_quota_service
):
"""Returns error when overage doesn't exist"""
from smoothschedule.identity.core.tasks import resolve_tenant_overage
mock_overage_model.DoesNotExist = ObjectDoesNotExist
mock_overage_model.objects.select_related.return_value.get.side_effect = ObjectDoesNotExist()
result = resolve_tenant_overage(999)
assert 'error' in result
@patch('smoothschedule.identity.core.quota_service.QuotaService')
@patch('smoothschedule.identity.core.models.QuotaOverage')
def test_returns_already_resolved_for_non_active_overage(
self, mock_overage_model, mock_quota_service
):
"""Returns already resolved for non-ACTIVE status"""
from smoothschedule.identity.core.tasks import resolve_tenant_overage
mock_overage = Mock()
mock_overage.status = 'RESOLVED'
mock_overage_model.objects.select_related.return_value.get.return_value = mock_overage
mock_overage_model.DoesNotExist = ObjectDoesNotExist
result = resolve_tenant_overage(1)
assert result['already_resolved'] is True
assert result['status'] == 'RESOLVED'
@patch('smoothschedule.identity.core.quota_service.QuotaService')
@patch('smoothschedule.identity.core.models.QuotaOverage')
def test_resolves_overage_when_usage_is_within_limit(
self, mock_overage_model, mock_quota_service
):
"""Resolves overage when current usage is at or below limit"""
from smoothschedule.identity.core.tasks import resolve_tenant_overage
mock_tenant = Mock()
mock_overage = Mock()
mock_overage.id = 1
mock_overage.status = 'ACTIVE'
mock_overage.tenant = mock_tenant
mock_overage.quota_type = 'MAX_STAFF'
mock_overage_model.objects.select_related.return_value.get.return_value = mock_overage
mock_overage_model.DoesNotExist = ObjectDoesNotExist
mock_service = Mock()
mock_service.get_current_usage.return_value = 5
mock_service.get_limit.return_value = 10
mock_quota_service.return_value = mock_service
result = resolve_tenant_overage(1)
assert result['resolved'] is True
assert result['current_usage'] == 5
assert result['allowed_limit'] == 10
mock_overage.resolve.assert_called_once()
@patch('smoothschedule.identity.core.quota_service.QuotaService')
@patch('smoothschedule.identity.core.models.QuotaOverage')
def test_updates_overage_when_still_over_limit(
self, mock_overage_model, mock_quota_service
):
"""Updates overage when still over limit"""
from smoothschedule.identity.core.tasks import resolve_tenant_overage
mock_tenant = Mock()
mock_overage = Mock()
mock_overage.id = 1
mock_overage.status = 'ACTIVE'
mock_overage.tenant = mock_tenant
mock_overage.quota_type = 'MAX_STAFF'
mock_overage_model.objects.select_related.return_value.get.return_value = mock_overage
mock_overage_model.DoesNotExist = ObjectDoesNotExist
mock_service = Mock()
mock_service.get_current_usage.return_value = 15
mock_service.get_limit.return_value = 10
mock_quota_service.return_value = mock_service
result = resolve_tenant_overage(1)
assert result['resolved'] is False
assert result['still_over_by'] == 5
mock_overage.save.assert_called_once()
assert mock_overage.current_usage == 15
assert mock_overage.overage_amount == 5
class TestCleanupOldResolvedOverages:
"""Unit tests for cleanup_old_resolved_overages task"""
@patch('smoothschedule.identity.core.models.QuotaOverage')
def test_deletes_old_resolved_overages(self, mock_overage_model):
"""Deletes resolved overages older than specified days"""
from smoothschedule.identity.core.tasks import cleanup_old_resolved_overages
mock_overage_model.objects.filter.return_value.delete.return_value = (5, {})
result = cleanup_old_resolved_overages(days_to_keep=90)
assert result == 5
mock_overage_model.objects.filter.assert_called_once()
@patch('smoothschedule.identity.core.models.QuotaOverage')
def test_uses_default_days_to_keep(self, mock_overage_model):
"""Uses default 90 days when not specified"""
from smoothschedule.identity.core.tasks import cleanup_old_resolved_overages
mock_overage_model.objects.filter.return_value.delete.return_value = (3, {})
result = cleanup_old_resolved_overages()
assert result == 3
@patch('smoothschedule.identity.core.models.QuotaOverage')
def test_only_deletes_resolved_archived_cancelled(self, mock_overage_model):
"""Only deletes overages with RESOLVED, ARCHIVED, or CANCELLED status"""
from smoothschedule.identity.core.tasks import cleanup_old_resolved_overages
mock_filter = Mock()
mock_filter.delete.return_value = (0, {})
mock_overage_model.objects.filter.return_value = mock_filter
cleanup_old_resolved_overages()
# Verify filter was called with correct status values
call_kwargs = mock_overage_model.objects.filter.call_args[1]
assert 'status__in' in call_kwargs
assert set(call_kwargs['status__in']) == {'RESOLVED', 'ARCHIVED', 'CANCELLED'}
@patch('smoothschedule.identity.core.models.QuotaOverage')
def test_returns_zero_when_nothing_to_delete(self, mock_overage_model):
"""Returns 0 when no overages match criteria"""
from smoothschedule.identity.core.tasks import cleanup_old_resolved_overages
mock_overage_model.objects.filter.return_value.delete.return_value = (0, {})
result = cleanup_old_resolved_overages()
assert result == 0

View File

@@ -0,0 +1,78 @@
"""
Migration to remove the Support Staff role.
The Support Staff role is being removed - only Manager and Staff roles are needed.
Staff members assigned to Support Staff will be migrated to the Staff role.
"""
from django.db import migrations
def remove_support_staff_role(apps, schema_editor):
"""Remove Support Staff role and migrate users to Staff role."""
StaffRole = apps.get_model('users', 'StaffRole')
User = apps.get_model('users', 'User')
# Get all tenants that have both Support Staff and Staff roles
support_staff_roles = StaffRole.objects.filter(
name='Support Staff',
is_default=True
)
for support_role in support_staff_roles:
# Find the Staff role for the same tenant
staff_role = StaffRole.objects.filter(
tenant=support_role.tenant,
name='Staff',
is_default=True
).first()
if staff_role:
# Migrate users from Support Staff to Staff
User.objects.filter(staff_role=support_role).update(staff_role=staff_role)
# Delete the Support Staff role
support_role.delete()
def reverse_migration(apps, schema_editor):
"""Recreate Support Staff role."""
StaffRole = apps.get_model('users', 'StaffRole')
Tenant = apps.get_model('core', 'Tenant')
support_staff_permissions = {
'can_access_dashboard': True,
'can_access_scheduler': True,
'can_access_my_schedule': True,
'can_access_my_availability': True,
'can_access_customers': True,
'can_access_tickets': True,
'can_access_messages': True,
'can_access_payments': True,
'can_cancel_appointments': True,
}
for tenant in Tenant.objects.all():
StaffRole.objects.get_or_create(
tenant=tenant,
name='Support Staff',
is_default=True,
defaults={
'description': 'Customer-facing operations and scheduling',
'permissions': support_staff_permissions,
}
)
class Migration(migrations.Migration):
dependencies = [
('users', '0016_rename_default_staff_roles'),
]
operations = [
migrations.RunPython(
remove_support_staff_role,
reverse_code=reverse_migration,
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.8 on 2025-12-24 04:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0030_add_sidebar_text_color'),
('users', '0017_remove_support_staff_role'),
]
operations = [
migrations.AlterModelOptions(
name='staffrole',
options={'ordering': ['position', '-is_default', 'name']},
),
migrations.AddField(
model_name='staffrole',
name='position',
field=models.PositiveIntegerField(default=0, help_text='Position in role hierarchy (lower = higher priority)'),
),
migrations.AddIndex(
model_name='staffrole',
index=models.Index(fields=['tenant', 'position'], name='users_staff_tenant__81ba45_idx'),
),
]

View File

@@ -623,16 +623,23 @@ class StaffRole(models.Model):
help_text="True for system-created default roles"
)
# Position for ordering in hierarchy (lower = higher priority)
position = models.PositiveIntegerField(
default=0,
help_text="Position in role hierarchy (lower = higher priority)"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'users'
ordering = ['-is_default', 'name']
ordering = ['position', '-is_default', 'name']
unique_together = [['tenant', 'name']]
indexes = [
models.Index(fields=['tenant', 'is_default']),
models.Index(fields=['tenant', 'position']),
]
def __str__(self):

View File

@@ -265,20 +265,6 @@ DEFAULT_ROLES = {
'description': 'Full access to all features and settings',
'permissions': {k: True for k in ALL_PERMISSIONS.keys()},
},
'Support Staff': {
'description': 'Customer-facing operations and scheduling',
'permissions': {
'can_access_dashboard': True,
'can_access_scheduler': True,
'can_access_my_schedule': True,
'can_access_my_availability': True,
'can_access_customers': True,
'can_access_tickets': True,
'can_access_messages': True,
'can_access_payments': True,
'can_cancel_appointments': True,
},
},
'Staff': {
'description': 'Basic access to own schedule and availability',
'permissions': {

View File

@@ -286,7 +286,6 @@ class TestDefaultRoles:
from smoothschedule.identity.users.staff_permissions import DEFAULT_ROLES
assert 'Manager' in DEFAULT_ROLES
assert 'Support Staff' in DEFAULT_ROLES
assert 'Staff' in DEFAULT_ROLES
def test_manager_has_all_permissions(self):

View File

@@ -0,0 +1,366 @@
"""
Tests for Activepieces Celery tasks.
Tests cover:
- reconcile_automation_run_counts task
- reset_monthly_run_counters task
- get_tenant_automation_usage task
- _get_flow_run_count helper function
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from datetime import date, datetime
from django.utils import timezone
class TestReconcileAutomationRunCounts:
"""Unit tests for reconcile_automation_run_counts task"""
@patch('smoothschedule.integrations.activepieces.models.TenantActivepiecesProject')
@patch('smoothschedule.integrations.activepieces.services.get_activepieces_client')
def test_returns_results_with_zero_counts_when_no_projects(
self, mock_get_client, mock_project_model
):
"""Returns empty results when no projects exist"""
from smoothschedule.integrations.activepieces.tasks import reconcile_automation_run_counts
mock_project_model.objects.select_related.return_value.all.return_value = []
mock_client = Mock()
mock_get_client.return_value = mock_client
result = reconcile_automation_run_counts()
assert result['tenants_checked'] == 0
assert result['flows_checked'] == 0
assert result['flows_updated'] == 0
assert result['errors'] == []
@patch('smoothschedule.integrations.activepieces.models.TenantDefaultFlow')
@patch('smoothschedule.integrations.activepieces.models.TenantActivepiecesProject')
@patch('smoothschedule.integrations.activepieces.services.get_activepieces_client')
def test_skips_tenant_without_session_token(
self, mock_get_client, mock_project_model, mock_flow_model
):
"""Skips tenants where session token cannot be obtained"""
from smoothschedule.integrations.activepieces.tasks import reconcile_automation_run_counts
mock_tenant = Mock()
mock_tenant.schema_name = 'test_tenant'
mock_project = Mock()
mock_project.tenant = mock_tenant
mock_project_model.objects.select_related.return_value.all.return_value = [mock_project]
mock_client = Mock()
mock_client.get_session_token.return_value = (None, None)
mock_get_client.return_value = mock_client
result = reconcile_automation_run_counts()
assert result['tenants_checked'] == 1
assert result['flows_checked'] == 0
mock_flow_model.objects.filter.assert_not_called()
@patch('smoothschedule.integrations.activepieces.tasks._get_flow_run_count')
@patch('smoothschedule.integrations.activepieces.models.TenantDefaultFlow')
@patch('smoothschedule.integrations.activepieces.models.TenantActivepiecesProject')
@patch('smoothschedule.integrations.activepieces.services.get_activepieces_client')
def test_updates_flow_when_run_count_differs(
self, mock_get_client, mock_project_model, mock_flow_model, mock_get_run_count
):
"""Updates flow when API run count differs from local count"""
from smoothschedule.integrations.activepieces.tasks import reconcile_automation_run_counts
mock_tenant = Mock()
mock_tenant.schema_name = 'test_tenant'
mock_project = Mock()
mock_project.tenant = mock_tenant
mock_project_model.objects.select_related.return_value.all.return_value = [mock_project]
mock_client = Mock()
mock_client.get_session_token.return_value = ('test_token', 'project_123')
mock_get_client.return_value = mock_client
mock_flow = Mock()
mock_flow.flow_type = 'appointment_created'
mock_flow.runs_this_month = 5
mock_flow.activepieces_flow_id = 'flow_123'
mock_flow_model.objects.filter.return_value = [mock_flow]
mock_get_run_count.return_value = 10 # Different from local count
result = reconcile_automation_run_counts()
assert result['tenants_checked'] == 1
assert result['flows_checked'] == 1
assert result['flows_updated'] == 1
mock_flow.save.assert_called_once()
assert mock_flow.runs_this_month == 10
@patch('smoothschedule.integrations.activepieces.tasks._get_flow_run_count')
@patch('smoothschedule.integrations.activepieces.models.TenantDefaultFlow')
@patch('smoothschedule.integrations.activepieces.models.TenantActivepiecesProject')
@patch('smoothschedule.integrations.activepieces.services.get_activepieces_client')
def test_does_not_update_when_counts_match(
self, mock_get_client, mock_project_model, mock_flow_model, mock_get_run_count
):
"""Does not update flow when counts already match"""
from smoothschedule.integrations.activepieces.tasks import reconcile_automation_run_counts
mock_tenant = Mock()
mock_tenant.schema_name = 'test_tenant'
mock_project = Mock()
mock_project.tenant = mock_tenant
mock_project_model.objects.select_related.return_value.all.return_value = [mock_project]
mock_client = Mock()
mock_client.get_session_token.return_value = ('test_token', 'project_123')
mock_get_client.return_value = mock_client
mock_flow = Mock()
mock_flow.flow_type = 'appointment_created'
mock_flow.runs_this_month = 10
mock_flow.activepieces_flow_id = 'flow_123'
mock_flow_model.objects.filter.return_value = [mock_flow]
mock_get_run_count.return_value = 10 # Same as local count
result = reconcile_automation_run_counts()
assert result['flows_updated'] == 0
mock_flow.save.assert_not_called()
@patch('smoothschedule.integrations.activepieces.models.TenantActivepiecesProject')
@patch('smoothschedule.integrations.activepieces.services.get_activepieces_client')
@patch('smoothschedule.integrations.activepieces.services.ActivepiecesError', new_callable=lambda: type('ActivepiecesError', (Exception,), {}))
def test_handles_activepieces_error(
self, mock_error_class, mock_get_client, mock_project_model
):
"""Records error when ActivepiecesError is raised"""
from smoothschedule.integrations.activepieces.tasks import reconcile_automation_run_counts
mock_tenant = Mock()
mock_tenant.schema_name = 'test_tenant'
mock_project = Mock()
mock_project.tenant = mock_tenant
mock_project_model.objects.select_related.return_value.all.return_value = [mock_project]
mock_client = Mock()
mock_client.get_session_token.side_effect = Exception("API error")
mock_get_client.return_value = mock_client
result = reconcile_automation_run_counts()
assert result['tenants_checked'] == 1
assert len(result['errors']) == 1
assert 'test_tenant' in result['errors'][0]
class TestGetFlowRunCount:
"""Unit tests for _get_flow_run_count helper function"""
def test_returns_count_of_successful_runs(self):
"""Returns count of runs with SUCCEEDED status"""
from smoothschedule.integrations.activepieces.tasks import _get_flow_run_count
mock_client = Mock()
mock_client._request.return_value = {
'data': [
{'status': 'SUCCEEDED'},
{'status': 'SUCCEEDED'},
{'status': 'FAILED'},
{'status': 'SUCCEEDED'},
]
}
since = timezone.now()
result = _get_flow_run_count(mock_client, 'token', 'flow_123', since)
assert result == 3
def test_returns_zero_for_empty_runs(self):
"""Returns 0 when no runs exist"""
from smoothschedule.integrations.activepieces.tasks import _get_flow_run_count
mock_client = Mock()
mock_client._request.return_value = {'data': []}
since = timezone.now()
result = _get_flow_run_count(mock_client, 'token', 'flow_123', since)
assert result == 0
def test_returns_none_on_exception(self):
"""Returns None when request fails"""
from smoothschedule.integrations.activepieces.tasks import _get_flow_run_count
mock_client = Mock()
mock_client._request.side_effect = Exception("Network error")
since = timezone.now()
result = _get_flow_run_count(mock_client, 'token', 'flow_123', since)
assert result is None
class TestResetMonthlyRunCounters:
"""Unit tests for reset_monthly_run_counters task"""
@patch('smoothschedule.integrations.activepieces.models.TenantDefaultFlow')
def test_resets_flows_from_previous_month(self, mock_flow_model):
"""Resets flows that have runs from a previous month"""
from smoothschedule.integrations.activepieces.tasks import reset_monthly_run_counters
mock_flow1 = Mock()
mock_flow1.runs_this_month = 15
mock_flow2 = Mock()
mock_flow2.runs_this_month = 8
mock_flow_model.objects.exclude.return_value.filter.return_value = [
mock_flow1, mock_flow2
]
result = reset_monthly_run_counters()
assert result['flows_reset'] == 2
assert result['errors'] == []
assert mock_flow1.runs_this_month == 0
assert mock_flow2.runs_this_month == 0
mock_flow1.save.assert_called_once()
mock_flow2.save.assert_called_once()
@patch('smoothschedule.integrations.activepieces.models.TenantDefaultFlow')
def test_returns_zero_when_no_flows_to_reset(self, mock_flow_model):
"""Returns zero when all flows are current"""
from smoothschedule.integrations.activepieces.tasks import reset_monthly_run_counters
mock_flow_model.objects.exclude.return_value.filter.return_value = []
result = reset_monthly_run_counters()
assert result['flows_reset'] == 0
assert result['errors'] == []
@patch('smoothschedule.integrations.activepieces.models.TenantDefaultFlow')
def test_handles_exception_during_reset(self, mock_flow_model):
"""Records error when exception occurs"""
from smoothschedule.integrations.activepieces.tasks import reset_monthly_run_counters
mock_flow_model.objects.exclude.side_effect = Exception("Database error")
result = reset_monthly_run_counters()
assert result['flows_reset'] == 0
assert len(result['errors']) == 1
assert 'Database error' in result['errors'][0]
class TestGetTenantAutomationUsage:
"""Unit tests for get_tenant_automation_usage task"""
@patch('smoothschedule.integrations.activepieces.models.TenantDefaultFlow')
@patch('smoothschedule.identity.core.quota_service.QuotaService')
@patch('smoothschedule.identity.core.models.Tenant')
def test_returns_usage_summary_for_tenant(
self, mock_tenant_model, mock_quota_service, mock_flow_model
):
"""Returns complete usage summary for a valid tenant"""
from smoothschedule.integrations.activepieces.tasks import get_tenant_automation_usage
from django.core.exceptions import ObjectDoesNotExist
mock_tenant = Mock()
mock_tenant.id = 1
mock_tenant_model.objects.get.return_value = mock_tenant
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
mock_service = Mock()
mock_service.get_current_usage.return_value = 50
mock_service.get_limit.return_value = 100
mock_quota_service.return_value = mock_service
mock_flow_model.objects.filter.return_value.values.return_value = [
{'flow_type': 'appointment_created', 'runs_this_month': 30, 'is_enabled': True},
{'flow_type': 'appointment_cancelled', 'runs_this_month': 20, 'is_enabled': False},
]
result = get_tenant_automation_usage(1)
assert result['tenant_id'] == 1
assert result['total_runs'] == 50
assert result['limit'] == 100
assert result['remaining'] == 50
assert result['is_unlimited'] is False
assert 'appointment_created' in result['flows']
assert result['flows']['appointment_created']['runs'] == 30
@patch('smoothschedule.identity.core.models.Tenant')
def test_returns_error_for_nonexistent_tenant(self, mock_tenant_model):
"""Returns error when tenant doesn't exist"""
from smoothschedule.integrations.activepieces.tasks import get_tenant_automation_usage
from django.core.exceptions import ObjectDoesNotExist
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
mock_tenant_model.objects.get.side_effect = ObjectDoesNotExist()
result = get_tenant_automation_usage(999)
assert 'error' in result
assert '999' in result['error']
@patch('smoothschedule.integrations.activepieces.models.TenantDefaultFlow')
@patch('smoothschedule.identity.core.quota_service.QuotaService')
@patch('smoothschedule.identity.core.models.Tenant')
def test_returns_unlimited_when_limit_is_negative(
self, mock_tenant_model, mock_quota_service, mock_flow_model
):
"""Correctly identifies unlimited plans (limit < 0)"""
from smoothschedule.integrations.activepieces.tasks import get_tenant_automation_usage
from django.core.exceptions import ObjectDoesNotExist
mock_tenant = Mock()
mock_tenant.id = 1
mock_tenant_model.objects.get.return_value = mock_tenant
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
mock_service = Mock()
mock_service.get_current_usage.return_value = 500
mock_service.get_limit.return_value = -1 # Unlimited
mock_quota_service.return_value = mock_service
mock_flow_model.objects.filter.return_value.values.return_value = []
result = get_tenant_automation_usage(1)
assert result['is_unlimited'] is True
assert result['remaining'] == -1
@patch('smoothschedule.integrations.activepieces.models.TenantDefaultFlow')
@patch('smoothschedule.identity.core.quota_service.QuotaService')
@patch('smoothschedule.identity.core.models.Tenant')
def test_returns_error_on_exception(self, mock_tenant_model, mock_quota_service, mock_flow_model):
"""Returns error when unexpected exception occurs"""
from smoothschedule.integrations.activepieces.tasks import get_tenant_automation_usage
from django.core.exceptions import ObjectDoesNotExist
mock_tenant = Mock()
mock_tenant.id = 1
mock_tenant_model.objects.get.return_value = mock_tenant
# Set DoesNotExist to a proper exception class
mock_tenant_model.DoesNotExist = ObjectDoesNotExist
mock_service = Mock()
mock_service.get_current_usage.side_effect = Exception("Service error")
mock_quota_service.return_value = mock_service
result = get_tenant_automation_usage(1)
assert 'error' in result
assert 'Service error' in result['error']

View File

@@ -807,12 +807,12 @@ class Command(BaseCommand):
"""Assign staff roles to demo staff members."""
staff_users = tenant_users.get("staff", [])
# Role assignments: mix of Manager, Support Staff, and Staff roles
# Role assignments: mix of Manager and Staff roles
role_assignments = {
0: "Manager", # Sophia - Senior Stylist gets manager role
1: "Support Staff", # Emma - handles front desk duties
1: "Staff", # Emma - basic access
2: "Staff", # Olivia - basic access
3: "Support Staff", # Isabella - handles customers
3: "Staff", # Isabella - basic access
4: "Staff", # Mia - basic access
}

View File

@@ -133,7 +133,7 @@ class StaffRoleSerializer(serializers.ModelSerializer):
model = StaffRole
fields = [
'id', 'name', 'description', 'permissions', 'is_default',
'staff_count', 'can_delete', 'created_at', 'updated_at',
'position', 'staff_count', 'can_delete', 'created_at', 'updated_at',
]
read_only_fields = ['id', 'is_default', 'staff_count', 'can_delete', 'created_at', 'updated_at']

View File

@@ -101,7 +101,7 @@ class StaffRoleViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
queryset = StaffRole.objects.all()
serializer_class = StaffRoleSerializer
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
ordering = ['-is_default', 'name']
ordering = ['position', '-is_default', 'name']
def filter_queryset_for_tenant(self, queryset):
"""
@@ -169,6 +169,51 @@ class StaffRoleViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
'dangerous_permissions': DANGEROUS_PERMISSIONS,
})
@action(detail=False, methods=['post'])
def reorder(self, request):
"""
Reorder staff roles by updating their positions.
Expects: { "role_ids": [1, 3, 2, 4] }
The order in the array determines the new position (0-indexed).
"""
role_ids = request.data.get('role_ids', [])
if not isinstance(role_ids, list):
return Response(
{'error': 'role_ids must be an array of role IDs.'},
status=status.HTTP_400_BAD_REQUEST
)
tenant = getattr(request, 'tenant', None)
if not tenant:
return Response(
{'error': 'Tenant context required.'},
status=status.HTTP_400_BAD_REQUEST
)
# Verify all role_ids belong to this tenant
existing_roles = StaffRole.objects.filter(tenant=tenant, id__in=role_ids)
if existing_roles.count() != len(role_ids):
return Response(
{'error': 'Some role IDs are invalid or do not belong to this tenant.'},
status=status.HTTP_400_BAD_REQUEST
)
# Update positions in a transaction
from django.db import transaction
with transaction.atomic():
for position, role_id in enumerate(role_ids):
StaffRole.objects.filter(id=role_id, tenant=tenant).update(position=position)
# Return updated list
from django.db.models import Count
queryset = StaffRole.objects.filter(tenant=tenant).annotate(
staff_count=Count('staff_members')
).order_by('position', '-is_default', 'name')
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class ResourceViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
"""

View File

@@ -0,0 +1 @@
# Tenant API - Isolated API for third-party tenant integrations

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class TenantApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "smoothschedule.tenant_api"
verbose_name = "Tenant API"

View File

@@ -0,0 +1,234 @@
"""
Tenant API Serializers
Isolated serializers for the tenant remote API with controlled field exposure.
These serializers only expose what's needed for third-party integrations.
"""
from rest_framework import serializers
from smoothschedule.scheduling.schedule.models import Service, Resource, Event, Participant
from smoothschedule.identity.users.models import User
from smoothschedule.identity.core.models import Tenant
from smoothschedule.platform.api.models import WebhookSubscription, WebhookEvent
class TenantBusinessSerializer(serializers.Serializer):
"""Business info serializer with limited fields."""
name = serializers.CharField()
timezone = serializers.CharField()
logo = serializers.SerializerMethodField()
primary_color = serializers.CharField(source='branding_primary_color')
secondary_color = serializers.CharField(source='branding_secondary_color')
def get_logo(self, obj):
if obj.logo:
return obj.logo.url
return None
class TenantServiceSerializer(serializers.ModelSerializer):
"""Service serializer with controlled field exposure."""
photos = serializers.SerializerMethodField()
price = serializers.IntegerField(source='price_cents', read_only=True)
class Meta:
model = Service
fields = [
'id',
'name',
'description',
'duration',
'price', # price in cents
'photos',
'is_active',
'variable_pricing',
'requires_manual_scheduling',
'display_order',
]
read_only_fields = fields
def get_photos(self, obj):
"""Return list of photo URLs."""
if obj.photos:
return obj.photos
return []
class TenantResourceSerializer(serializers.ModelSerializer):
"""Resource serializer with controlled field exposure."""
photo = serializers.SerializerMethodField()
class Meta:
model = Resource
fields = [
'id',
'name',
'type',
'photo',
'is_active',
]
read_only_fields = fields
def get_photo(self, obj):
if obj.photo:
return obj.photo.url
return None
class TimeSlotSerializer(serializers.Serializer):
"""Individual time slot for availability."""
start = serializers.DateTimeField()
end = serializers.DateTimeField()
available = serializers.BooleanField(default=True)
class TenantAvailabilitySerializer(serializers.Serializer):
"""Availability response serializer."""
date = serializers.DateField()
service_id = serializers.IntegerField()
resource_id = serializers.IntegerField(required=False, allow_null=True)
slots = TimeSlotSerializer(many=True)
class TenantCustomerSerializer(serializers.ModelSerializer):
"""Customer serializer with controlled field exposure."""
class Meta:
model = User
fields = [
'id',
'first_name',
'last_name',
'email',
'phone_number',
]
read_only_fields = ['id']
class TenantCustomerCreateSerializer(serializers.Serializer):
"""Serializer for creating a customer."""
first_name = serializers.CharField(max_length=150)
last_name = serializers.CharField(max_length=150)
email = serializers.EmailField()
phone_number = serializers.CharField(max_length=20, required=False, allow_blank=True)
class TenantBookingSerializer(serializers.ModelSerializer):
"""Booking/Event serializer with controlled field exposure."""
service = TenantServiceSerializer(read_only=True)
resource = serializers.SerializerMethodField()
customer = serializers.SerializerMethodField()
class Meta:
model = Event
fields = [
'id',
'title',
'start_time',
'end_time',
'status',
'service',
'resource',
'customer',
'notes',
'created_at',
'updated_at',
]
read_only_fields = fields
def get_resource(self, obj):
# Get the first resource participant
resource_participant = obj.participants.filter(
participant_type=Participant.ParticipantType.RESOURCE
).select_related('resource').first()
if resource_participant and resource_participant.resource:
return TenantResourceSerializer(resource_participant.resource).data
return None
def get_customer(self, obj):
# Get the customer participant
customer_participant = obj.participants.filter(
participant_type=Participant.ParticipantType.CUSTOMER
).select_related('user').first()
if customer_participant and customer_participant.user:
return TenantCustomerSerializer(customer_participant.user).data
return None
class TenantBookingCreateSerializer(serializers.Serializer):
"""Serializer for creating a booking."""
service_id = serializers.IntegerField()
resource_id = serializers.IntegerField(required=False, allow_null=True)
customer_id = serializers.IntegerField(required=False, allow_null=True)
customer_email = serializers.EmailField(required=False, allow_blank=True)
customer_first_name = serializers.CharField(max_length=150, required=False, allow_blank=True)
customer_last_name = serializers.CharField(max_length=150, required=False, allow_blank=True)
customer_phone = serializers.CharField(max_length=20, required=False, allow_blank=True)
start_time = serializers.DateTimeField()
notes = serializers.CharField(required=False, allow_blank=True)
def validate(self, data):
# Must have either customer_id or customer_email
if not data.get('customer_id') and not data.get('customer_email'):
raise serializers.ValidationError(
"Either customer_id or customer_email is required"
)
return data
class TenantBookingUpdateSerializer(serializers.Serializer):
"""Serializer for updating a booking."""
start_time = serializers.DateTimeField(required=False)
notes = serializers.CharField(required=False, allow_blank=True)
status = serializers.ChoiceField(
choices=[
('CONFIRMED', 'Confirmed'),
('CANCELLED', 'Cancelled'),
('COMPLETED', 'Completed'),
],
required=False
)
class TenantWebhookSerializer(serializers.ModelSerializer):
"""Webhook subscription serializer."""
class Meta:
model = WebhookSubscription
fields = [
'id',
'url',
'events',
'is_active',
'description',
'created_at',
]
read_only_fields = ['id', 'created_at']
class TenantWebhookCreateSerializer(serializers.Serializer):
"""Serializer for creating a webhook subscription."""
url = serializers.URLField()
events = serializers.ListField(
child=serializers.ChoiceField(choices=[
(e, e) for e in WebhookEvent.ALL_EVENTS
])
)
description = serializers.CharField(required=False, allow_blank=True)
class TenantWebhookWithSecretSerializer(serializers.ModelSerializer):
"""Webhook serializer that includes the secret (only shown on creation)."""
class Meta:
model = WebhookSubscription
fields = [
'id',
'url',
'events',
'secret',
'is_active',
'description',
'created_at',
]
read_only_fields = ['id', 'secret', 'created_at']

View File

@@ -0,0 +1,41 @@
"""
Tenant API URL Configuration
This is the isolated public API for tenant third-party integrations.
All endpoints require API token authentication.
Base URL: /tenant-api/v1/
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
TenantBusinessView,
TenantServiceViewSet,
TenantResourceViewSet,
TenantAvailabilityView,
TenantBookingViewSet,
TenantCustomerViewSet,
TenantWebhookViewSet,
)
app_name = 'tenant_api'
router = DefaultRouter()
router.register(r'services', TenantServiceViewSet, basename='services')
router.register(r'resources', TenantResourceViewSet, basename='resources')
router.register(r'bookings', TenantBookingViewSet, basename='bookings')
router.register(r'customers', TenantCustomerViewSet, basename='customers')
router.register(r'webhooks', TenantWebhookViewSet, basename='webhooks')
urlpatterns = [
# Business info
path('business/', TenantBusinessView.as_view(), name='business'),
# Availability check
path('availability/', TenantAvailabilityView.as_view(), name='availability'),
# ViewSet routes
path('', include(router.urls)),
]

View File

@@ -0,0 +1,781 @@
"""
Tenant API Views
Isolated views for the tenant remote API. These views are separate from
the internal dashboard API and the platform API.
All views require API token authentication with appropriate scopes.
"""
from django.db.models import Q
from django.utils import timezone
from rest_framework import viewsets, views, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django_tenants.utils import schema_context
from smoothschedule.platform.api.authentication import APITokenAuthentication
from smoothschedule.platform.api.permissions import (
HasAPIToken,
HasScope,
CanReadBusiness,
CanReadServices,
CanReadResources,
CanReadAvailability,
BookingsReadWritePermission,
CustomersReadWritePermission,
CanManageWebhooks,
)
from smoothschedule.platform.api.throttling import GlobalBurstRateThrottle
from smoothschedule.platform.api.models import APIScope, WebhookSubscription, WebhookEvent
from smoothschedule.scheduling.schedule.models import Service, Resource, Event, Participant
from smoothschedule.scheduling.schedule.services import AvailabilityService
from smoothschedule.identity.users.models import User
from .serializers import (
TenantBusinessSerializer,
TenantServiceSerializer,
TenantResourceSerializer,
TenantAvailabilitySerializer,
TenantBookingSerializer,
TenantBookingCreateSerializer,
TenantBookingUpdateSerializer,
TenantCustomerSerializer,
TenantCustomerCreateSerializer,
TenantWebhookSerializer,
TenantWebhookCreateSerializer,
TenantWebhookWithSecretSerializer,
)
class TenantAPIViewMixin:
"""
Base mixin for all tenant API views.
Provides:
- API token authentication
- Rate limiting
- Tenant context from token
"""
authentication_classes = [APITokenAuthentication]
throttle_classes = [GlobalBurstRateThrottle]
def get_tenant(self):
"""Get the tenant from the API token."""
if hasattr(self.request, 'tenant'):
return self.request.tenant
return None
class TenantBusinessView(TenantAPIViewMixin, views.APIView):
"""
GET /tenant-api/v1/business/
Returns business information for the tenant.
Requires scope: business:read
"""
permission_classes = [HasAPIToken, CanReadBusiness]
def get(self, request):
tenant = self.get_tenant()
if not tenant:
return Response(
{'error': 'Tenant not found'},
status=status.HTTP_404_NOT_FOUND
)
serializer = TenantBusinessSerializer(tenant)
return Response(serializer.data)
class TenantServiceViewSet(TenantAPIViewMixin, viewsets.ReadOnlyModelViewSet):
"""
GET /tenant-api/v1/services/
GET /tenant-api/v1/services/{id}/
List and retrieve services.
Requires scope: services:read
"""
permission_classes = [HasAPIToken, CanReadServices]
serializer_class = TenantServiceSerializer
def get_queryset(self):
tenant = self.get_tenant()
if not tenant:
return Service.objects.none()
with schema_context(tenant.schema_name):
queryset = Service.objects.filter(is_active=True)
# Search by name (partial match)
search = self.request.query_params.get('search')
if search:
queryset = queryset.filter(name__icontains=search)
# Exact name match
name = self.request.query_params.get('name')
if name:
queryset = queryset.filter(name__iexact=name)
# Price filtering (in cents) - supports comparison operators
# price=1000 (exact), price__lt=1000, price__lte=1000, price__gt=1000, price__gte=1000
price = self.request.query_params.get('price')
if price:
queryset = queryset.filter(price_cents=int(price))
price_lt = self.request.query_params.get('price__lt')
if price_lt:
queryset = queryset.filter(price_cents__lt=int(price_lt))
price_lte = self.request.query_params.get('price__lte')
if price_lte:
queryset = queryset.filter(price_cents__lte=int(price_lte))
price_gt = self.request.query_params.get('price__gt')
if price_gt:
queryset = queryset.filter(price_cents__gt=int(price_gt))
price_gte = self.request.query_params.get('price__gte')
if price_gte:
queryset = queryset.filter(price_cents__gte=int(price_gte))
# Duration filtering (in minutes) - supports comparison operators
# duration=60 (exact), duration__lt=60, duration__lte=60, duration__gt=60, duration__gte=60
duration = self.request.query_params.get('duration')
if duration:
queryset = queryset.filter(duration=int(duration))
duration_lt = self.request.query_params.get('duration__lt')
if duration_lt:
queryset = queryset.filter(duration__lt=int(duration_lt))
duration_lte = self.request.query_params.get('duration__lte')
if duration_lte:
queryset = queryset.filter(duration__lte=int(duration_lte))
duration_gt = self.request.query_params.get('duration__gt')
if duration_gt:
queryset = queryset.filter(duration__gt=int(duration_gt))
duration_gte = self.request.query_params.get('duration__gte')
if duration_gte:
queryset = queryset.filter(duration__gte=int(duration_gte))
# Boolean field filters
variable_pricing = self.request.query_params.get('variable_pricing')
if variable_pricing is not None:
queryset = queryset.filter(variable_pricing=variable_pricing.lower() == 'true')
requires_manual_scheduling = self.request.query_params.get('requires_manual_scheduling')
if requires_manual_scheduling is not None:
queryset = queryset.filter(requires_manual_scheduling=requires_manual_scheduling.lower() == 'true')
# Sorting - use price_cents for price ordering
ordering = self.request.query_params.get('ordering', 'name')
ordering_map = {
'price': 'price_cents',
'-price': '-price_cents',
'name': 'name',
'-name': '-name',
'duration': 'duration',
'-duration': '-duration',
'display_order': 'display_order',
'-display_order': '-display_order',
}
if ordering in ordering_map:
queryset = queryset.order_by(ordering_map[ordering])
else:
queryset = queryset.order_by('name')
return queryset
class TenantResourceViewSet(TenantAPIViewMixin, viewsets.ReadOnlyModelViewSet):
"""
GET /tenant-api/v1/resources/
GET /tenant-api/v1/resources/{id}/
List and retrieve resources.
Requires scope: resources:read
"""
permission_classes = [HasAPIToken, CanReadResources]
serializer_class = TenantResourceSerializer
def get_queryset(self):
tenant = self.get_tenant()
if not tenant:
return Resource.objects.none()
with schema_context(tenant.schema_name):
queryset = Resource.objects.filter(is_active=True)
# Search by name
search = self.request.query_params.get('search')
if search:
queryset = queryset.filter(name__icontains=search)
# Filter by type
resource_type = self.request.query_params.get('type')
if resource_type:
queryset = queryset.filter(type=resource_type.upper())
# Sorting
ordering = self.request.query_params.get('ordering', 'name')
allowed_ordering = ['name', '-name', 'type', '-type']
if ordering in allowed_ordering:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('name')
return queryset
class TenantAvailabilityView(TenantAPIViewMixin, views.APIView):
"""
GET /tenant-api/v1/availability/
Check available time slots for a service on a given date.
Requires scope: availability:read
Query params:
- service_id: Required. The service to check availability for.
- date: Required. The date to check (YYYY-MM-DD).
- resource_id: Optional. Specific resource to check.
"""
permission_classes = [HasAPIToken, CanReadAvailability]
def get(self, request):
tenant = self.get_tenant()
if not tenant:
return Response(
{'error': 'Tenant not found'},
status=status.HTTP_404_NOT_FOUND
)
service_id = request.query_params.get('service_id')
date_str = request.query_params.get('date')
resource_id = request.query_params.get('resource_id')
if not service_id:
return Response(
{'error': 'service_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
if not date_str:
return Response(
{'error': 'date is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
from datetime import datetime
date = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
return Response(
{'error': 'Invalid date format. Use YYYY-MM-DD.'},
status=status.HTTP_400_BAD_REQUEST
)
with schema_context(tenant.schema_name):
try:
service = Service.objects.get(id=service_id, is_active=True)
except Service.DoesNotExist:
return Response(
{'error': 'Service not found'},
status=status.HTTP_404_NOT_FOUND
)
resource = None
if resource_id:
try:
resource = Resource.objects.get(id=resource_id, is_active=True)
except Resource.DoesNotExist:
return Response(
{'error': 'Resource not found'},
status=status.HTTP_404_NOT_FOUND
)
# Use AvailabilityService to check slots
availability_service = AvailabilityService()
slots = availability_service.get_available_slots(
service=service,
date=date,
resource=resource,
tenant=tenant
)
return Response({
'date': date_str,
'service_id': int(service_id),
'resource_id': int(resource_id) if resource_id else None,
'slots': slots
})
class TenantBookingViewSet(TenantAPIViewMixin, viewsets.ModelViewSet):
"""
CRUD operations for bookings (events).
GET /tenant-api/v1/bookings/ - List bookings (bookings:read)
GET /tenant-api/v1/bookings/{id}/ - Get booking (bookings:read)
POST /tenant-api/v1/bookings/ - Create booking (bookings:write)
PATCH /tenant-api/v1/bookings/{id}/ - Update booking (bookings:write)
DELETE /tenant-api/v1/bookings/{id}/ - Cancel booking (bookings:write)
"""
permission_classes = [HasAPIToken, BookingsReadWritePermission]
def get_serializer_class(self):
if self.action == 'create':
return TenantBookingCreateSerializer
if self.action in ['update', 'partial_update']:
return TenantBookingUpdateSerializer
return TenantBookingSerializer
def get_queryset(self):
tenant = self.get_tenant()
if not tenant:
return Event.objects.none()
with schema_context(tenant.schema_name):
queryset = Event.objects.select_related('service').prefetch_related(
'participants__resource',
'participants__user'
)
# Filter by status if provided
status_filter = self.request.query_params.get('status')
if status_filter:
queryset = queryset.filter(status=status_filter.upper())
# Filter by date range (date only)
start_date = self.request.query_params.get('start_date')
end_date = self.request.query_params.get('end_date')
if start_date:
queryset = queryset.filter(start_time__date__gte=start_date)
if end_date:
queryset = queryset.filter(start_time__date__lte=end_date)
# Filter by datetime range (precise datetime)
from datetime import datetime
start_datetime = self.request.query_params.get('start_datetime')
end_datetime = self.request.query_params.get('end_datetime')
if start_datetime:
try:
dt = datetime.fromisoformat(start_datetime.replace('Z', '+00:00'))
queryset = queryset.filter(start_time__gte=dt)
except ValueError:
pass
if end_datetime:
try:
dt = datetime.fromisoformat(end_datetime.replace('Z', '+00:00'))
queryset = queryset.filter(start_time__lte=dt)
except ValueError:
pass
# Filter by service
service_id = self.request.query_params.get('service_id')
if service_id:
queryset = queryset.filter(service_id=service_id)
# Filter by customer
customer_id = self.request.query_params.get('customer_id')
if customer_id:
queryset = queryset.filter(
participants__user_id=customer_id,
participants__participant_type=Participant.ParticipantType.CUSTOMER
)
# Filter by resource
resource_id = self.request.query_params.get('resource_id')
if resource_id:
queryset = queryset.filter(
participants__resource_id=resource_id,
participants__participant_type=Participant.ParticipantType.RESOURCE
)
# Sorting: ordering param (e.g., -start_time, start_time, -created_at)
# Allowed fields: start_time, end_time, created_at, updated_at
ordering = self.request.query_params.get('ordering', '-start_time')
allowed_ordering = ['start_time', '-start_time', 'end_time', '-end_time',
'created_at', '-created_at', 'updated_at', '-updated_at']
if ordering in allowed_ordering:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-start_time')
return queryset
def create(self, request, *args, **kwargs):
"""Create a new booking."""
tenant = self.get_tenant()
if not tenant:
return Response(
{'error': 'Tenant not found'},
status=status.HTTP_404_NOT_FOUND
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
with schema_context(tenant.schema_name):
# Get service
try:
service = Service.objects.get(id=data['service_id'], is_active=True)
except Service.DoesNotExist:
return Response(
{'error': 'Service not found'},
status=status.HTTP_404_NOT_FOUND
)
# Get or create customer
customer = None
if data.get('customer_id'):
try:
customer = User.objects.get(
id=data['customer_id'],
role=User.Role.CUSTOMER
)
except User.DoesNotExist:
return Response(
{'error': 'Customer not found'},
status=status.HTTP_404_NOT_FOUND
)
elif data.get('customer_email'):
customer, _ = User.objects.get_or_create(
email=data['customer_email'],
defaults={
'first_name': data.get('customer_first_name', ''),
'last_name': data.get('customer_last_name', ''),
'phone_number': data.get('customer_phone', ''),
'role': User.Role.CUSTOMER,
'username': data['customer_email'],
}
)
# Get resource if specified
resource = None
if data.get('resource_id'):
try:
resource = Resource.objects.get(
id=data['resource_id'],
is_active=True
)
except Resource.DoesNotExist:
return Response(
{'error': 'Resource not found'},
status=status.HTTP_404_NOT_FOUND
)
# Calculate end time
from datetime import timedelta
start_time = data['start_time']
end_time = start_time + timedelta(minutes=service.duration)
# Create event
event = Event.objects.create(
title=f"{service.name} - {customer.get_full_name() if customer else 'Guest'}",
service=service,
start_time=start_time,
end_time=end_time,
status=Event.Status.CONFIRMED,
notes=data.get('notes', ''),
)
# Create participants
if customer:
Participant.objects.create(
event=event,
user=customer,
participant_type=Participant.ParticipantType.CUSTOMER
)
if resource:
Participant.objects.create(
event=event,
resource=resource,
participant_type=Participant.ParticipantType.RESOURCE
)
# Return created booking
output_serializer = TenantBookingSerializer(event)
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def partial_update(self, request, *args, **kwargs):
"""Update a booking."""
tenant = self.get_tenant()
if not tenant:
return Response(
{'error': 'Tenant not found'},
status=status.HTTP_404_NOT_FOUND
)
with schema_context(tenant.schema_name):
try:
event = Event.objects.get(pk=kwargs['pk'])
except Event.DoesNotExist:
return Response(
{'error': 'Booking not found'},
status=status.HTTP_404_NOT_FOUND
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
# Update fields
if 'start_time' in data:
from datetime import timedelta
event.start_time = data['start_time']
if event.service:
event.end_time = event.start_time + timedelta(
minutes=event.service.duration
)
if 'notes' in data:
event.notes = data['notes']
if 'status' in data:
event.status = data['status']
event.save()
output_serializer = TenantBookingSerializer(event)
return Response(output_serializer.data)
def destroy(self, request, *args, **kwargs):
"""Cancel a booking (set status to CANCELLED)."""
tenant = self.get_tenant()
if not tenant:
return Response(
{'error': 'Tenant not found'},
status=status.HTTP_404_NOT_FOUND
)
with schema_context(tenant.schema_name):
try:
event = Event.objects.get(pk=kwargs['pk'])
except Event.DoesNotExist:
return Response(
{'error': 'Booking not found'},
status=status.HTTP_404_NOT_FOUND
)
event.status = Event.Status.CANCELLED
event.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class TenantCustomerViewSet(TenantAPIViewMixin, viewsets.ModelViewSet):
"""
CRUD operations for customers.
GET /tenant-api/v1/customers/ - List customers (customers:read)
GET /tenant-api/v1/customers/{id}/ - Get customer (customers:read)
POST /tenant-api/v1/customers/ - Create customer (customers:write)
PATCH /tenant-api/v1/customers/{id}/ - Update customer (customers:write)
"""
permission_classes = [HasAPIToken, CustomersReadWritePermission]
http_method_names = ['get', 'post', 'patch', 'head', 'options'] # No DELETE
def get_serializer_class(self):
if self.action == 'create':
return TenantCustomerCreateSerializer
return TenantCustomerSerializer
def get_queryset(self):
tenant = self.get_tenant()
if not tenant:
return User.objects.none()
# Customers are in the public schema but associated with tenant
queryset = User.objects.filter(
role=User.Role.CUSTOMER,
business_subdomain=tenant.schema_name
)
# Search by name or email
search = self.request.query_params.get('search')
if search:
queryset = queryset.filter(
Q(first_name__icontains=search) |
Q(last_name__icontains=search) |
Q(email__icontains=search)
)
# Filter by email
email = self.request.query_params.get('email')
if email:
queryset = queryset.filter(email__iexact=email)
# Sorting: ordering param (e.g., -created_at, last_name)
ordering = self.request.query_params.get('ordering', 'last_name')
allowed_ordering = ['last_name', '-last_name', 'first_name', '-first_name',
'email', '-email', 'date_joined', '-date_joined']
if ordering in allowed_ordering:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('last_name', 'first_name')
return queryset
def create(self, request, *args, **kwargs):
"""Create a new customer."""
tenant = self.get_tenant()
if not tenant:
return Response(
{'error': 'Tenant not found'},
status=status.HTTP_404_NOT_FOUND
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
# Check if customer with email already exists
existing = User.objects.filter(
email=data['email'],
business_subdomain=tenant.schema_name
).first()
if existing:
return Response(
{'error': 'Customer with this email already exists'},
status=status.HTTP_400_BAD_REQUEST
)
customer = User.objects.create(
email=data['email'],
username=data['email'],
first_name=data['first_name'],
last_name=data['last_name'],
phone_number=data.get('phone_number', ''),
role=User.Role.CUSTOMER,
business_subdomain=tenant.schema_name,
)
output_serializer = TenantCustomerSerializer(customer)
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def partial_update(self, request, *args, **kwargs):
"""Update a customer."""
tenant = self.get_tenant()
if not tenant:
return Response(
{'error': 'Tenant not found'},
status=status.HTTP_404_NOT_FOUND
)
try:
customer = User.objects.get(
pk=kwargs['pk'],
role=User.Role.CUSTOMER,
business_subdomain=tenant.schema_name
)
except User.DoesNotExist:
return Response(
{'error': 'Customer not found'},
status=status.HTTP_404_NOT_FOUND
)
# Update allowed fields
for field in ['first_name', 'last_name', 'phone_number']:
if field in request.data:
setattr(customer, field, request.data[field])
customer.save()
output_serializer = TenantCustomerSerializer(customer)
return Response(output_serializer.data)
class TenantWebhookViewSet(TenantAPIViewMixin, viewsets.ModelViewSet):
"""
CRUD operations for webhook subscriptions.
GET /tenant-api/v1/webhooks/ - List webhooks (webhooks:manage)
GET /tenant-api/v1/webhooks/{id}/ - Get webhook (webhooks:manage)
POST /tenant-api/v1/webhooks/ - Create webhook (webhooks:manage)
PATCH /tenant-api/v1/webhooks/{id}/ - Update webhook (webhooks:manage)
DELETE /tenant-api/v1/webhooks/{id}/ - Delete webhook (webhooks:manage)
GET /tenant-api/v1/webhooks/events/ - List available events
"""
permission_classes = [HasAPIToken, CanManageWebhooks]
def get_serializer_class(self):
if self.action == 'create':
return TenantWebhookCreateSerializer
return TenantWebhookSerializer
def get_queryset(self):
tenant = self.get_tenant()
if not tenant:
return WebhookSubscription.objects.none()
# Get webhooks created by this API token
return WebhookSubscription.objects.filter(
tenant=tenant,
api_token=self.request.api_token
).order_by('-created_at')
def create(self, request, *args, **kwargs):
"""Create a new webhook subscription."""
tenant = self.get_tenant()
if not tenant:
return Response(
{'error': 'Tenant not found'},
status=status.HTTP_404_NOT_FOUND
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
webhook = WebhookSubscription.objects.create(
tenant=tenant,
api_token=self.request.api_token,
url=data['url'],
events=data['events'],
description=data.get('description', ''),
secret=WebhookSubscription.generate_secret(),
is_active=True,
)
# Return with secret (only shown on creation)
output_serializer = TenantWebhookWithSecretSerializer(webhook)
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def partial_update(self, request, *args, **kwargs):
"""Update a webhook subscription."""
tenant = self.get_tenant()
if not tenant:
return Response(
{'error': 'Tenant not found'},
status=status.HTTP_404_NOT_FOUND
)
try:
webhook = WebhookSubscription.objects.get(
pk=kwargs['pk'],
tenant=tenant,
api_token=self.request.api_token
)
except WebhookSubscription.DoesNotExist:
return Response(
{'error': 'Webhook not found'},
status=status.HTTP_404_NOT_FOUND
)
# Update allowed fields
for field in ['url', 'events', 'is_active', 'description']:
if field in request.data:
setattr(webhook, field, request.data[field])
webhook.save()
output_serializer = TenantWebhookSerializer(webhook)
return Response(output_serializer.data)
@action(detail=False, methods=['get'])
def events(self, request):
"""List available webhook event types."""
# WebhookEvent.CHOICES is a list of (value, label) tuples
events = [
{'event': value, 'description': label}
for value, label in WebhookEvent.CHOICES
]
return Response(events)