Add max_public_pages feature and site builder access control

- Add max_public_pages billing feature (Free=0, Starter=1, Growth=5, Pro=10)
- Gate site builder access based on max_public_pages entitlement
- Auto-create Site with default booking page for new tenants
- Update PageEditor to use useEntitlements hook for permission checks
- Replace hardcoded limits in BusinessEditModal with DynamicFeaturesEditor
- Add force update functionality for superusers in PlanEditorWizard
- Add comprehensive filters to all safe scripting get_* methods
- Update plugin documentation with full filter reference

🤖 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-13 00:27:15 -05:00
parent aa9d920612
commit 41caccd31a
11 changed files with 4413 additions and 255 deletions

View File

@@ -19,6 +19,7 @@ import {
Star, Star,
Loader2, Loader2,
ChevronLeft, ChevronLeft,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { Modal, Alert } from '../../components/ui'; import { Modal, Alert } from '../../components/ui';
import { FeaturePicker } from './FeaturePicker'; import { FeaturePicker } from './FeaturePicker';
@@ -29,8 +30,11 @@ import {
useCreatePlanVersion, useCreatePlanVersion,
useUpdatePlan, useUpdatePlan,
useUpdatePlanVersion, useUpdatePlanVersion,
useForceUpdatePlanVersion,
isForceUpdateConfirmRequired,
type PlanFeatureWrite, type PlanFeatureWrite,
} from '../../hooks/useBillingAdmin'; } from '../../hooks/useBillingAdmin';
import { useCurrentUser } from '../../hooks/useAuth';
// ============================================================================= // =============================================================================
// Types // Types
@@ -110,13 +114,20 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
}) => { }) => {
const { data: features, isLoading: featuresLoading } = useFeatures(); const { data: features, isLoading: featuresLoading } = useFeatures();
const { data: addons } = useAddOnProducts(); const { data: addons } = useAddOnProducts();
const { data: currentUser } = useCurrentUser();
const createPlanMutation = useCreatePlan(); const createPlanMutation = useCreatePlan();
const createVersionMutation = useCreatePlanVersion(); const createVersionMutation = useCreatePlanVersion();
const updatePlanMutation = useUpdatePlan(); const updatePlanMutation = useUpdatePlan();
const updateVersionMutation = useUpdatePlanVersion(); const updateVersionMutation = useUpdatePlanVersion();
const forceUpdateMutation = useForceUpdatePlanVersion();
const isNewPlan = mode === 'create'; const isNewPlan = mode === 'create';
const hasSubscribers = (initialData?.version?.subscriber_count ?? 0) > 0; const hasSubscribers = (initialData?.version?.subscriber_count ?? 0) > 0;
const isSuperuser = currentUser?.role === 'superuser';
// Force update state (for updating without creating new version)
const [showForceUpdateConfirm, setShowForceUpdateConfirm] = useState(false);
const [forceUpdateError, setForceUpdateError] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState<WizardStep>('basics'); const [currentStep, setCurrentStep] = useState<WizardStep>('basics');
const [newMarketingFeature, setNewMarketingFeature] = useState(''); const [newMarketingFeature, setNewMarketingFeature] = useState('');
@@ -313,11 +324,49 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
} }
}; };
// Force update handler (updates existing version without creating new one)
const handleForceUpdate = async () => {
if (!initialData?.version?.id) return;
try {
setForceUpdateError(null);
// First call without confirm to get affected subscriber count
const response = await forceUpdateMutation.mutateAsync({
id: initialData.version.id,
name: formData.version_name,
is_public: formData.is_public,
price_monthly_cents: formData.price_monthly_cents,
price_yearly_cents: formData.price_yearly_cents,
transaction_fee_percent: formData.transaction_fee_percent,
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
trial_days: formData.trial_days,
is_most_popular: formData.is_most_popular,
show_price: formData.show_price,
marketing_features: formData.marketing_features,
stripe_product_id: formData.stripe_product_id,
stripe_price_id_monthly: formData.stripe_price_id_monthly,
stripe_price_id_yearly: formData.stripe_price_id_yearly,
features: formData.selectedFeatures,
confirm: true, // Confirm immediately since user already acknowledged
});
// If successful, close the modal
if (!isForceUpdateConfirmRequired(response)) {
onClose();
}
} catch (error) {
console.error('Failed to force update plan:', error);
setForceUpdateError('Failed to update plan. Please try again.');
}
};
const isLoading = const isLoading =
createPlanMutation.isPending || createPlanMutation.isPending ||
createVersionMutation.isPending || createVersionMutation.isPending ||
updatePlanMutation.isPending || updatePlanMutation.isPending ||
updateVersionMutation.isPending; updateVersionMutation.isPending ||
forceUpdateMutation.isPending;
// Derived values for display // Derived values for display
const monthlyEquivalent = formData.price_yearly_cents > 0 const monthlyEquivalent = formData.price_yearly_cents > 0
@@ -345,7 +394,7 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
size="4xl" size="4xl"
> >
{/* Grandfathering Warning */} {/* Grandfathering Warning */}
{hasSubscribers && ( {hasSubscribers && !showForceUpdateConfirm && (
<Alert <Alert
variant="warning" variant="warning"
className="mb-4" className="mb-4"
@@ -359,6 +408,53 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
/> />
)} )}
{/* Force Update Confirmation Dialog */}
{showForceUpdateConfirm && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-base font-semibold text-red-800 dark:text-red-200 mb-2">
Warning: This will affect existing customers
</h4>
<p className="text-sm text-red-700 dark:text-red-300 mb-3">
You are about to update this plan version <strong>in place</strong>. This will immediately
change the features and pricing for all <strong>{initialData?.version?.subscriber_count}</strong> existing
subscriber(s). This action cannot be undone.
</p>
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
Only use this for correcting errors or minor adjustments. For significant changes,
use the standard save which creates a new version and grandfathers existing subscribers.
</p>
{forceUpdateError && (
<Alert variant="error" message={forceUpdateError} className="mb-3" />
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => {
setShowForceUpdateConfirm(false);
setForceUpdateError(null);
}}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="button"
onClick={handleForceUpdate}
disabled={isLoading}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{forceUpdateMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Yes, Update All Subscribers
</button>
</div>
</div>
</div>
</div>
)}
{/* Step Indicator */} {/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-6 pb-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-center gap-2 mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
{steps.map((step, index) => { {steps.map((step, index) => {
@@ -834,7 +930,7 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
{/* Footer */} {/* Footer */}
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700"> <div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<div> <div>
{!isFirstStep && ( {!isFirstStep && !showForceUpdateConfirm && (
<button <button
type="button" type="button"
onClick={goPrev} onClick={goPrev}
@@ -845,35 +941,51 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
</button> </button>
)} )}
</div> </div>
<div className="flex gap-3"> {!showForceUpdateConfirm && (
<button <div className="flex gap-3">
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
{!isLastStep ? (
<button <button
type="button" type="button"
onClick={goNext} onClick={onClose}
disabled={!canProceed} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
Next Cancel
</button> </button>
) : ( {!isLastStep ? (
<button <button
type="button" type="button"
onClick={handleSubmit} onClick={goNext}
disabled={isLoading || !canProceed} disabled={!canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />} Next
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'} </button>
</button> ) : (
)} <>
</div> {/* Force Update button - only for superusers editing plans with subscribers */}
{hasSubscribers && isSuperuser && (
<button
type="button"
onClick={() => setShowForceUpdateConfirm(true)}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
<AlertTriangle className="w-4 h-4" />
Update Without Versioning
</button>
)}
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
</button>
</>
)}
</div>
)}
</div> </div>
</Modal> </Modal>
); );

View File

@@ -57,6 +57,23 @@ vi.mock('../../../hooks/useBillingAdmin', () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }), mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
isPending: false, isPending: false,
}), }),
useForceUpdatePlanVersion: () => ({
mutateAsync: vi.fn().mockResolvedValue({ version: { id: 1 }, affected_count: 5 }),
isPending: false,
}),
isForceUpdateConfirmRequired: (response: unknown) =>
response !== null &&
typeof response === 'object' &&
'requires_confirm' in response &&
(response as { requires_confirm: boolean }).requires_confirm === true,
}));
// Mock useCurrentUser from useAuth
vi.mock('../../../hooks/useAuth', () => ({
useCurrentUser: () => ({
data: { id: 1, role: 'superuser', email: 'admin@test.com' },
isLoading: false,
}),
})); }));
describe('PlanEditorWizard', () => { describe('PlanEditorWizard', () => {
@@ -409,4 +426,135 @@ describe('PlanEditorWizard', () => {
}); });
}); });
}); });
describe('Force Update (Superuser)', () => {
it('shows "Update Without Versioning" button for superuser editing plan with subscribers', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Should show "Update Without Versioning" button
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
});
it('shows confirmation dialog when clicking "Update Without Versioning"', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Click the force update button
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
// Should show confirmation dialog with warning
expect(screen.getByText(/warning: this will affect existing customers/i)).toBeInTheDocument();
expect(screen.getByText(/5/)).toBeInTheDocument(); // subscriber count
expect(screen.getByRole('button', { name: /yes, update all subscribers/i })).toBeInTheDocument();
});
it('can cancel force update confirmation', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Click the force update button
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
// Click Cancel
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
await user.click(cancelButtons[0]); // First cancel is in the confirmation dialog
// Confirmation dialog should be hidden, back to normal footer
expect(screen.queryByText(/warning: this will affect existing customers/i)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
});
it('does not show "Update Without Versioning" for plans without subscribers', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 0, // No subscribers
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Should NOT show "Update Without Versioning" button
expect(screen.queryByRole('button', { name: /update without versioning/i })).not.toBeInTheDocument();
});
});
}); });

View File

@@ -127,6 +127,7 @@ export const FEATURE_CODES = {
MAX_RESOURCES: 'max_resources', MAX_RESOURCES: 'max_resources',
MAX_EVENT_TYPES: 'max_event_types', MAX_EVENT_TYPES: 'max_event_types',
MAX_CALENDARS_CONNECTED: 'max_calendars_connected', MAX_CALENDARS_CONNECTED: 'max_calendars_connected',
MAX_PUBLIC_PAGES: 'max_public_pages',
} as const; } as const;
export type FeatureCode = (typeof FEATURE_CODES)[keyof typeof FEATURE_CODES]; export type FeatureCode = (typeof FEATURE_CODES)[keyof typeof FEATURE_CODES];

View File

@@ -5,11 +5,11 @@ import { config } from "../puckConfig";
import { usePages, useUpdatePage, useCreatePage, useDeletePage } from "../hooks/useSites"; import { usePages, useUpdatePage, useCreatePage, useDeletePage } from "../hooks/useSites";
import { Loader2, Plus, Trash2, FileText } from "lucide-react"; import { Loader2, Plus, Trash2, FileText } from "lucide-react";
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useAuth } from '../hooks/useAuth'; import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements';
export const PageEditor: React.FC = () => { export const PageEditor: React.FC = () => {
const { data: pages, isLoading } = usePages(); const { data: pages, isLoading } = usePages();
const { user } = useAuth(); const { getLimit, isLoading: entitlementsLoading } = useEntitlements();
const updatePage = useUpdatePage(); const updatePage = useUpdatePage();
const createPage = useCreatePage(); const createPage = useCreatePage();
const deletePage = useDeletePage(); const deletePage = useDeletePage();
@@ -18,6 +18,13 @@ export const PageEditor: React.FC = () => {
const [showNewPageModal, setShowNewPageModal] = useState(false); const [showNewPageModal, setShowNewPageModal] = useState(false);
const [newPageTitle, setNewPageTitle] = useState(''); const [newPageTitle, setNewPageTitle] = useState('');
// Get max_public_pages from billing entitlements
// null = unlimited, 0 = no access, >0 = limited pages
const maxPagesLimit = getLimit(FEATURE_CODES.MAX_PUBLIC_PAGES);
const canCustomize = maxPagesLimit === null || maxPagesLimit > 0;
const pageCount = pages?.length || 0;
const canCreateMore = canCustomize && (maxPagesLimit === null || pageCount < maxPagesLimit);
const currentPage = pages?.find((p: any) => p.id === currentPageId) || pages?.find((p: any) => p.is_home) || pages?.[0]; const currentPage = pages?.find((p: any) => p.id === currentPageId) || pages?.find((p: any) => p.is_home) || pages?.[0];
useEffect(() => { useEffect(() => {
@@ -35,9 +42,8 @@ export const PageEditor: React.FC = () => {
const handlePublish = async (newData: any) => { const handlePublish = async (newData: any) => {
if (!currentPage) return; if (!currentPage) return;
// Check if user has permission to customize // Check if user has permission to customize (based on max_public_pages entitlement)
const hasPermission = (user as any)?.tenant?.can_customize_booking_page || false; if (!canCustomize) {
if (!hasPermission) {
toast.error("Your plan does not include site customization. Please upgrade to edit pages."); toast.error("Your plan does not include site customization. Please upgrade to edit pages.");
return; return;
} }
@@ -84,7 +90,7 @@ export const PageEditor: React.FC = () => {
} }
}; };
if (isLoading) { if (isLoading || entitlementsLoading) {
return <div className="flex justify-center p-10"><Loader2 className="animate-spin" /></div>; return <div className="flex justify-center p-10"><Loader2 className="animate-spin" /></div>;
} }
@@ -94,10 +100,8 @@ export const PageEditor: React.FC = () => {
if (!data) return null; if (!data) return null;
const maxPages = (user as any)?.tenant?.max_pages || 1; // Display max pages as string for UI (null = unlimited shown as ∞)
const pageCount = pages?.length || 0; const maxPagesDisplay = maxPagesLimit === null ? '∞' : maxPagesLimit;
const canCustomize = (user as any)?.tenant?.can_customize_booking_page || false;
const canCreateMore = canCustomize && (maxPages === -1 || pageCount < maxPages);
return ( return (
<div className="h-screen flex flex-col"> <div className="h-screen flex flex-col">
@@ -136,7 +140,7 @@ export const PageEditor: React.FC = () => {
onClick={() => setShowNewPageModal(true)} onClick={() => setShowNewPageModal(true)}
disabled={!canCreateMore} disabled={!canCreateMore}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
title={canCreateMore ? "Create new page" : `Page limit reached (${pageCount}/${maxPages})`} title={canCreateMore ? "Create new page" : `Page limit reached (${pageCount}/${maxPagesDisplay})`}
> >
<Plus size={16} /> <Plus size={16} />
New Page New Page
@@ -156,7 +160,7 @@ export const PageEditor: React.FC = () => {
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400"> <div className="text-sm text-gray-600 dark:text-gray-400">
{pageCount} / {maxPages === -1 ? '∞' : maxPages} pages {pageCount} / {maxPagesDisplay} pages
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -422,64 +422,18 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
</p> </p>
</div> </div>
{/* Limits Configuration */} {/* Limits & Quotas - Dynamic from billing system */}
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700"> <div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white"> <DynamicFeaturesEditor
Limits Configuration values={featureValues}
</h3> onChange={(fieldName, value) => {
<p className="text-xs text-gray-500 dark:text-gray-400"> setFeatureValues(prev => ({ ...prev, [fieldName]: value }));
Use -1 for unlimited. These limits control what this business can create. }}
</p> featureType="integer"
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> headerTitle="Limits & Quotas"
<div> showDescriptions
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> columns={4}
Max Users />
</label>
<input
type="number"
min="-1"
value={featureValues.max_users ?? 5}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_users: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Resources
</label>
<input
type="number"
min="-1"
value={featureValues.max_resources ?? 10}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_resources: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Pages
</label>
<input
type="number"
min="-1"
value={featureValues.max_pages ?? 1}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_pages: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Locations
</label>
<input
type="number"
min="-1"
value={featureValues.max_locations ?? 1}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_locations: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
</div> </div>
{/* Site Builder Access */} {/* Site Builder Access */}
@@ -488,7 +442,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
Site Builder Site Builder
</h3> </h3>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
Access the public-facing website builder for this business. Current limit: {featureValues.max_pages === -1 ? 'unlimited' : (featureValues.max_pages ?? 1)} page{(featureValues.max_pages ?? 1) !== 1 ? 's' : ''}. Access the public-facing website builder for this business. Current limit: {(featureValues.max_public_pages === null || featureValues.max_public_pages === -1) ? 'unlimited' : (featureValues.max_public_pages ?? 0)} page{(featureValues.max_public_pages ?? 0) !== 1 ? 's' : ''}.
</p> </p>
<a <a
href={`http://${business.subdomain}.lvh.me:5173/site-editor`} href={`http://${business.subdomain}.lvh.me:5173/site-editor`}
@@ -510,14 +464,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
onChange={(fieldName, value) => { onChange={(fieldName, value) => {
setFeatureValues(prev => ({ ...prev, [fieldName]: value })); setFeatureValues(prev => ({ ...prev, [fieldName]: value }));
}} }}
// Show only boolean features (limits are in the grid above)
featureType="boolean" featureType="boolean"
// Skip limits category since they're shown in the dedicated grid
excludeCodes={[
'max_users', 'max_resources', 'max_locations', 'max_services',
'max_customers', 'max_appointments_per_month', 'max_sms_per_month',
'max_email_per_month', 'max_storage_mb', 'max_api_calls_per_day',
]}
headerTitle="Features & Permissions" headerTitle="Features & Permissions"
showDescriptions showDescriptions
/> />

View File

@@ -69,6 +69,7 @@ FEATURES = [
{"code": "custom_domain", "name": "Custom Domain", "description": "Use your own domain for booking pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_use_custom_domain", "display_order": 30}, {"code": "custom_domain", "name": "Custom Domain", "description": "Use your own domain for booking pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_use_custom_domain", "display_order": 30},
{"code": "remove_branding", "name": "Remove Branding", "description": "Remove SmoothSchedule branding from customer-facing pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 40}, {"code": "remove_branding", "name": "Remove Branding", "description": "Remove SmoothSchedule branding from customer-facing pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 40},
{"code": "white_label", "name": "White Label", "description": "Remove all SmoothSchedule branding completely", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 50}, {"code": "white_label", "name": "White Label", "description": "Remove all SmoothSchedule branding completely", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 50},
{"code": "max_public_pages", "name": "Public Web Pages", "description": "Maximum number of public-facing web pages", "feature_type": "integer", "category": "customization", "tenant_field_name": "max_public_pages", "display_order": 55},
# --- Plugins & Automation --- # --- Plugins & Automation ---
{"code": "can_use_plugins", "name": "Use Plugins", "description": "Install and use marketplace plugins", "feature_type": "boolean", "category": "plugins", "tenant_field_name": "can_use_plugins", "display_order": 10}, {"code": "can_use_plugins", "name": "Use Plugins", "description": "Install and use marketplace plugins", "feature_type": "boolean", "category": "plugins", "tenant_field_name": "can_use_plugins", "display_order": 10},
@@ -134,6 +135,7 @@ PLANS = [
"max_storage_mb": 250, "max_storage_mb": 250,
"max_api_calls_per_day": 0, "max_api_calls_per_day": 0,
"max_sms_per_month": 0, "max_sms_per_month": 0,
"max_public_pages": 0,
}, },
}, },
{ {
@@ -171,6 +173,7 @@ PLANS = [
"max_storage_mb": 2000, "max_storage_mb": 2000,
"max_api_calls_per_day": 0, "max_api_calls_per_day": 0,
"max_sms_per_month": 0, "max_sms_per_month": 0,
"max_public_pages": 1,
}, },
}, },
{ {
@@ -216,6 +219,7 @@ PLANS = [
"max_sms_per_month": 2000, "max_sms_per_month": 2000,
"max_storage_mb": 10000, "max_storage_mb": 10000,
"max_api_calls_per_day": 10000, "max_api_calls_per_day": 10000,
"max_public_pages": 5,
}, },
}, },
{ {
@@ -270,6 +274,7 @@ PLANS = [
"max_sms_per_month": 10000, "max_sms_per_month": 10000,
"max_storage_mb": 50000, "max_storage_mb": 50000,
"max_api_calls_per_day": 50000, "max_api_calls_per_day": 50000,
"max_public_pages": 10,
}, },
}, },
{ {
@@ -332,6 +337,7 @@ PLANS = [
"max_sms_per_month": None, "max_sms_per_month": None,
"max_storage_mb": None, "max_storage_mb": None,
"max_api_calls_per_day": None, "max_api_calls_per_day": None,
"max_public_pages": None,
}, },
}, },
] ]

View File

@@ -100,3 +100,14 @@ def create_default_page_for_site(sender, instance, created, **kwargs):
"""Automatically create a default home page when a new Site is created.""" """Automatically create a default home page when a new Site is created."""
if created: if created:
instance.create_default_page() instance.create_default_page()
@receiver(post_save, sender=Tenant)
def create_site_for_tenant(sender, instance, created, **kwargs):
"""Automatically create a Site with default page for new tenants.
This ensures ALL tenants (including Free tier) get a default booking page.
The Site post_save signal will then create the default home page.
"""
if created:
Site.objects.get_or_create(tenant=instance)

View File

@@ -8,6 +8,7 @@ from .models import Site, Page, Domain
from .serializers import SiteSerializer, PageSerializer, DomainSerializer, PublicPageSerializer, PublicServiceSerializer from .serializers import SiteSerializer, PageSerializer, DomainSerializer, PublicPageSerializer, PublicServiceSerializer
from smoothschedule.identity.core.models import Tenant from smoothschedule.identity.core.models import Tenant
from smoothschedule.scheduling.schedule.models import Service from smoothschedule.scheduling.schedule.models import Service
from smoothschedule.billing.services.entitlements import EntitlementService
class SiteViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.UpdateModelMixin): class SiteViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.UpdateModelMixin):
serializer_class = SiteSerializer serializer_class = SiteSerializer
@@ -53,17 +54,20 @@ class SiteViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.Upd
return Response(serializer.data) return Response(serializer.data)
elif request.method == 'POST': elif request.method == 'POST':
# Check if tenant has permission to customize booking page # Check max_public_pages from billing system
if not tenant.can_customize_booking_page: max_pages = EntitlementService.get_limit(tenant, 'max_public_pages')
# None means unlimited, 0 means no access
can_customize = max_pages is None or max_pages > 0
if not can_customize:
return Response({ return Response({
"error": "Your plan does not include site customization. Please upgrade to a paid plan to create and edit pages." "error": "Your plan does not include site customization. Please upgrade to a paid plan to create and edit pages."
}, status=status.HTTP_403_FORBIDDEN) }, status=status.HTTP_403_FORBIDDEN)
# Check page limit # Check page limit (None = unlimited)
current_page_count = Page.objects.filter(site=site).count() current_page_count = Page.objects.filter(site=site).count()
max_pages = tenant.max_pages
if max_pages != -1 and current_page_count >= max_pages: if max_pages is not None and current_page_count >= max_pages:
return Response({ return Response({
"error": f"Page limit reached. Your plan allows {max_pages} page(s). Please upgrade to create more pages." "error": f"Page limit reached. Your plan allows {max_pages} page(s). Please upgrade to create more pages."
}, status=status.HTTP_403_FORBIDDEN) }, status=status.HTTP_403_FORBIDDEN)
@@ -81,25 +85,31 @@ class PageViewSet(viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
return Page.objects.filter(site__tenant=self.request.tenant) return Page.objects.filter(site__tenant=self.request.tenant)
def _check_site_permission(self, tenant):
"""Check if tenant can edit site based on max_public_pages entitlement."""
max_pages = EntitlementService.get_limit(tenant, 'max_public_pages')
# None means unlimited, 0 means no access
return max_pages is None or max_pages > 0
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
# Check if tenant has permission to customize booking page # Check max_public_pages from billing system
if not request.tenant.can_customize_booking_page: if not self._check_site_permission(request.tenant):
return Response({ return Response({
"error": "Your plan does not include site customization. Please upgrade to a paid plan to edit pages." "error": "Your plan does not include site customization. Please upgrade to a paid plan to edit pages."
}, status=status.HTTP_403_FORBIDDEN) }, status=status.HTTP_403_FORBIDDEN)
return super().update(request, *args, **kwargs) return super().update(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
# Check if tenant has permission to customize booking page # Check max_public_pages from billing system
if not request.tenant.can_customize_booking_page: if not self._check_site_permission(request.tenant):
return Response({ return Response({
"error": "Your plan does not include site customization. Please upgrade to a paid plan to edit pages." "error": "Your plan does not include site customization. Please upgrade to a paid plan to edit pages."
}, status=status.HTTP_403_FORBIDDEN) }, status=status.HTTP_403_FORBIDDEN)
return super().partial_update(request, *args, **kwargs) return super().partial_update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
# Check if tenant has permission to customize booking page # Check max_public_pages from billing system
if not request.tenant.can_customize_booking_page: if not self._check_site_permission(request.tenant):
return Response({ return Response({
"error": "Your plan does not include site customization. Please upgrade to a paid plan to delete pages." "error": "Your plan does not include site customization. Please upgrade to a paid plan to delete pages."
}, status=status.HTTP_403_FORBIDDEN) }, status=status.HTTP_403_FORBIDDEN)

View File

@@ -847,3 +847,872 @@ class TestValidatePluginWhitelistValidation:
assert len(result['warnings']) > 0 assert len(result['warnings']) > 0
assert any('dynamic URL' in w for w in result['warnings']) assert any('dynamic URL' in w for w in result['warnings'])
# =========================================================================
# TESTS FOR NEW API METHODS
# =========================================================================
class TestSafeScriptAPIFeatureCheck:
"""Tests for SafeScriptAPI._check_feature method."""
def test_has_check_feature_method(self):
"""Should have _check_feature method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, '_check_feature')
assert callable(api._check_feature)
def test_raises_when_feature_not_available(self):
"""Should raise ScriptExecutionError when feature not available."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api._check_feature('sms_enabled', 'SMS messaging')
assert 'not available on your plan' in str(exc_info.value)
def test_passes_when_feature_available(self):
"""Should not raise when feature is available."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_business = Mock()
mock_business.has_feature = Mock(return_value=True)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
# Should not raise
api._check_feature('sms_enabled', 'SMS messaging')
mock_business.has_feature.assert_called_with('sms_enabled')
class TestSafeScriptAPISendSMS:
"""Tests for SafeScriptAPI.send_sms method."""
def test_has_send_sms_method(self):
"""Should have send_sms method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'send_sms')
assert callable(api.send_sms)
def test_validates_phone_number(self):
"""Should validate phone number."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=True)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.send_sms('123', 'Test message')
assert 'Invalid phone number' in str(exc_info.value)
def test_validates_message_length(self):
"""Should validate message length."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=True)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.send_sms('+15551234567', 'x' * 2000)
assert 'too long' in str(exc_info.value)
class TestSafeScriptAPIGetSMSBalance:
"""Tests for SafeScriptAPI.get_sms_balance method."""
def test_has_get_sms_balance_method(self):
"""Should have get_sms_balance method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_sms_balance')
assert callable(api.get_sms_balance)
class TestSafeScriptAPIGetResources:
"""Tests for SafeScriptAPI.get_resources method."""
def test_has_get_resources_method(self):
"""Should have get_resources method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_resources')
assert callable(api.get_resources)
@patch('smoothschedule.scheduling.schedule.models.Resource')
def test_returns_list(self, mock_resource_class):
"""Should return a list of resources."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_resource = Mock()
mock_resource.id = 1
mock_resource.name = 'Test Resource'
mock_resource.type = 'STAFF'
mock_resource.resource_type = None
mock_resource.description = 'Description'
mock_resource.is_active = True
mock_resource.max_concurrent_events = 1
mock_resource.location_id = None
mock_resource.location = None
mock_resource.is_mobile = False
mock_resource.user_id = None
mock_resource.user = None
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[mock_resource])
mock_resource_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
result = api.get_resources()
assert isinstance(result, list)
class TestSafeScriptAPIGetResourceAvailability:
"""Tests for SafeScriptAPI.get_resource_availability method."""
def test_has_get_resource_availability_method(self):
"""Should have get_resource_availability method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_resource_availability')
assert callable(api.get_resource_availability)
class TestSafeScriptAPIGetServices:
"""Tests for SafeScriptAPI.get_services method."""
def test_has_get_services_method(self):
"""Should have get_services method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_services')
assert callable(api.get_services)
class TestSafeScriptAPIGetServiceStats:
"""Tests for SafeScriptAPI.get_service_stats method."""
def test_has_get_service_stats_method(self):
"""Should have get_service_stats method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_service_stats')
assert callable(api.get_service_stats)
class TestSafeScriptAPIGetPayments:
"""Tests for SafeScriptAPI.get_payments method."""
def test_has_get_payments_method(self):
"""Should have get_payments method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_payments')
assert callable(api.get_payments)
def test_requires_payment_processing_feature(self):
"""Should require payment_processing feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_payments()
assert 'not available on your plan' in str(exc_info.value)
class TestSafeScriptAPIGetInvoices:
"""Tests for SafeScriptAPI.get_invoices method."""
def test_has_get_invoices_method(self):
"""Should have get_invoices method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_invoices')
assert callable(api.get_invoices)
class TestSafeScriptAPIGetRevenueStats:
"""Tests for SafeScriptAPI.get_revenue_stats method."""
def test_has_get_revenue_stats_method(self):
"""Should have get_revenue_stats method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_revenue_stats')
assert callable(api.get_revenue_stats)
class TestSafeScriptAPIGetContracts:
"""Tests for SafeScriptAPI.get_contracts method."""
def test_has_get_contracts_method(self):
"""Should have get_contracts method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_contracts')
assert callable(api.get_contracts)
def test_requires_contracts_feature(self):
"""Should require can_use_contracts feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_contracts()
assert 'not available on your plan' in str(exc_info.value)
class TestSafeScriptAPIGetExpiringContracts:
"""Tests for SafeScriptAPI.get_expiring_contracts method."""
def test_has_get_expiring_contracts_method(self):
"""Should have get_expiring_contracts method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_expiring_contracts')
assert callable(api.get_expiring_contracts)
class TestSafeScriptAPIGetLocations:
"""Tests for SafeScriptAPI.get_locations method."""
def test_has_get_locations_method(self):
"""Should have get_locations method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_locations')
assert callable(api.get_locations)
class TestSafeScriptAPIGetLocationStats:
"""Tests for SafeScriptAPI.get_location_stats method."""
def test_has_get_location_stats_method(self):
"""Should have get_location_stats method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_location_stats')
assert callable(api.get_location_stats)
def test_requires_multi_location_feature(self):
"""Should require multi_location feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_location_stats(1)
assert 'not available on your plan' in str(exc_info.value)
class TestSafeScriptAPIGetStaff:
"""Tests for SafeScriptAPI.get_staff method."""
def test_has_get_staff_method(self):
"""Should have get_staff method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_staff')
assert callable(api.get_staff)
class TestSafeScriptAPIGetStaffPerformance:
"""Tests for SafeScriptAPI.get_staff_performance method."""
def test_has_get_staff_performance_method(self):
"""Should have get_staff_performance method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_staff_performance')
assert callable(api.get_staff_performance)
class TestSafeScriptAPICreateVideoMeeting:
"""Tests for SafeScriptAPI.create_video_meeting method."""
def test_has_create_video_meeting_method(self):
"""Should have create_video_meeting method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'create_video_meeting')
assert callable(api.create_video_meeting)
def test_requires_video_conferencing_feature(self):
"""Should require can_add_video_conferencing feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.create_video_meeting()
assert 'not available on your plan' in str(exc_info.value)
def test_validates_provider(self):
"""Should validate video provider."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=True)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.create_video_meeting(provider='invalid_provider')
assert 'Invalid video provider' in str(exc_info.value)
def test_returns_meeting_data(self):
"""Should return meeting data."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_business = Mock()
mock_business.has_feature = Mock(return_value=True)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
result = api.create_video_meeting(provider='zoom', title='Test Meeting')
assert 'meeting_id' in result
assert 'join_url' in result
assert 'host_url' in result
assert result['provider'] == 'zoom'
assert result['title'] == 'Test Meeting'
class TestSafeScriptAPIGetEmailTemplates:
"""Tests for SafeScriptAPI.get_email_templates method."""
def test_has_get_email_templates_method(self):
"""Should have get_email_templates method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_email_templates')
assert callable(api.get_email_templates)
def test_requires_email_templates_feature(self):
"""Should require can_use_email_templates feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_email_templates()
assert 'not available on your plan' in str(exc_info.value)
class TestSafeScriptAPISendTemplateEmail:
"""Tests for SafeScriptAPI.send_template_email method."""
def test_has_send_template_email_method(self):
"""Should have send_template_email method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'send_template_email')
assert callable(api.send_template_email)
class TestSafeScriptAPIGetAnalytics:
"""Tests for SafeScriptAPI.get_analytics method."""
def test_has_get_analytics_method(self):
"""Should have get_analytics method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_analytics')
assert callable(api.get_analytics)
def test_requires_advanced_reporting_feature(self):
"""Should require advanced_reporting feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_analytics()
assert 'not available on your plan' in str(exc_info.value)
class TestSafeScriptAPIGetBookingTrends:
"""Tests for SafeScriptAPI.get_booking_trends method."""
def test_has_get_booking_trends_method(self):
"""Should have get_booking_trends method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_booking_trends')
assert callable(api.get_booking_trends)
class TestSafeScriptAPIUpdateAppointment:
"""Tests for SafeScriptAPI.update_appointment method."""
def test_has_update_appointment_method(self):
"""Should have update_appointment method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'update_appointment')
assert callable(api.update_appointment)
class TestSafeScriptAPIGetRecurringAppointments:
"""Tests for SafeScriptAPI.get_recurring_appointments method."""
def test_has_get_recurring_appointments_method(self):
"""Should have get_recurring_appointments method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_recurring_appointments')
assert callable(api.get_recurring_appointments)
class TestSafeScriptAPIGetAppointmentsFilters:
"""Tests for comprehensive filtering in get_appointments method."""
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_start_time_gt(self, mock_event_class):
"""Should filter by start_time greater than."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(start_time__gt='2024-01-01T10:00:00')
# Should have called filter with start_time__gt
filter_calls = [str(c) for c in mock_queryset.filter.call_args_list]
assert any('start_time__gt' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_start_time_lt(self, mock_event_class):
"""Should filter by start_time less than."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(start_time__lt='2024-01-01T10:00:00')
assert any('start_time__lt' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_start_time_gte(self, mock_event_class):
"""Should filter by start_time greater than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(start_time__gte='2024-01-01T10:00:00')
assert any('start_time__gte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_start_time_lte(self, mock_event_class):
"""Should filter by start_time less than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(start_time__lte='2024-01-01T10:00:00')
assert any('start_time__lte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_end_time_gt(self, mock_event_class):
"""Should filter by end_time greater than."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(end_time__gt='2024-01-01T10:00:00')
assert any('end_time__gt' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_end_time_lt(self, mock_event_class):
"""Should filter by end_time less than."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(end_time__lt='2024-01-01T10:00:00')
assert any('end_time__lt' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_created_at_gte(self, mock_event_class):
"""Should filter by created_at greater than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(created_at__gte='2024-01-01T10:00:00')
assert any('created_at__gte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_updated_at_gte(self, mock_event_class):
"""Should filter by updated_at greater than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(updated_at__gte='2024-01-01T10:00:00')
assert any('updated_at__gte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_service_id(self, mock_event_class):
"""Should filter by service_id."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(service_id=123)
assert any('service_id' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_location_id(self, mock_event_class):
"""Should filter by location_id."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(location_id=456)
assert any('location_id' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_title_contains(self, mock_event_class):
"""Should filter by title contains (case-insensitive)."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(title__icontains='meeting')
assert any('title__icontains' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_notes_contains(self, mock_event_class):
"""Should filter by notes contains (case-insensitive)."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(notes__icontains='important')
assert any('notes__icontains' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_customer_id(self, mock_event_class):
"""Should filter by customer_id (via participants)."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.distinct.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(customer_id=789)
assert any('participants__user_id' in str(c) or 'customer' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_resource_id(self, mock_event_class):
"""Should filter by resource_id (via participants)."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.distinct.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(resource_id=321)
assert any('participants__resource_id' in str(c) or 'resource' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_deposit_amount_gte(self, mock_event_class):
"""Should filter by deposit_amount greater than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(deposit_amount__gte=50.00)
assert any('deposit_amount__gte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_final_price_lte(self, mock_event_class):
"""Should filter by final_price less than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(final_price__lte=100.00)
assert any('final_price__lte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_has_deposit(self, mock_event_class):
"""Should filter appointments that have a deposit."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.exclude.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(has_deposit=True)
# Should use exclude(deposit_amount__isnull=True) or filter(deposit_amount__isnull=False)
call_args_str = str(mock_queryset.filter.call_args_list) + str(mock_queryset.exclude.call_args_list)
assert 'deposit_amount' in call_args_str
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_multiple_statuses(self, mock_event_class):
"""Should filter by multiple statuses using __in."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(status__in=['SCHEDULED', 'COMPLETED'])
assert any('status__in' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_combination(self, mock_event_class):
"""Should support combining multiple filters."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(
status='SCHEDULED',
start_time__gte='2024-01-01T00:00:00',
start_time__lt='2024-02-01T00:00:00',
service_id=123
)
# Multiple filter calls should have been made
assert mock_queryset.filter.call_count >= 1
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_returns_comprehensive_data(self, mock_event_class):
"""Should return comprehensive appointment data including related objects."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
from datetime import datetime
mock_service = Mock()
mock_service.id = 1
mock_service.name = 'Haircut'
mock_location = Mock()
mock_location.id = 2
mock_location.name = 'Main Office'
mock_event = Mock()
mock_event.id = 100
mock_event.title = 'Test Appointment'
mock_event.start_time = datetime(2024, 1, 15, 10, 0, 0)
mock_event.end_time = datetime(2024, 1, 15, 11, 0, 0)
mock_event.status = 'SCHEDULED'
mock_event.notes = 'Some notes'
mock_event.created_at = datetime(2024, 1, 1, 9, 0, 0)
mock_event.updated_at = datetime(2024, 1, 1, 9, 0, 0)
mock_event.service = mock_service
mock_event.service_id = 1
mock_event.location = mock_location
mock_event.location_id = 2
mock_event.deposit_amount = 25.00
mock_event.final_price = None
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[mock_event])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
result = api.get_appointments()
assert len(result) == 1
appt = result[0]
assert appt['id'] == 100
assert appt['title'] == 'Test Appointment'
assert 'service_id' in appt
assert 'service_name' in appt
assert 'location_id' in appt
assert 'location_name' in appt
assert 'created_at' in appt
assert 'updated_at' in appt
assert 'deposit_amount' in appt
assert 'final_price' in appt
def test_requires_recurring_appointments_feature(self):
"""Should require recurring_appointments feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_recurring_appointments()
assert 'not available on your plan' in str(exc_info.value)