6 Commits

Author SHA1 Message Date
poduck
fc63cf4fce Add platform email templates, staff invitations, and quota tracking
- Add PlatformEmailTemplate model and API for superuser-managed email templates
- Add PlatformStaffInvitation model with email sending via Celery tasks
- Add platform staff invite page and acceptance flow with auto-login
- Add quota tracking models (DailyAppointmentUsage, DailyAPIUsage, StorageUsage)
- Add quota status API endpoints and frontend banners
- Add storage usage service for tenant media tracking
- Fix platform user deletion with raw SQL to handle multi-tenant FK constraints
- Update EditPlatformUserModal with archive/delete buttons
- Update PlatformSidebar with email templates link for superusers
- Configure console email backend and Celery eager mode for local development

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:35:35 -05:00
poduck
f13a40e4bc Add /auth/ location to nginx proxy config
The /auth/ path was not being proxied to Django, causing POST
requests to authentication endpoints to return 405 errors.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 11:10:46 -05:00
poduck
1d1cfbb164 Simplify embedded mode navigation to stay within iframe
Remove the complex token-passing logic for new tabs in embedded mode.
Instead, navigation now stays within the iframe for a simpler UX.

- Remove handleNewWindowClick auth handler from sidebar items
- Simplify useNewWindow hook to navigate within iframe when embedded

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 10:45:01 -05:00
poduck
174cc94b42 Fix TypeScript error in authenticate route
Add saveToken() method to authenticationSession for saving standalone
JWT tokens without requiring a full AuthenticationResponse object.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 21:23:32 -05:00
poduck
edc896b10e Add auto-authentication for new tabs opened from embedded mode
When opening flows/runs in new tabs from within the embedded Activepieces
iframe, users were being redirected to a login page because the JWT token
stored in sessionStorage wasn't shared across tabs.

Changes:
- Modify /authenticate route to accept standalone token parameter
- Update useNewWindow hook to pass JWT token via URL in embedded mode
- Add click handler in ApSidebarItem for authenticated new-window links

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 21:19:52 -05:00
poduck
76be5377d9 Remove 'Open in new tab' link from Automations page (requires iframe auth) 2025-12-29 21:07:45 -05:00
72 changed files with 8206 additions and 680 deletions

View File

@@ -2,6 +2,7 @@ import { LockKeyhole } from 'lucide-react';
import { ComponentType, SVGProps } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useEmbedding } from '@/components/embed-provider';
import { buttonVariants } from '@/components/ui/button';
import { Dot } from '@/components/ui/dot';
import {
@@ -15,6 +16,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { authenticationSession } from '@/lib/authentication-session';
import { cn } from '@/lib/utils';
export type SidebarItemType = {

View File

@@ -9,14 +9,22 @@ const AuthenticatePage = () => {
const searchParams = new URLSearchParams(location.search);
const response = searchParams.get('response');
const token = searchParams.get('token');
const redirectTo = searchParams.get('redirect') || '/flows';
useEffect(() => {
if (response) {
// Handle full response object (legacy)
const decodedResponse = JSON.parse(response);
authenticationSession.saveResponse(decodedResponse, false);
navigate('/flows');
navigate(redirectTo);
} else if (token) {
// Handle standalone JWT token (from embedded mode new tab)
// Save token directly to localStorage for persistence in new tabs
authenticationSession.saveToken(token);
navigate(redirectTo);
}
}, [response]);
}, [response, token, redirectTo, navigate]);
return <>Please wait...</>;
};

View File

@@ -19,6 +19,14 @@ export const authenticationSession = {
ApStorage.getInstance().setItem(tokenKey, response.token);
window.dispatchEvent(new Event('storage'));
},
/**
* Save a standalone JWT token directly.
* Used for auto-authentication when opening new tabs from embedded mode.
*/
saveToken(token: string) {
ApStorage.getInstance().setItem(tokenKey, token);
window.dispatchEvent(new Event('storage'));
},
isJwtExpired(token: string): boolean {
if (!token) {
return true;

View File

@@ -2,10 +2,13 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { useEmbedding } from '../components/embed-provider';
import { authenticationSession } from './authentication-session';
export const useNewWindow = () => {
const { embedState } = useEmbedding();
const navigate = useNavigate();
if (embedState.isEmbedded) {
// In embedded mode, navigate within the iframe (don't open new tabs)
return (route: string, searchParams?: string) =>
navigate({
pathname: route,
@@ -21,6 +24,35 @@ export const useNewWindow = () => {
}
};
/**
* Opens a route in a new browser tab with automatic authentication.
* For embedded contexts where sessionStorage isn't shared across tabs,
* this passes the JWT token via URL for auto-login.
*/
export const useOpenInNewTab = () => {
const { embedState } = useEmbedding();
return (route: string, searchParams?: string) => {
const token = authenticationSession.getToken();
if (embedState.isEmbedded && token) {
// In embedded mode, pass token for auto-authentication in new tab
const encodedRedirect = encodeURIComponent(
`${route}${searchParams ? '?' + searchParams : ''}`,
);
const authUrl = `/authenticate?token=${encodeURIComponent(token)}&redirect=${encodedRedirect}`;
window.open(authUrl, '_blank', 'noopener');
} else {
// Non-embedded mode - token is already in localStorage
window.open(
`${route}${searchParams ? '?' + searchParams : ''}`,
'_blank',
'noopener noreferrer',
);
}
};
};
export const FROM_QUERY_PARAM = 'from';
/**State param is for oauth2 flow, it is used to redirect to the page after login*/
export const STATE_QUERY_PARAM = 'state';

View File

@@ -54,6 +54,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy Auth requests to Django
location /auth/ {
proxy_pass http://django:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy Admin requests to Django
location /admin/ {
proxy_pass http://django:5000;

View File

@@ -66,10 +66,12 @@ const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'))
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
const PlatformStaffEmail = React.lazy(() => import('./pages/platform/PlatformStaffEmail'));
const PlatformEmailTemplates = React.lazy(() => import('./pages/platform/PlatformEmailTemplates'));
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
const PlatformStaffInvitePage = React.lazy(() => import('./pages/platform/PlatformStaffInvitePage'));
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage'));
const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page
@@ -375,6 +377,7 @@ const AppContent: React.FC = () => {
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -411,6 +414,7 @@ const AppContent: React.FC = () => {
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -419,10 +423,10 @@ const AppContent: React.FC = () => {
);
}
// For platform subdomain, only /platform/login exists - everything else renders nothing
// For platform subdomain, only specific paths exist - everything else renders nothing
if (isPlatformSubdomain) {
const path = window.location.pathname;
const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email'];
const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email', '/platform-staff-invite'];
// If not an allowed path, render nothing
if (!allowedPaths.includes(path)) {
@@ -435,6 +439,7 @@ const AppContent: React.FC = () => {
<Route path="/platform/login" element={<PlatformLoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
</Routes>
</Suspense>
);
@@ -460,6 +465,7 @@ const AppContent: React.FC = () => {
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -599,6 +605,7 @@ const AppContent: React.FC = () => {
<>
<Route path="/platform/settings" element={<PlatformSettings />} />
<Route path="/platform/billing" element={<BillingManagement />} />
<Route path="/platform/email-templates" element={<PlatformEmailTemplates />} />
</>
)}
<Route path="/platform/profile" element={<ProfileSettings />} />

View File

@@ -9,6 +9,7 @@
import React, { useState, useMemo } from 'react';
import { Check, Sliders, Search, X } from 'lucide-react';
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
import { isWipFeature } from '../featureCatalog';
export interface FeaturePickerProps {
/** Available features from the API */
@@ -168,8 +169,13 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white">
<span className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-1.5">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
<code className="text-xs text-gray-400 dark:text-gray-500 block mt-0.5 font-mono">
{feature.code}
@@ -219,8 +225,13 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white block">
<span className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-1.5">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
{feature.code}

View File

@@ -34,6 +34,8 @@ export interface FeatureCatalogEntry {
description: string;
type: FeatureType;
category: FeatureCategory;
/** Feature is work-in-progress and not yet enforced */
wip?: boolean;
}
export type FeatureCategory =
@@ -66,13 +68,6 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
type: 'boolean',
category: 'communication',
},
{
code: 'proxy_number_enabled',
name: 'Proxy Phone Numbers',
description: 'Use proxy phone numbers for customer communication',
type: 'boolean',
category: 'communication',
},
// Payments & Commerce
{
@@ -88,6 +83,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Use Point of Sale (POS) system',
type: 'boolean',
category: 'access',
wip: true,
},
// Scheduling & Booking
@@ -97,27 +93,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Schedule recurring appointments',
type: 'boolean',
category: 'scheduling',
},
{
code: 'group_bookings',
name: 'Group Bookings',
description: 'Allow multiple customers per appointment',
type: 'boolean',
category: 'scheduling',
},
{
code: 'waitlist',
name: 'Waitlist',
description: 'Enable waitlist for fully booked slots',
type: 'boolean',
category: 'scheduling',
},
{
code: 'can_add_video_conferencing',
name: 'Video Conferencing',
description: 'Add video conferencing to events',
type: 'boolean',
category: 'scheduling',
wip: true,
},
// Access & Features
@@ -127,13 +103,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Access the public API for integrations',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_analytics',
name: 'Analytics Dashboard',
description: 'Access business analytics and reporting',
type: 'boolean',
category: 'access',
wip: true,
},
{
code: 'can_use_tasks',
@@ -149,19 +119,13 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
type: 'boolean',
category: 'access',
},
{
code: 'customer_portal',
name: 'Customer Portal',
description: 'Branded self-service portal for customers',
type: 'boolean',
category: 'access',
},
{
code: 'custom_fields',
name: 'Custom Fields',
description: 'Create custom data fields for resources and events',
description: 'Add custom intake fields to services for customer booking',
type: 'boolean',
category: 'access',
wip: true,
},
{
code: 'can_export_data',
@@ -169,44 +133,26 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Export data (appointments, customers, etc.)',
type: 'boolean',
category: 'access',
wip: true,
},
{
code: 'can_use_mobile_app',
code: 'mobile_app_access',
name: 'Mobile App',
description: 'Access the mobile app for field employees',
type: 'boolean',
category: 'access',
wip: true,
},
{
code: 'proxy_number_enabled',
name: 'Proxy Phone Numbers',
description: 'Assign dedicated phone numbers to staff for customer communication',
type: 'boolean',
category: 'communication',
wip: true,
},
// Integrations
{
code: 'calendar_sync',
name: 'Calendar Sync',
description: 'Sync with Google Calendar, Outlook, etc.',
type: 'boolean',
category: 'integrations',
},
{
code: 'webhooks_enabled',
name: 'Webhooks',
description: 'Send webhook notifications for events',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_use_plugins',
name: 'Plugin Integrations',
description: 'Use third-party plugin integrations',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_create_plugins',
name: 'Create Plugins',
description: 'Create custom plugins for automation',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_manage_oauth_credentials',
name: 'Manage OAuth',
@@ -217,21 +163,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
// Branding
{
code: 'custom_branding',
name: 'Custom Branding',
description: 'Customize branding colors, logo, and styling',
type: 'boolean',
category: 'branding',
},
{
code: 'remove_branding',
name: 'Remove Branding',
description: 'Remove SmoothSchedule branding from customer-facing pages',
type: 'boolean',
category: 'branding',
},
{
code: 'can_use_custom_domain',
code: 'custom_domain',
name: 'Custom Domain',
description: 'Configure a custom domain for your booking page',
type: 'boolean',
@@ -245,6 +177,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Get priority customer support response',
type: 'boolean',
category: 'support',
wip: true,
},
// Security & Compliance
@@ -254,6 +187,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Require two-factor authentication for users',
type: 'boolean',
category: 'security',
wip: true,
},
{
code: 'sso_enabled',
@@ -261,20 +195,15 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Enable SSO authentication for team members',
type: 'boolean',
category: 'security',
wip: true,
},
{
code: 'can_delete_data',
name: 'Delete Data',
description: 'Permanently delete data',
type: 'boolean',
category: 'security',
},
{
code: 'can_download_logs',
name: 'Download Logs',
description: 'Download system logs',
code: 'audit_logs',
name: 'Audit Logs',
description: 'Track changes and download audit logs',
type: 'boolean',
category: 'security',
wip: true,
},
];
@@ -406,6 +335,14 @@ export const isCanonicalFeature = (code: string): boolean => {
return featureMap.has(code);
};
/**
* Check if a feature is work-in-progress (not yet enforced)
*/
export const isWipFeature = (code: string): boolean => {
const feature = featureMap.get(code);
return feature?.wip ?? false;
};
/**
* Get all features by type
*/

View File

@@ -0,0 +1,150 @@
/**
* AppointmentQuotaBanner Component
*
* Shows a warning banner when the user has reached 90% of their monthly
* appointment quota. Dismissable per-user per-billing-period.
*
* This is different from QuotaWarningBanner which handles grace period
* overages for permanent limits like max_users.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { AlertTriangle, X, TrendingUp, Calendar } from 'lucide-react';
import { useQuotaStatus, useDismissQuotaBanner } from '../hooks/useQuotaStatus';
interface AppointmentQuotaBannerProps {
/** Only show for owners/managers who can take action */
userRole?: string;
}
const AppointmentQuotaBanner: React.FC<AppointmentQuotaBannerProps> = ({ userRole }) => {
const { t } = useTranslation();
const { data: quotaStatus, isLoading } = useQuotaStatus();
const dismissMutation = useDismissQuotaBanner();
// Don't show while loading or if no data
if (isLoading || !quotaStatus) {
return null;
}
// Don't show if banner shouldn't be shown
if (!quotaStatus.warning.show_banner) {
return null;
}
// Only show for owners and managers who can take action
if (userRole && !['owner', 'manager'].includes(userRole)) {
return null;
}
const { appointments, billing_period } = quotaStatus;
// Don't show if unlimited
if (appointments.is_unlimited) {
return null;
}
const isOverQuota = appointments.is_over_quota;
const percentage = Math.round(appointments.usage_percentage);
const handleDismiss = () => {
dismissMutation.mutate();
};
// Format billing period for display
const billingPeriodDisplay = new Date(
billing_period.year,
billing_period.month - 1
).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
return (
<div
className={`border-b ${
isOverQuota
? 'bg-gradient-to-r from-red-500 to-red-600 text-white'
: 'bg-gradient-to-r from-amber-400 to-amber-500 text-amber-950'
}`}
>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between flex-wrap gap-2">
{/* Left: Warning Info */}
<div className="flex items-center gap-3 flex-1">
<div
className={`p-2 rounded-full ${
isOverQuota ? 'bg-white/20' : 'bg-amber-600/20'
}`}
>
{isOverQuota ? (
<AlertTriangle className="h-5 w-5" />
) : (
<TrendingUp className="h-5 w-5" />
)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<span className="font-semibold text-sm sm:text-base">
{isOverQuota
? t('quota.appointmentBanner.overTitle', 'Appointment Quota Exceeded')
: t('quota.appointmentBanner.warningTitle', 'Approaching Appointment Limit')}
</span>
<span className="text-sm opacity-90 flex items-center gap-1">
<Calendar className="h-4 w-4 hidden sm:inline" />
{t('quota.appointmentBanner.usage', '{{used}} of {{limit}} ({{percentage}}%)', {
used: appointments.count,
limit: appointments.limit,
percentage,
})}
{' • '}
{billingPeriodDisplay}
</span>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
{isOverQuota && appointments.overage_count > 0 && (
<span className="text-xs sm:text-sm px-2 py-1 bg-white/20 rounded">
{t('quota.appointmentBanner.overage', '+{{count}} @ $0.10 each', {
count: appointments.overage_count,
})}
</span>
)}
<Link
to="/dashboard/settings/billing"
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isOverQuota
? 'bg-white text-red-600 hover:bg-red-50'
: 'bg-amber-700 text-white hover:bg-amber-800'
}`}
>
{t('quota.appointmentBanner.upgrade', 'Upgrade Plan')}
</Link>
<button
onClick={handleDismiss}
disabled={dismissMutation.isPending}
className={`p-1.5 rounded-md transition-colors ${
isOverQuota ? 'hover:bg-white/20' : 'hover:bg-amber-600/20'
}`}
aria-label={t('common.dismiss', 'Dismiss')}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Additional info for over-quota */}
{isOverQuota && (
<div className="mt-2 text-sm opacity-90">
{t(
'quota.appointmentBanner.overageInfo',
'Appointments over your limit will be billed at $0.10 each at the end of your billing cycle.'
)}
</div>
)}
</div>
</div>
);
};
export default AppointmentQuotaBanner;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard, Inbox } from 'lucide-react';
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard, Inbox, FileText } from 'lucide-react';
import { User } from '../types';
import SmoothScheduleLogo from './SmoothScheduleLogo';
@@ -81,6 +81,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
<Shield size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.staff')}</span>}
</Link>
<Link to="/platform/email-templates" className={getNavClass('/platform/email-templates')} title={t('nav.emailTemplates', 'Email Templates')}>
<FileText size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.emailTemplates', 'Email Templates')}</span>}
</Link>
<Link to="/platform/billing" className={getNavClass('/platform/billing')} title="Billing Management">
<CreditCard size={18} className="shrink-0" />
{!isCollapsed && <span>Billing</span>}

View File

@@ -0,0 +1,179 @@
/**
* StorageQuotaBanner Component
*
* Shows a warning banner when the user has reached 90% of their database
* storage quota. This helps business owners be aware of storage usage
* and potential overage charges.
*
* Storage is measured periodically by a backend task and cached.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { AlertTriangle, X, Database, HardDrive } from 'lucide-react';
import { useQuotaStatus } from '../hooks/useQuotaStatus';
interface StorageQuotaBannerProps {
/** Only show for owners/managers who can take action */
userRole?: string;
/** Callback when banner is dismissed */
onDismiss?: () => void;
}
const StorageQuotaBanner: React.FC<StorageQuotaBannerProps> = ({ userRole, onDismiss }) => {
const { t } = useTranslation();
const { data: quotaStatus, isLoading } = useQuotaStatus();
// Don't show while loading or if no data
if (isLoading || !quotaStatus) {
return null;
}
const { storage, billing_period } = quotaStatus;
// Don't show if unlimited
if (storage.is_unlimited) {
return null;
}
// Don't show if not at warning threshold
if (!storage.is_at_warning_threshold) {
return null;
}
// Only show for owners and managers who can take action
if (userRole && !['owner', 'manager'].includes(userRole)) {
return null;
}
const isOverQuota = storage.is_over_quota;
const percentage = Math.round(storage.usage_percentage);
// Format storage sizes for display
const formatSize = (mb: number): string => {
if (mb >= 1024) {
return `${(mb / 1024).toFixed(1)} GB`;
}
return `${mb.toFixed(1)} MB`;
};
const currentDisplay = formatSize(storage.current_size_mb);
const limitDisplay = formatSize(storage.quota_limit_mb);
const overageDisplay = storage.overage_mb > 0 ? formatSize(storage.overage_mb) : null;
// Format billing period for display
const billingPeriodDisplay = new Date(
billing_period.year,
billing_period.month - 1
).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
// Format last measured time
const lastMeasuredDisplay = storage.last_measured_at
? new Date(storage.last_measured_at).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
: null;
return (
<div
className={`border-b ${
isOverQuota
? 'bg-gradient-to-r from-purple-600 to-purple-700 text-white'
: 'bg-gradient-to-r from-purple-400 to-purple-500 text-purple-950'
}`}
>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between flex-wrap gap-2">
{/* Left: Warning Info */}
<div className="flex items-center gap-3 flex-1">
<div
className={`p-2 rounded-full ${
isOverQuota ? 'bg-white/20' : 'bg-purple-600/20'
}`}
>
{isOverQuota ? (
<AlertTriangle className="h-5 w-5" />
) : (
<Database className="h-5 w-5" />
)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<span className="font-semibold text-sm sm:text-base">
{isOverQuota
? t('quota.storageBanner.overTitle', 'Storage Quota Exceeded')
: t('quota.storageBanner.warningTitle', 'Approaching Storage Limit')}
</span>
<span className="text-sm opacity-90 flex items-center gap-1">
<HardDrive className="h-4 w-4 hidden sm:inline" />
{t('quota.storageBanner.usage', '{{used}} of {{limit}} ({{percentage}}%)', {
used: currentDisplay,
limit: limitDisplay,
percentage,
})}
{' • '}
{billingPeriodDisplay}
</span>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
{isOverQuota && overageDisplay && (
<span className="text-xs sm:text-sm px-2 py-1 bg-white/20 rounded">
{t('quota.storageBanner.overage', '+{{size}} @ $0.50/GB', {
size: overageDisplay,
})}
</span>
)}
<Link
to="/dashboard/settings/billing"
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isOverQuota
? 'bg-white text-purple-600 hover:bg-purple-50'
: 'bg-purple-700 text-white hover:bg-purple-800'
}`}
>
{t('quota.storageBanner.upgrade', 'Upgrade Plan')}
</Link>
{onDismiss && (
<button
onClick={onDismiss}
className={`p-1.5 rounded-md transition-colors ${
isOverQuota ? 'hover:bg-white/20' : 'hover:bg-purple-600/20'
}`}
aria-label={t('common.dismiss', 'Dismiss')}
>
<X className="h-5 w-5" />
</button>
)}
</div>
</div>
{/* Additional info for over-quota */}
{isOverQuota && (
<div className="mt-2 text-sm opacity-90">
{t(
'quota.storageBanner.overageInfo',
'Storage over your limit will be billed at $0.50 per GB at the end of your billing cycle.'
)}
</div>
)}
{/* Last measured timestamp */}
{lastMeasuredDisplay && (
<div className="mt-1 text-xs opacity-70">
{t('quota.storageBanner.lastMeasured', 'Last measured: {{time}}', {
time: lastMeasuredDisplay,
})}
</div>
)}
</div>
</div>
);
};
export default StorageQuotaBanner;

View File

@@ -52,8 +52,7 @@ const FEATURE_CATEGORIES = [
key: 'branding',
features: [
{ code: 'custom_domain', label: 'Custom domain' },
{ code: 'custom_branding', label: 'Custom branding' },
{ code: 'remove_branding', label: 'Remove branding' },
{ code: 'can_white_label', label: 'White label branding' },
],
},
{

View File

@@ -12,6 +12,7 @@
import React, { useMemo } from 'react';
import { Key, AlertCircle } from 'lucide-react';
import { useBillingFeatures, BillingFeature, FEATURE_CATEGORY_META } from '../../hooks/useBillingPlans';
import { isWipFeature } from '../../billing/featureCatalog';
export interface DynamicFeaturesEditorProps {
/**
@@ -62,6 +63,11 @@ export interface DynamicFeaturesEditorProps {
* Number of columns (default: 3)
*/
columns?: 2 | 3 | 4;
/**
* Disable all inputs (for read-only mode)
*/
disabled?: boolean;
}
const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
@@ -74,6 +80,7 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
headerTitle = 'Features & Permissions',
showDescriptions = false,
columns = 3,
disabled = false,
}) => {
const { data: features, isLoading, error } = useBillingFeatures();
@@ -223,12 +230,13 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
if (feature.feature_type === 'boolean') {
const isChecked = currentValue === true;
const isInputDisabled = isDisabled || disabled;
return (
<label
key={feature.code}
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
isDisabled
isInputDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`}
@@ -237,12 +245,17 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
type="checkbox"
checked={isChecked}
onChange={(e) => handleChange(feature, e.target.checked)}
disabled={isDisabled}
disabled={isInputDisabled}
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 block">
<span className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1.5">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
{showDescriptions && feature.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
@@ -261,10 +274,15 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
return (
<div
key={feature.code}
className="p-2 border border-gray-200 dark:border-gray-700 rounded-lg"
className={`p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${disabled ? 'opacity-50' : ''}`}
>
<label className="text-sm text-gray-700 dark:text-gray-300 block mb-1">
<label className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1.5 mb-1">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</label>
<div className="flex items-center gap-2">
<input
@@ -275,7 +293,8 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
const val = parseInt(e.target.value);
handleChange(feature, val === -1 ? null : val);
}}
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-indigo-500"
disabled={disabled}
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{showDescriptions && (

View File

@@ -12,6 +12,7 @@
import React from 'react';
import { Key } from 'lucide-react';
import { isWipFeature } from '../../billing/featureCatalog';
/**
* Permission definition with metadata
@@ -117,21 +118,21 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
category: 'customization',
},
{
key: 'remove_branding',
key: 'white_label',
planKey: 'can_white_label',
businessKey: 'can_white_label',
label: 'Remove Branding',
description: 'Remove SmoothSchedule branding from customer-facing pages',
label: 'White Label',
description: 'Customize branding and remove SmoothSchedule branding',
category: 'customization',
},
// Plugins & Automation
// Automations
{
key: 'plugins',
planKey: 'can_use_plugins',
businessKey: 'can_use_plugins',
label: 'Use Plugins',
description: 'Install and use marketplace plugins',
key: 'automations',
planKey: 'can_use_automations',
businessKey: 'can_use_automations',
label: 'Automations',
description: 'Install and use marketplace automations',
category: 'plugins',
},
{
@@ -141,16 +142,16 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
label: 'Scheduled Tasks',
description: 'Create automated scheduled tasks',
category: 'plugins',
dependsOn: 'plugins',
dependsOn: 'automations',
},
{
key: 'create_plugins',
planKey: 'can_create_plugins',
businessKey: 'can_create_plugins',
label: 'Create Plugins',
description: 'Build custom plugins',
key: 'create_automations',
planKey: 'can_create_automations',
businessKey: 'can_create_automations',
label: 'Create Automations',
description: 'Build custom automations',
category: 'plugins',
dependsOn: 'plugins',
dependsOn: 'automations',
},
// Advanced Features
@@ -172,7 +173,7 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
},
{
key: 'calendar_sync',
planKey: 'calendar_sync',
planKey: 'can_use_calendar_sync',
businessKey: 'can_use_calendar_sync',
label: 'Calendar Sync',
description: 'Sync with Google Calendar, etc.',
@@ -186,14 +187,6 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
description: 'Export data to CSV/Excel',
category: 'advanced',
},
{
key: 'video_conferencing',
planKey: 'video_conferencing',
businessKey: 'can_add_video_conferencing',
label: 'Video Conferencing',
description: 'Add video links to appointments',
category: 'advanced',
},
{
key: 'advanced_reporting',
planKey: 'advanced_reporting',
@@ -240,8 +233,8 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
key: 'sso_enabled',
planKey: 'sso_enabled',
businessKey: 'sso_enabled',
label: 'SSO / SAML',
description: 'Single sign-on integration',
label: 'Single Sign-On (SSO)',
description: 'Enable SSO/SAML authentication for team members',
category: 'enterprise',
},
{
@@ -499,8 +492,13 @@ const FeaturesPermissionsEditor: React.FC<FeaturesPermissionsEditorProps> = ({
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 block">
<span className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1.5">
{def.label}
{isWipFeature(def.planKey || def.key) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
{showDescriptions && def.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">

View File

@@ -280,7 +280,7 @@ export const PERMISSION_TO_FEATURE_CODE: Record<string, string> = {
// Platform
can_api_access: 'api_access',
can_use_custom_domain: 'custom_domain',
can_white_label: 'remove_branding',
can_white_label: 'can_white_label',
// Features
can_accept_payments: 'payment_processing',
@@ -328,8 +328,9 @@ export function planFeaturesToLegacyPermissions(
case 'custom_domain':
permissions.can_use_custom_domain = value as boolean;
break;
case 'remove_branding':
case 'can_white_label':
permissions.can_white_label = value as boolean;
permissions.can_customize_booking_page = value as boolean;
break;
case 'payment_processing':
permissions.can_accept_payments = value as boolean;
@@ -356,9 +357,6 @@ export function planFeaturesToLegacyPermissions(
case 'audit_logs':
permissions.can_download_logs = value as boolean;
break;
case 'custom_branding':
permissions.can_customize_booking_page = value as boolean;
break;
case 'recurring_appointments':
permissions.can_book_repeated_events = value as boolean;
break;

View File

@@ -60,13 +60,12 @@ export const useCurrentBusiness = () => {
webhooks: false,
api_access: false,
custom_domain: false,
remove_branding: false,
white_label: false,
custom_oauth: false,
automations: false,
can_create_automations: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,

View File

@@ -81,14 +81,12 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
webhooks: 'Webhooks',
api_access: 'API Access',
custom_domain: 'Custom Domain',
custom_branding: 'Custom Branding',
remove_branding: 'Remove Branding',
white_label: 'White Label',
custom_oauth: 'Custom OAuth',
automations: 'Automations',
can_create_automations: 'Custom Automation Creation',
tasks: 'Scheduled Tasks',
export_data: 'Data Export',
video_conferencing: 'Video Conferencing',
two_factor_auth: 'Two-Factor Authentication',
masked_calling: 'Masked Calling',
pos_system: 'POS System',
@@ -105,14 +103,12 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
webhooks: 'Integrate with external services using webhooks',
api_access: 'Access the SmoothSchedule API for custom integrations',
custom_domain: 'Use your own custom domain for your booking site',
custom_branding: 'Customize branding colors, logo, and styling',
remove_branding: 'Remove SmoothSchedule branding from customer-facing pages',
white_label: 'Customize branding and remove SmoothSchedule branding',
custom_oauth: 'Configure your own OAuth credentials for social login',
automations: 'Automate repetitive tasks with custom workflows',
can_create_automations: 'Create custom automations tailored to your business needs',
tasks: 'Create scheduled tasks to automate execution',
export_data: 'Export your data to CSV or other formats',
video_conferencing: 'Add video conferencing links to appointments',
two_factor_auth: 'Require two-factor authentication for enhanced security',
masked_calling: 'Use masked phone numbers to protect privacy',
pos_system: 'Process in-person payments with Point of Sale',

View File

@@ -0,0 +1,116 @@
/**
* Platform Email Templates Hooks
*
* React Query hooks for managing platform-level email templates.
* These templates are used for platform communications like tenant invitations,
* trial notifications, billing alerts, etc.
*
* Access: Superusers only
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
import {
PlatformEmailTemplate,
PlatformEmailTemplateDetail,
PlatformEmailTemplatePreview,
PlatformEmailTemplateUpdate,
PlatformEmailType,
} from '../types';
// Query keys
const QUERY_KEYS = {
all: ['platform-email-templates'] as const,
detail: (type: PlatformEmailType) => ['platform-email-templates', type] as const,
};
/**
* Fetch all platform email templates
*/
export function usePlatformEmailTemplates() {
return useQuery<PlatformEmailTemplate[]>({
queryKey: QUERY_KEYS.all,
queryFn: async () => {
const { data } = await api.get('/platform/email-templates/');
return data;
},
});
}
/**
* Fetch a single platform email template by type
*/
export function usePlatformEmailTemplate(emailType: PlatformEmailType | null) {
return useQuery<PlatformEmailTemplateDetail>({
queryKey: emailType ? QUERY_KEYS.detail(emailType) : ['platform-email-templates', 'none'],
queryFn: async () => {
if (!emailType) throw new Error('No email type specified');
const { data } = await api.get(`/platform/email-templates/${emailType}/`);
return data;
},
enabled: !!emailType,
});
}
/**
* Update a platform email template
*/
export function useUpdatePlatformEmailTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
emailType,
data,
}: {
emailType: PlatformEmailType;
data: PlatformEmailTemplateUpdate;
}) => {
const response = await api.put(`/platform/email-templates/${emailType}/`, data);
return response.data as PlatformEmailTemplateDetail;
},
onSuccess: (_, variables) => {
// Invalidate both the list and the specific template
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.detail(variables.emailType) });
},
});
}
/**
* Reset a platform email template to its default
*/
export function useResetPlatformEmailTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (emailType: PlatformEmailType) => {
const response = await api.post(`/platform/email-templates/${emailType}/reset/`);
return response.data;
},
onSuccess: (_, emailType) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.detail(emailType) });
},
});
}
/**
* Preview a rendered platform email template
*/
export function usePreviewPlatformEmailTemplate() {
return useMutation({
mutationFn: async ({
emailType,
context = {},
}: {
emailType: PlatformEmailType;
context?: Record<string, string>;
}) => {
const response = await api.post(`/platform/email-templates/${emailType}/preview/`, {
context,
});
return response.data as PlatformEmailTemplatePreview;
},
});
}

View File

@@ -0,0 +1,178 @@
/**
* Platform Staff Invitations Hooks
*
* React Query hooks for managing platform staff invitations.
* These are used to invite new platform_manager and platform_support users.
*
* Access: Superusers only
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
// Types
export type PlatformStaffRole = 'platform_manager' | 'platform_support';
export interface PlatformStaffInvitation {
id: number;
email: string;
role: PlatformStaffRole;
role_display: string;
status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'CANCELLED';
status_display: string;
invited_by: string | null;
invited_by_email: string | null;
created_at: string;
expires_at: string | null;
accepted_at: string | null;
is_valid: boolean;
}
export interface PlatformStaffInvitationDetail extends PlatformStaffInvitation {
role_description: string;
personal_message: string;
permissions: Record<string, boolean>;
}
export interface CreatePlatformStaffInvitationData {
email: string;
role: PlatformStaffRole;
personal_message?: string;
}
export interface AcceptPlatformStaffInvitationData {
password: string;
first_name?: string;
last_name?: string;
}
export interface PlatformStaffInvitationTokenData {
email: string;
role: PlatformStaffRole;
role_display: string;
role_description: string;
invited_by: string | null;
personal_message: string;
expires_at: string | null;
}
// Query keys
const QUERY_KEYS = {
all: ['platform-staff-invitations'] as const,
detail: (id: number) => ['platform-staff-invitations', id] as const,
token: (token: string) => ['platform-staff-invitations', 'token', token] as const,
};
/**
* Fetch all platform staff invitations
*/
export function usePlatformStaffInvitations(statusFilter?: string) {
return useQuery<PlatformStaffInvitation[]>({
queryKey: [...QUERY_KEYS.all, { status: statusFilter }],
queryFn: async () => {
const params = statusFilter ? { status: statusFilter } : {};
const { data } = await api.get('/platform/staff-invitations/', { params });
return data;
},
});
}
/**
* Fetch a single platform staff invitation by ID
*/
export function usePlatformStaffInvitation(id: number | null) {
return useQuery<PlatformStaffInvitationDetail>({
queryKey: id ? QUERY_KEYS.detail(id) : ['platform-staff-invitations', 'none'],
queryFn: async () => {
if (!id) throw new Error('No invitation ID specified');
const { data } = await api.get(`/platform/staff-invitations/${id}/`);
return data;
},
enabled: !!id,
});
}
/**
* Create a new platform staff invitation
*/
export function useCreatePlatformStaffInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (invitationData: CreatePlatformStaffInvitationData) => {
const response = await api.post('/platform/staff-invitations/', invitationData);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
},
});
}
/**
* Resend a platform staff invitation email
*/
export function useResendPlatformStaffInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await api.post(`/platform/staff-invitations/${id}/resend/`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
},
});
}
/**
* Cancel a platform staff invitation
*/
export function useCancelPlatformStaffInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await api.post(`/platform/staff-invitations/${id}/cancel/`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
},
});
}
/**
* Fetch invitation details by token (public endpoint)
*/
export function usePlatformStaffInvitationByToken(token: string | null) {
return useQuery<PlatformStaffInvitationTokenData>({
queryKey: token ? QUERY_KEYS.token(token) : ['platform-staff-invitations', 'token', 'none'],
queryFn: async () => {
if (!token) throw new Error('No token specified');
const { data } = await api.get(`/platform/staff-invitations/token/${token}/`);
return data;
},
enabled: !!token,
retry: false, // Don't retry on failure (invalid token)
});
}
/**
* Accept a platform staff invitation (public endpoint)
*/
export function useAcceptPlatformStaffInvitation() {
return useMutation({
mutationFn: async ({
token,
data,
}: {
token: string;
data: AcceptPlatformStaffInvitationData;
}) => {
const response = await api.post(`/platform/staff-invitations/token/${token}/accept/`, data);
return response.data;
},
});
}

View File

@@ -0,0 +1,111 @@
/**
* useQuotaStatus Hook
*
* Fetches monthly quota status from the billing API.
* Used to show warning banners when approaching quota limits.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export interface QuotaStatus {
billing_period: {
year: number;
month: number;
};
appointments: {
count: number;
limit: number;
is_unlimited: boolean;
usage_percentage: number;
remaining: number | null;
is_at_warning_threshold: boolean;
is_over_quota: boolean;
overage_count: number;
overage_amount_cents: number;
};
flow_executions: {
count: number;
amount_cents: number;
};
api_requests: {
date: string;
request_count: number;
quota_limit: number;
is_unlimited: boolean;
is_over_quota: boolean;
remaining_requests: number | null;
usage_percentage: number;
};
storage: {
current_size_mb: number;
current_size_gb: number;
peak_size_mb: number;
quota_limit_mb: number;
quota_limit_gb: number;
is_unlimited: boolean;
usage_percentage: number;
remaining_mb: number | null;
is_at_warning_threshold: boolean;
is_over_quota: boolean;
overage_mb: number;
overage_amount_cents: number;
warning_email_sent: boolean;
last_measured_at: string | null;
};
warning: {
show_banner: boolean;
email_sent: boolean;
};
}
/**
* Fetch current quota status
*/
export const fetchQuotaStatus = async (): Promise<QuotaStatus> => {
const response = await apiClient.get('/me/quota/');
return response.data;
};
/**
* Dismiss the quota warning banner for the current billing period
*/
export const dismissQuotaBanner = async (): Promise<void> => {
await apiClient.post('/me/quota/dismiss-banner/');
};
/**
* Hook to fetch quota status
*/
export const useQuotaStatus = () => {
return useQuery({
queryKey: ['quotaStatus'],
queryFn: fetchQuotaStatus,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
};
/**
* Hook to dismiss the quota warning banner
*/
export const useDismissQuotaBanner = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: dismissQuotaBanner,
onSuccess: () => {
// Update the cached quota status to hide the banner
queryClient.setQueryData<QuotaStatus>(['quotaStatus'], (old) => {
if (!old) return old;
return {
...old,
warning: {
...old.warning,
show_banner: false,
},
};
});
},
});
};

View File

@@ -2368,6 +2368,74 @@
"staff": "Staff",
"customer": "Customer"
},
"emailTemplates": {
"title": "Platform Email Templates",
"description": "Customize platform-level automated emails for tenant invitations, trial notifications, billing alerts, and more.",
"aboutTags": "About Template Tags",
"tagsDescription": "Use template tags like {{ tenant_name }} to insert dynamic content. Available tags vary by email type and are shown when editing each template.",
"categories": {
"invitation": "Invitations",
"trial": "Trial & Onboarding",
"subscription": "Subscription Changes",
"billing": "Billing & Payments"
},
"types": {
"tenant_invitation": {
"name": "Tenant Invitation",
"description": "Sent when inviting a new business to join the platform"
},
"trial_expiration_warning": {
"name": "Trial Expiration Warning",
"description": "Sent a few days before a business trial expires"
},
"trial_expired": {
"name": "Trial Expired",
"description": "Sent when a business trial has expired"
},
"plan_upgrade": {
"name": "Plan Upgrade Confirmation",
"description": "Sent when a business upgrades their subscription plan"
},
"plan_downgrade": {
"name": "Plan Downgrade Confirmation",
"description": "Sent when a business downgrades their subscription plan"
},
"subscription_cancelled": {
"name": "Subscription Cancelled",
"description": "Sent when a business cancels their subscription"
},
"payment_failed": {
"name": "Payment Failed",
"description": "Sent when a recurring payment fails"
},
"payment_succeeded": {
"name": "Payment Succeeded",
"description": "Sent after a successful subscription payment"
}
},
"customized": "Customized",
"disabled": "Disabled",
"subject": "Subject",
"edit": "Edit",
"resetToDefault": "Reset to Default",
"resetConfirmTitle": "Reset to Default?",
"resetConfirmMessage": "This will reset the email template to its default content. Any customizations you've made will be lost.",
"cancel": "Cancel",
"save": "Save",
"preview": "Preview",
"unsavedChanges": "Unsaved changes",
"emailSubject": "Email Subject",
"subjectPlaceholder": "Enter email subject...",
"subjectHint": "Use tags like {{ tenant_name }} for dynamic content",
"availableTags": "Available Template Tags",
"clickToCopy": "Click a tag to copy it. Hover for description.",
"emailPreview": "Email Preview",
"close": "Close",
"failedToLoad": "Failed to load template",
"failedToSave": "Failed to save template",
"failedToReset": "Failed to reset template",
"failedToPreview": "Failed to generate preview"
},
"settings": {
"title": "Platform Settings",
"description": "Configure platform-wide settings and integrations",
@@ -2777,8 +2845,7 @@
"api_access": "API access",
"max_api_calls_per_day": "API calls/day",
"custom_domain": "Custom domain",
"custom_branding": "Custom branding",
"remove_branding": "Remove branding",
"can_white_label": "White label branding",
"multi_location": "Multi-location management",
"team_permissions": "Team permissions",
"audit_logs": "Audit logs",
@@ -3423,6 +3490,23 @@
"autoArchiveWarning": "After the grace period, the oldest {{count}} {{type}} will be automatically archived.",
"noOverages": "You are within your plan limits.",
"resolved": "Resolved! Your usage is now within limits."
},
"appointmentBanner": {
"warningTitle": "Approaching Appointment Limit",
"overTitle": "Appointment Quota Exceeded",
"usage": "{{used}} of {{limit}} ({{percentage}}%)",
"overage": "+{{count}} @ $0.10 each",
"upgrade": "Upgrade Plan",
"overageInfo": "Appointments over your limit will be billed at $0.10 each at the end of your billing cycle."
},
"storageBanner": {
"warningTitle": "Approaching Storage Limit",
"overTitle": "Storage Quota Exceeded",
"usage": "{{used}} of {{limit}} ({{percentage}}%)",
"overage": "+{{size}} @ $0.50/GB",
"upgrade": "Upgrade Plan",
"overageInfo": "Storage over your limit will be billed at $0.50 per GB at the end of your billing cycle.",
"lastMeasured": "Last measured: {{time}}"
}
},
"upgrade": {

View File

@@ -6,6 +6,8 @@ import TrialBanner from '../components/TrialBanner';
import SandboxBanner from '../components/SandboxBanner';
import QuotaWarningBanner from '../components/QuotaWarningBanner';
import QuotaOverageModal, { resetQuotaOverageModalDismissal } from '../components/QuotaOverageModal';
import AppointmentQuotaBanner from '../components/AppointmentQuotaBanner';
import StorageQuotaBanner from '../components/StorageQuotaBanner';
import { Business, User } from '../types';
import MasqueradeBanner from '../components/MasqueradeBanner';
import OnboardingWizard from '../components/OnboardingWizard';
@@ -200,7 +202,7 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
onStop={handleStopMasquerade}
/>
)}
{/* Quota overage warning banner - show for owners and managers */}
{/* Quota overage warning banner - show for owners and managers (grace period system) */}
{user.quota_overages && user.quota_overages.length > 0 && (
<QuotaWarningBanner overages={user.quota_overages} />
)}
@@ -208,6 +210,10 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
{user.quota_overages && user.quota_overages.length > 0 && (
<QuotaOverageModal overages={user.quota_overages} onDismiss={() => {}} />
)}
{/* Appointment quota warning banner - 90% threshold warning (billing cycle system) */}
<AppointmentQuotaBanner userRole={user.role} />
{/* Storage quota warning banner - 90% threshold warning */}
<StorageQuotaBanner userRole={user.role} />
{/* Sandbox mode banner */}
<SandboxBannerWrapper />
{/* Show trial banner if trial is active and payments not yet enabled */}

View File

@@ -44,7 +44,7 @@ interface ParentContext {
// Map settings pages to their required plan features
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = {
'/dashboard/settings/branding': 'custom_branding',
'/dashboard/settings/branding': 'white_label',
'/dashboard/settings/custom-domains': 'custom_domain',
'/dashboard/settings/api': 'api_access',
'/dashboard/settings/authentication': 'custom_oauth',

View File

@@ -387,18 +387,6 @@ export default function Automations() {
<RefreshCw className="h-5 w-5" />
</button>
{/* Open in new tab */}
{embedData?.embedUrl && (
<a
href={embedData.embedUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={t('automations.openInTab', 'Open in new tab')}
>
<ExternalLink className="h-5 w-5" />
</a>
)}
</div>
</div>
</div>

View File

@@ -61,7 +61,7 @@ const transformPuckDataForEditor = (puckData: any): any => {
...item,
props: {
...item.props,
id: `${item.type}-${index}-${crypto.randomUUID().substring(0, 8)}`,
id: `${item.type}-${index}-${Math.random().toString(36).substring(2, 10)}`,
},
};
}

View File

@@ -0,0 +1,635 @@
/**
* Platform Email Templates Page
*
* Allows superusers to customize platform-level automated emails
* (tenant invitations, trial notifications, billing alerts, etc.) using a Puck editor.
*
* Access: Superusers only
*/
import React, { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Puck } from '@measured/puck';
import '@measured/puck/puck.css';
import {
Mail,
Edit2,
RotateCcw,
Eye,
X,
AlertTriangle,
UserPlus,
Clock,
CreditCard,
Package,
ChevronDown,
ChevronRight,
Info,
Save,
Loader2,
Code,
MonitorPlay,
} from 'lucide-react';
import { getEmailEditorConfig } from '../../puck/emailConfig';
import {
PlatformEmailTemplate,
PlatformEmailTemplateDetail,
PlatformEmailTag,
PlatformEmailCategory,
PlatformEmailType,
} from '../../types';
import {
usePlatformEmailTemplates,
useUpdatePlatformEmailTemplate,
useResetPlatformEmailTemplate,
usePreviewPlatformEmailTemplate,
} from '../../hooks/usePlatformEmailTemplates';
import api from '../../api/client';
// Category metadata
const CATEGORY_CONFIG: Record<PlatformEmailCategory, {
label: string;
icon: React.ReactNode;
color: string;
}> = {
invitation: {
label: 'Invitations',
icon: <UserPlus className="h-5 w-5" />,
color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
},
trial: {
label: 'Trial & Onboarding',
icon: <Clock className="h-5 w-5" />,
color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
},
subscription: {
label: 'Subscription Changes',
icon: <Package className="h-5 w-5" />,
color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
},
billing: {
label: 'Billing & Payments',
icon: <CreditCard className="h-5 w-5" />,
color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
},
};
// Category order for display
const CATEGORY_ORDER: PlatformEmailCategory[] = [
'invitation',
'trial',
'subscription',
'billing',
];
const PlatformEmailTemplates: React.FC = () => {
const { t } = useTranslation();
const [expandedCategories, setExpandedCategories] = useState<Set<PlatformEmailCategory>>(
new Set(CATEGORY_ORDER)
);
const [editingTemplate, setEditingTemplate] = useState<PlatformEmailTemplateDetail | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewSubject, setPreviewSubject] = useState<string>('');
const [showResetConfirm, setShowResetConfirm] = useState<string | null>(null);
const [editorData, setEditorData] = useState<any>(null);
const [editorSubject, setEditorSubject] = useState<string>('');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Get email editor config
const editorConfig = getEmailEditorConfig();
// Fetch all email templates
const { data: templates = [], isLoading } = usePlatformEmailTemplates();
// Mutations
const updateMutation = useUpdatePlatformEmailTemplate();
const resetMutation = useResetPlatformEmailTemplate();
const previewMutation = usePreviewPlatformEmailTemplate();
// Fix Puck sidebar scrolling when editor is open
useEffect(() => {
if (!editingTemplate) return;
const fixSidebars = () => {
const leftSidebar = document.querySelector('[class*="Sidebar--left"]') as HTMLElement;
const rightSidebar = document.querySelector('[class*="Sidebar--right"]') as HTMLElement;
[leftSidebar, rightSidebar].forEach((sidebar) => {
if (!sidebar) return;
sidebar.style.maxHeight = 'calc(100vh - 300px)';
sidebar.style.overflowY = 'auto';
const innerSections = sidebar.querySelectorAll('[class*="SidebarSection"]');
innerSections.forEach((section) => {
(section as HTMLElement).style.overflow = 'visible';
(section as HTMLElement).style.maxHeight = 'none';
});
});
};
const timer = setTimeout(fixSidebars, 200);
return () => clearTimeout(timer);
}, [editingTemplate]);
// Fetch single template with tags
const fetchTemplateDetail = async (emailType: PlatformEmailType): Promise<PlatformEmailTemplateDetail> => {
const { data } = await api.get(`/platform/email-templates/${emailType}/`);
return data;
};
// Group templates by category
const templatesByCategory = React.useMemo(() => {
const grouped: Record<PlatformEmailCategory, PlatformEmailTemplate[]> = {
invitation: [],
trial: [],
subscription: [],
billing: [],
};
templates.forEach((template) => {
if (grouped[template.category]) {
grouped[template.category].push(template);
}
});
return grouped;
}, [templates]);
// Toggle category expansion
const toggleCategory = (category: PlatformEmailCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
/**
* Transform puck_data to ensure id is inside props (Puck requirement).
*/
const transformPuckDataForEditor = (puckData: any): any => {
if (!puckData?.content) return puckData;
return {
...puckData,
content: puckData.content.map((item: any, index: number) => {
const rootId = item.id;
const hasPropsId = item.props?.id;
if (rootId && !hasPropsId) {
const { id, ...rest } = item;
return {
...rest,
props: {
...rest.props,
id: rootId,
},
};
} else if (!hasPropsId) {
return {
...item,
props: {
...item.props,
id: `${item.type}-${index}-${Math.random().toString(36).substring(2, 10)}`,
},
};
}
return item;
}),
};
};
// Open editor for a template
const handleEdit = async (template: PlatformEmailTemplate) => {
try {
const detail = await fetchTemplateDetail(template.email_type);
const transformedData = transformPuckDataForEditor(detail.puck_data);
setEditingTemplate(detail);
setEditorData(transformedData);
setEditorSubject(detail.subject_template);
setHasUnsavedChanges(false);
} catch (error) {
console.error('Failed to load template:', error);
}
};
// Handle data changes in editor
const handleEditorChange = useCallback((newData: any) => {
setEditorData(newData);
setHasUnsavedChanges(true);
}, []);
// Save template
const handleSave = async () => {
if (!editingTemplate) return;
try {
await updateMutation.mutateAsync({
emailType: editingTemplate.email_type,
data: {
subject_template: editorSubject,
puck_data: editorData,
},
});
setHasUnsavedChanges(false);
setEditingTemplate(null);
} catch (error: any) {
console.error('Failed to save template:', error);
const errorMsg = error?.response?.data?.subject_template?.[0] ||
error?.response?.data?.error ||
'Failed to save template';
alert(errorMsg);
}
};
// Preview template
const handlePreview = async () => {
if (!editingTemplate) return;
try {
const preview = await previewMutation.mutateAsync({
emailType: editingTemplate.email_type,
context: {},
});
setPreviewSubject(preview.subject);
setPreviewHtml(preview.html);
setShowPreviewModal(true);
} catch (error) {
console.error('Failed to generate preview:', error);
}
};
// Reset template to default
const handleReset = async (emailType: PlatformEmailType) => {
try {
await resetMutation.mutateAsync(emailType);
setShowResetConfirm(null);
} catch (error) {
console.error('Failed to reset template:', error);
}
};
// Close editor
const handleCloseEditor = () => {
if (hasUnsavedChanges) {
if (!confirm('You have unsaved changes. Are you sure you want to close?')) {
return;
}
}
setEditingTemplate(null);
setEditorData(null);
setEditorSubject('');
setHasUnsavedChanges(false);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Mail className="h-7 w-7 text-brand-600" />
{t('platform.emailTemplates.title', 'Platform Email Templates')}
</h1>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{t(
'platform.emailTemplates.description',
'Customize platform-level automated emails for tenant invitations, trial notifications, billing alerts, and more.'
)}
</p>
</div>
{/* Info Banner */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-300">
<p className="font-medium mb-1">About Template Tags</p>
<p>
Use template tags like <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{{ tenant_name }}'}</code> to
insert dynamic content. Available tags vary by email type and are shown when editing each template.
</p>
</div>
</div>
</div>
{/* Template Categories */}
<div className="space-y-4">
{CATEGORY_ORDER.map((category) => {
const categoryTemplates = templatesByCategory[category];
const config = CATEGORY_CONFIG[category];
const isExpanded = expandedCategories.has(category);
if (categoryTemplates.length === 0) return null;
return (
<div
key={category}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Category Header */}
<button
onClick={() => toggleCategory(category)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-3">
<span className={`p-2 rounded-lg ${config.color}`}>{config.icon}</span>
<div className="text-left">
<h3 className="font-semibold text-gray-900 dark:text-white">{config.label}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{categoryTemplates.length} template{categoryTemplates.length !== 1 ? 's' : ''}
</p>
</div>
</div>
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-gray-400" />
) : (
<ChevronRight className="h-5 w-5 text-gray-400" />
)}
</button>
{/* Template List */}
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
{categoryTemplates.map((template) => (
<div
key={template.email_type}
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">
{template.display_name}
</h4>
{template.is_customized && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400">
Customized
</span>
)}
{!template.is_active && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Disabled
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{template.description}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Subject: <span className="font-mono">{template.subject_template}</span>
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
{template.is_customized && (
<button
onClick={() => setShowResetConfirm(template.email_type)}
className="p-2 text-gray-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors"
title="Reset to default"
>
<RotateCcw className="h-4 w-4" />
</button>
)}
<button
onClick={() => handleEdit(template)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
>
<Edit2 className="h-4 w-4" />
Edit
</button>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
{/* Reset Confirmation Modal */}
{showResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Reset to Default?
</h3>
</div>
<div className="p-6">
<p className="text-gray-600 dark:text-gray-400">
This will reset the email template to its default content. Any customizations you've made will be lost.
</p>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
onClick={() => setShowResetConfirm(null)}
className="px-4 py-2 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 transition-colors font-medium"
>
Cancel
</button>
<button
onClick={() => handleReset(showResetConfirm as PlatformEmailType)}
disabled={resetMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 transition-colors font-medium"
>
{resetMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
Reset to Default
</button>
</div>
</div>
</div>
)}
{/* Editor Modal */}
{editingTemplate && (
<div className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-gray-900">
{/* Editor Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={handleCloseEditor}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
<div>
<h2 className="font-semibold text-gray-900 dark:text-white">
{editingTemplate.display_name}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{editingTemplate.description}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{hasUnsavedChanges && (
<span className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded text-xs font-medium">
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></span>
Unsaved changes
</span>
)}
<button
onClick={handlePreview}
disabled={previewMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={handleSave}
disabled={updateMutation.isPending || !hasUnsavedChanges}
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 text-sm font-medium transition-colors"
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save
</button>
</div>
</div>
{/* Subject Line Editor */}
<div className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Subject
</label>
<input
type="text"
value={editorSubject}
onChange={(e) => {
setEditorSubject(e.target.value);
setHasUnsavedChanges(true);
}}
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-brand-500 focus:border-brand-500"
placeholder="Enter email subject..."
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Use tags like <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{ tenant_name }}'}</code> for dynamic content
</p>
</div>
{/* Available Tags Panel */}
<div className="bg-gray-100 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700 px-6 py-3">
<details className="group" open>
<summary className="flex items-center gap-2 cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
<Code className="h-4 w-4" />
Available Template Tags ({editingTemplate.available_tags?.length || 0})
<ChevronRight className="h-4 w-4 group-open:rotate-90 transition-transform" />
</summary>
<div className="mt-3">
<div className="flex flex-wrap gap-1.5">
{(editingTemplate.available_tags || []).map((tag: PlatformEmailTag) => (
<span
key={tag.name}
className="inline-flex items-center px-2 py-0.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-xs font-mono cursor-help hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
title={tag.description}
onClick={() => {
navigator.clipboard.writeText(tag.syntax);
}}
>
{tag.name}
</span>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Click a tag to copy it. Hover for description.
</p>
</div>
</details>
</div>
{/* Puck Editor */}
<div className="flex-1 overflow-hidden">
{editorData && (
<Puck
key={`platform-email-puck-${editingTemplate?.email_type}`}
config={editorConfig}
data={editorData}
onChange={handleEditorChange}
onPublish={handleSave}
/>
)}
</div>
</div>
)}
{/* Preview Modal */}
{showPreviewModal && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Preview Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-3">
<MonitorPlay className="h-5 w-5 text-brand-600" />
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Email Preview</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Subject: {previewSubject}
</p>
</div>
</div>
<button
onClick={() => setShowPreviewModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Preview Content */}
<div className="flex-1 overflow-auto p-6 bg-gray-50 dark:bg-gray-900">
<div className="bg-white rounded-lg shadow-sm overflow-hidden max-w-2xl mx-auto">
<iframe
srcDoc={previewHtml}
className="w-full h-[500px]"
title="Email Preview"
/>
</div>
</div>
{/* Preview Footer */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={() => setShowPreviewModal(false)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PlatformEmailTemplates;

View File

@@ -17,10 +17,23 @@ import {
CheckCircle,
XCircle,
Loader2,
Send,
Clock,
RefreshCw,
X,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { usePlatformUsers } from '../../hooks/usePlatform';
import { useCurrentUser } from '../../hooks/useAuth';
import EditPlatformUserModal from './components/EditPlatformUserModal';
import {
usePlatformStaffInvitations,
useCreatePlatformStaffInvitation,
useResendPlatformStaffInvitation,
useCancelPlatformStaffInvitation,
type PlatformStaffRole,
type PlatformStaffInvitation,
} from '../../hooks/usePlatformStaffInvitations';
interface PlatformUser {
id: number;
@@ -32,11 +45,6 @@ interface PlatformUser {
role: string;
is_active: boolean;
is_superuser: boolean;
permissions: {
can_approve_plugins?: boolean;
can_whitelist_urls?: boolean;
[key: string]: any;
};
created_at: string;
last_login?: string;
}
@@ -47,8 +55,23 @@ const PlatformStaff: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedUser, setSelectedUser] = useState<PlatformUser | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [showInvitations, setShowInvitations] = useState(false);
// Invitation form state
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<PlatformStaffRole>('platform_support');
const [inviteMessage, setInviteMessage] = useState('');
const { data: allUsers, isLoading, error } = usePlatformUsers();
const { data: invitations, isLoading: invitationsLoading } = usePlatformStaffInvitations();
const createInvitation = useCreatePlatformStaffInvitation();
const resendInvitation = useResendPlatformStaffInvitation();
const cancelInvitation = useCancelPlatformStaffInvitation();
const pendingInvitations = (invitations || []).filter(
(inv: PlatformStaffInvitation) => inv.status === 'PENDING'
);
// Filter to only show platform staff (not superusers, not business users)
const platformStaff = (allUsers || []).filter(
@@ -92,6 +115,51 @@ const PlatformStaff: React.FC = () => {
setIsEditModalOpen(true);
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) {
toast.error('Email is required');
return;
}
try {
await createInvitation.mutateAsync({
email: inviteEmail.trim(),
role: inviteRole,
personal_message: inviteMessage.trim(),
});
toast.success('Invitation sent successfully!');
setIsInviteModalOpen(false);
resetInviteForm();
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Failed to send invitation');
}
};
const handleResendInvitation = async (id: number) => {
try {
await resendInvitation.mutateAsync(id);
toast.success('Invitation resent');
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Failed to resend invitation');
}
};
const handleCancelInvitation = async (id: number) => {
try {
await cancelInvitation.mutateAsync(id);
toast.success('Invitation cancelled');
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Failed to cancel invitation');
}
};
const resetInviteForm = () => {
setInviteEmail('');
setInviteRole('platform_support');
setInviteMessage('');
};
const getRoleBadge = (role: string) => {
if (role === 'platform_manager') {
return (
@@ -156,13 +224,10 @@ const PlatformStaff: React.FC = () => {
</div>
<button
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
onClick={() => {
// TODO: Implement create new staff member
alert('Create new staff member - coming soon');
}}
onClick={() => setIsInviteModalOpen(true)}
>
<Plus className="w-4 h-4" />
Add Staff Member
Invite Staff Member
</button>
</div>
</div>
@@ -182,7 +247,7 @@ const PlatformStaff: React.FC = () => {
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-sm text-gray-500 dark:text-gray-400">Total Staff</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
@@ -201,8 +266,75 @@ const PlatformStaff: React.FC = () => {
{platformStaff.filter((u: any) => u.role === 'platform_support').length}
</div>
</div>
<button
onClick={() => setShowInvitations(!showInvitations)}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-left hover:border-indigo-300 dark:hover:border-indigo-700 transition-colors"
>
<div className="flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">Pending Invitations</div>
{pendingInvitations.length > 0 && (
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></span>
)}
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
{pendingInvitations.length}
</div>
</button>
</div>
{/* Pending Invitations */}
{showInvitations && pendingInvitations.length > 0 && (
<div className="mb-6 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
<h3 className="text-lg font-medium text-amber-800 dark:text-amber-200 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5" />
Pending Invitations
</h3>
<div className="space-y-3">
{pendingInvitations.map((inv: PlatformStaffInvitation) => (
<div
key={inv.id}
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-4 border border-amber-200 dark:border-amber-800"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-white font-semibold">
<Mail className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">
{inv.email}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{inv.role_display} &bull; Invited by {inv.invited_by || 'Unknown'} &bull; Expires{' '}
{inv.expires_at
? new Date(inv.expires_at).toLocaleDateString()
: 'in 7 days'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleResendInvitation(inv.id)}
disabled={resendInvitation.isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors disabled:opacity-50"
>
<RefreshCw className="w-3.5 h-3.5" />
Resend
</button>
<button
onClick={() => handleCancelInvitation(inv.id)}
disabled={cancelInvitation.isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
>
<X className="w-3.5 h-3.5" />
Cancel
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Staff List */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<table className="w-full text-sm">
@@ -214,9 +346,6 @@ const PlatformStaff: React.FC = () => {
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Permissions
</th>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
@@ -254,27 +383,6 @@ const PlatformStaff: React.FC = () => {
{/* Role */}
<td className="px-6 py-4">{getRoleBadge(user.role)}</td>
{/* Permissions */}
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{user.permissions?.can_approve_plugins && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
Plugin Approver
</span>
)}
{user.permissions?.can_whitelist_urls && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
URL Whitelister
</span>
)}
{!user.permissions?.can_approve_plugins && !user.permissions?.can_whitelist_urls && (
<span className="text-xs text-gray-400 dark:text-gray-500">
No special permissions
</span>
)}
</div>
</td>
{/* Status */}
<td className="px-6 py-4">
{user.is_active ? (
@@ -341,6 +449,108 @@ const PlatformStaff: React.FC = () => {
user={selectedUser}
/>
)}
{/* Invite Modal */}
{isInviteModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full overflow-hidden">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Send className="w-5 h-5" />
Invite Staff Member
</h2>
<button
onClick={() => {
setIsInviteModalOpen(false);
resetInviteForm();
}}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */}
<form onSubmit={handleInvite} className="p-6 space-y-4">
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email Address <span className="text-red-500">*</span>
</label>
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="email@example.com"
required
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
{/* Role */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Role <span className="text-red-500">*</span>
</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as PlatformStaffRole)}
className="w-full px-4 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"
>
<option value="platform_support">Platform Support</option>
<option value="platform_manager">Platform Manager</option>
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{inviteRole === 'platform_manager'
? 'Managers can oversee platform operations and manage tenants.'
: 'Support staff can help users and respond to tickets.'}
</p>
</div>
{/* Personal Message */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Personal Message (Optional)
</label>
<textarea
value={inviteMessage}
onChange={(e) => setInviteMessage(e.target.value)}
placeholder="Add a personal note to the invitation email..."
rows={3}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => {
setIsInviteModalOpen(false);
resetInviteForm();
}}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={createInvitation.isPending || !inviteEmail.trim()}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createInvitation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Send Invitation
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,334 @@
import React, { useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
usePlatformStaffInvitationByToken,
useAcceptPlatformStaffInvitation,
} from '../../hooks/usePlatformStaffInvitations';
import { useAuth } from '../../hooks/useAuth';
import {
Loader2,
CheckCircle,
XCircle,
Shield,
Mail,
User,
Lock,
AlertCircle,
Eye,
EyeOff,
} from 'lucide-react';
const PlatformStaffInvitePage: React.FC = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const { data: invitation, isLoading, error } = usePlatformStaffInvitationByToken(token);
const acceptMutation = useAcceptPlatformStaffInvitation();
const { setTokens } = useAuth();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [formError, setFormError] = useState('');
const [accepted, setAccepted] = useState(false);
const handleAccept = async (e: React.FormEvent) => {
e.preventDefault();
setFormError('');
if (!firstName.trim()) {
setFormError(t('platformStaffInvite.firstNameRequired', 'First name is required'));
return;
}
if (!password || password.length < 10) {
setFormError(t('platformStaffInvite.passwordMinLength', 'Password must be at least 10 characters'));
return;
}
if (password !== confirmPassword) {
setFormError(t('platformStaffInvite.passwordsMustMatch', 'Passwords do not match'));
return;
}
try {
const result = await acceptMutation.mutateAsync({
token: token!,
data: {
password,
first_name: firstName.trim(),
last_name: lastName.trim(),
},
});
// Set auth tokens and redirect to platform dashboard
if (result.access && result.refresh) {
setTokens(result.access, result.refresh);
}
setAccepted(true);
// Redirect after a short delay
setTimeout(() => {
navigate('/platform/dashboard');
}, 2000);
} catch (err: any) {
setFormError(
err.response?.data?.detail ||
err.response?.data?.error ||
t('platformStaffInvite.acceptFailed', 'Failed to accept invitation')
);
}
};
// No token provided
if (!token) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<XCircle size={48} className="mx-auto text-red-500 mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('platformStaffInvite.invalidLink', 'Invalid Invitation Link')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t(
'platformStaffInvite.noToken',
'This invitation link is invalid. Please check your email for the correct link.'
)}
</p>
</div>
</div>
);
}
// Loading state
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<Loader2 size={48} className="mx-auto text-indigo-600 animate-spin mb-4" />
<p className="text-gray-500 dark:text-gray-400">
{t('platformStaffInvite.loading', 'Loading invitation...')}
</p>
</div>
</div>
);
}
// Error state (invalid/expired token)
if (error || !invitation) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<AlertCircle size={48} className="mx-auto text-amber-500 mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('platformStaffInvite.expiredTitle', 'Invitation Expired or Invalid')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t(
'platformStaffInvite.expiredDescription',
'This invitation has expired or is no longer valid. Please contact the administrator to request a new invitation.'
)}
</p>
</div>
</div>
);
}
// Accepted state
if (accepted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<CheckCircle size={48} className="mx-auto text-green-500 mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('platformStaffInvite.welcomeTitle', 'Welcome to the Platform Team!')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t('platformStaffInvite.redirecting', 'Your account has been created. Redirecting to dashboard...')}
</p>
<Loader2 size={24} className="mx-auto text-indigo-600 animate-spin mt-4" />
</div>
</div>
);
}
// Main acceptance form
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 p-6 text-center">
<Shield size={40} className="mx-auto text-white/90 mb-2" />
<h1 className="text-2xl font-bold text-white mb-2">
{t('platformStaffInvite.title', 'Platform Staff Invitation')}
</h1>
<p className="text-indigo-100">
{t('platformStaffInvite.subtitle', 'Join the SmoothSchedule platform team')}
</p>
</div>
{/* Invitation Details */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center text-indigo-600 dark:text-indigo-400">
<Shield size={20} />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{t('platformStaffInvite.role', 'Role')}
</p>
<p className="font-semibold text-gray-900 dark:text-white">{invitation.role_display}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-600 dark:text-gray-400">
<Mail size={20} />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{t('platformStaffInvite.email', 'Email')}
</p>
<p className="font-semibold text-gray-900 dark:text-white">{invitation.email}</p>
</div>
</div>
{invitation.role_description && (
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">{invitation.role_description}</p>
</div>
)}
{invitation.personal_message && (
<div className="mt-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
<p className="text-xs text-indigo-600 dark:text-indigo-400 uppercase tracking-wide mb-1">
{t('platformStaffInvite.personalMessage', 'Personal Message')}
</p>
<p className="text-sm text-gray-700 dark:text-gray-300 italic">"{invitation.personal_message}"</p>
</div>
)}
{invitation.invited_by && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center pt-2">
{t('platformStaffInvite.invitedBy', 'Invited by')}{' '}
<span className="font-medium">{invitation.invited_by}</span>
</p>
)}
</div>
</div>
{/* Form */}
<form onSubmit={handleAccept} className="p-6 space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{t('platformStaffInvite.formDescription', 'Create your account to accept this invitation.')}
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('platformStaffInvite.firstName', 'First Name')} *
</label>
<div className="relative">
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
className="w-full pl-10 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="John"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('platformStaffInvite.lastName', 'Last Name')}
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Doe"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('platformStaffInvite.password', 'Password')} *
</label>
<div className="relative">
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={10}
className="w-full pl-10 pr-10 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Min. 10 characters"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('platformStaffInvite.confirmPassword', 'Confirm Password')} *
</label>
<div className="relative">
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full pl-10 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Repeat password"
/>
</div>
</div>
{/* Error Message */}
{formError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
</div>
)}
{/* Submit Button */}
<div className="pt-4">
<button
type="submit"
disabled={acceptMutation.isPending}
className="w-full px-4 py-3 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{acceptMutation.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<CheckCircle size={18} />
)}
{t('platformStaffInvite.acceptButton', 'Accept Invitation & Create Account')}
</button>
</div>
</form>
</div>
</div>
);
};
export default PlatformStaffInvitePage;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
import { X, Save, RefreshCw, AlertCircle } from 'lucide-react';
import { X, Save, AlertCircle } from 'lucide-react';
import { useUpdateBusiness, useChangeBusinessPlan } from '../../../hooks/usePlatform';
import {
useBillingPlans,
@@ -33,6 +33,9 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
const [loadingCustomTier, setLoadingCustomTier] = useState(false);
const [deletingCustomTier, setDeletingCustomTier] = useState(false);
// Toggle for custom features - when true, features are editable and saved to custom tier
const [useCustomFeatures, setUseCustomFeatures] = useState(false);
// Core form fields (non-feature fields only)
const [editForm, setEditForm] = useState({
name: '',
@@ -118,27 +121,33 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
setFeatureValues(featureDefaults);
};
// Reset to plan defaults button handler
const handleResetToPlanDefaults = async () => {
// Handle toggling custom features on/off
const handleCustomFeaturesToggle = async (enabled: boolean) => {
if (!business) return;
// If custom tier exists, delete it
if (customTier) {
setDeletingCustomTier(true);
try {
await deleteCustomTier(business.id);
setCustomTier(null);
} catch (error) {
console.error('Failed to delete custom tier:', error);
if (enabled) {
// Enable custom features - just toggle the state
// Custom tier will be created when saving
setUseCustomFeatures(true);
} else {
// Disable custom features - delete custom tier and reset to plan defaults
if (customTier) {
setDeletingCustomTier(true);
try {
await deleteCustomTier(business.id);
setCustomTier(null);
} catch (error) {
console.error('Failed to delete custom tier:', error);
setDeletingCustomTier(false);
return;
}
setDeletingCustomTier(false);
return;
}
setDeletingCustomTier(false);
setUseCustomFeatures(false);
// Reset to plan defaults
const featureDefaults = getPlanDefaults(editForm.plan_code);
setFeatureValues(featureDefaults);
}
// Reset all feature values to plan defaults (includes limits)
const featureDefaults = getPlanDefaults(editForm.plan_code);
setFeatureValues(featureDefaults);
};
// Map tier name/code to plan code
@@ -191,6 +200,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
const tier = await getCustomTier(businessId);
console.log('[loadCustomTier] Got tier:', tier ? 'exists' : 'null');
setCustomTier(tier);
setUseCustomFeatures(!!tier); // Enable custom features if tier exists
if (tier && billingFeatures) {
// Custom tier exists - load features from custom tier
@@ -227,6 +237,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
// 404 means no custom tier exists - this is expected, load plan defaults
console.log('[loadCustomTier] Error (likely 404):', error);
setCustomTier(null);
setUseCustomFeatures(false);
if (business) {
const planCode = tierToPlanCode(business.tier);
console.log('[loadCustomTier] Loading plan defaults for:', planCode);
@@ -269,19 +280,25 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
if (!business || !billingFeatures) return;
try {
// Convert featureValues (keyed by tenant_field_name) to feature codes for the backend
const featuresForBackend: Record<string, boolean | number | null> = {};
for (const feature of billingFeatures) {
if (!feature.tenant_field_name) continue;
const value = featureValues[feature.tenant_field_name];
if (value !== undefined) {
// Use feature.code as the key for the backend
featuresForBackend[feature.code] = value;
// Only save custom tier if custom features are enabled
if (useCustomFeatures) {
// Convert featureValues (keyed by tenant_field_name) to feature codes for the backend
const featuresForBackend: Record<string, boolean | number | null> = {};
for (const feature of billingFeatures) {
if (!feature.tenant_field_name) continue;
const value = featureValues[feature.tenant_field_name];
if (value !== undefined) {
// Use feature.code as the key for the backend
featuresForBackend[feature.code] = value;
}
}
}
// Save feature values to custom tier
await updateCustomTier(business.id, featuresForBackend);
// Save feature values to custom tier
await updateCustomTier(business.id, featuresForBackend);
} else if (customTier) {
// Custom features disabled but tier exists - delete it
await deleteCustomTier(business.id);
}
// Extract only the fields that the update endpoint accepts (exclude plan_code and feature values)
const { plan_code, ...coreFields } = editForm;
@@ -318,14 +335,14 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
<span className="px-2 py-1 text-xs rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
Loading...
</span>
) : customTier ? (
) : useCustomFeatures ? (
<span className="flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
<AlertCircle size={12} />
Custom Tier
Custom Features
</span>
) : (
<span className="px-2 py-1 text-xs rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
Plan Defaults
Plan Features
</span>
)}
</div>
@@ -374,24 +391,9 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
{/* Subscription Plan */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Subscription Plan
</label>
<button
type="button"
onClick={handleResetToPlanDefaults}
disabled={plansLoading || deletingCustomTier}
className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 disabled:opacity-50"
>
<RefreshCw size={12} className={deletingCustomTier ? 'animate-spin' : ''} />
{deletingCustomTier
? 'Deleting...'
: customTier
? 'Delete custom tier & reset to plan defaults'
: 'Reset to plan defaults'}
</button>
</div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subscription Plan
</label>
<select
value={editForm.plan_code}
onChange={(e) => handlePlanChange(e.target.value)}
@@ -417,12 +419,34 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
)}
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{customTier
? 'This business has a custom tier. Feature changes will be saved to the custom tier.'
: 'Changing plan will auto-update limits and permissions to plan defaults'}
Changing plan will auto-update limits and permissions to plan defaults
</p>
</div>
{/* Custom Features Toggle */}
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div>
<label className="text-sm font-medium text-gray-900 dark:text-white">
Enable Custom Features
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{useCustomFeatures
? 'Features below will be saved as a custom tier (overrides plan)'
: 'Features below are from the subscription plan (read-only)'}
</p>
</div>
<button
type="button"
onClick={() => handleCustomFeaturesToggle(!useCustomFeatures)}
disabled={loadingCustomTier || deletingCustomTier}
className={`${useCustomFeatures ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50`}
role="switch"
aria-checked={useCustomFeatures}
>
<span className={`${useCustomFeatures ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
</button>
</div>
{/* Limits & Quotas - Dynamic from billing system */}
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<DynamicFeaturesEditor
@@ -434,6 +458,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
headerTitle="Limits & Quotas"
showDescriptions
columns={4}
disabled={!useCustomFeatures}
/>
</div>
@@ -468,6 +493,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
featureType="boolean"
headerTitle="Features & Permissions"
showDescriptions
disabled={!useCustomFeatures}
/>
</div>

View File

@@ -4,7 +4,6 @@
* - Basic info (name, email, username)
* - Password reset
* - Role assignment
* - Permissions (can_approve_plugins, etc.)
* - Account status (active/inactive)
*/
@@ -22,6 +21,9 @@ import {
Eye,
EyeOff,
AlertCircle,
Trash2,
Archive,
Loader2,
} from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../../../api/client';
@@ -38,10 +40,6 @@ interface EditPlatformUserModalProps {
last_name: string;
role: string;
is_active: boolean;
permissions: {
can_approve_plugins?: boolean;
[key: string]: any;
};
};
}
@@ -61,13 +59,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
const canEditRole = currentRole === 'superuser' ||
(currentRole === 'platform_manager' && targetRole === 'platform_support');
// Get available permissions for current user
// Superusers always have all permissions, others check the permissions field
const availablePermissions = {
can_approve_plugins: currentRole === 'superuser' || !!currentUser?.permissions?.can_approve_plugins,
can_whitelist_urls: currentRole === 'superuser' || !!currentUser?.permissions?.can_whitelist_urls,
};
// Form state
const [formData, setFormData] = useState({
username: user.username,
@@ -78,15 +69,15 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
is_active: user.is_active,
});
const [permissions, setPermissions] = useState({
can_approve_plugins: user.permissions?.can_approve_plugins || false,
can_whitelist_urls: user.permissions?.can_whitelist_urls || false,
});
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [passwordError, setPasswordError] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
// Check if current user can delete/archive (only superuser, and not themselves)
const canDelete = currentRole === 'superuser' && currentUser?.id !== user.id;
// Update mutation
const updateMutation = useMutation({
@@ -100,6 +91,31 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async () => {
await apiClient.delete(`/platform/users/${user.id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platform', 'users'] });
onClose();
},
});
// Archive mutation (deactivate user)
const archiveMutation = useMutation({
mutationFn: async () => {
const response = await apiClient.patch(`/platform/users/${user.id}/`, {
is_active: false,
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platform', 'users'] });
onClose();
},
});
// Reset form when user changes
useEffect(() => {
setFormData({
@@ -110,13 +126,11 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
role: user.role,
is_active: user.is_active,
});
setPermissions({
can_approve_plugins: user.permissions?.can_approve_plugins || false,
can_whitelist_urls: user.permissions?.can_whitelist_urls || false,
});
setPassword('');
setConfirmPassword('');
setPasswordError('');
setShowDeleteConfirm(false);
setShowArchiveConfirm(false);
}, [user]);
const handleSubmit = (e: React.FormEvent) => {
@@ -137,7 +151,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
// Prepare update data
const updateData: any = {
...formData,
permissions: permissions,
};
// Only include password if changed
@@ -148,13 +161,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
updateMutation.mutate(updateData);
};
const handlePermissionToggle = (permission: string) => {
setPermissions((prev) => ({
...prev,
[permission]: !prev[permission as keyof typeof prev],
}));
};
if (!isOpen) return null;
return (
@@ -293,56 +299,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
</div>
</div>
{/* Permissions */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
Special Permissions
</h4>
<div className="space-y-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
{availablePermissions.can_approve_plugins && (
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={permissions.can_approve_plugins}
onChange={() => handlePermissionToggle('can_approve_plugins')}
className="mt-0.5 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
Can Approve Plugins
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Allow this user to review and approve community plugins for the marketplace
</div>
</div>
</label>
)}
{availablePermissions.can_whitelist_urls && (
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={permissions.can_whitelist_urls}
onChange={() => handlePermissionToggle('can_whitelist_urls')}
className="mt-0.5 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
Can Whitelist URLs
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Allow this user to whitelist external URLs for plugin API calls (per-user and platform-wide)
</div>
</div>
</label>
)}
{!availablePermissions.can_approve_plugins && !availablePermissions.can_whitelist_urls && (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
You don't have any special permissions to grant.
</p>
)}
</div>
</div>
{/* Password Reset */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
@@ -445,22 +401,95 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
)}
{/* Actions */}
<div className="mt-6 flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
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 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm font-medium rounded-lg transition-colors"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
<div className="mt-6 flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
{/* Left side - Destructive actions */}
<div className="flex items-center gap-2">
{canDelete && !showDeleteConfirm && !showArchiveConfirm && (
<>
<button
type="button"
onClick={() => setShowArchiveConfirm(true)}
disabled={!user.is_active}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={!user.is_active ? 'User is already archived' : 'Archive user'}
>
<Archive className="w-4 h-4" />
Archive
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</>
)}
{/* Archive confirmation */}
{showArchiveConfirm && (
<div className="flex items-center gap-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
<span className="text-sm text-amber-700 dark:text-amber-300">Archive this user?</span>
<button
type="button"
onClick={() => archiveMutation.mutate()}
disabled={archiveMutation.isPending}
className="px-2 py-1 text-xs font-medium text-white bg-amber-600 hover:bg-amber-700 rounded transition-colors disabled:opacity-50"
>
{archiveMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Yes, Archive'}
</button>
<button
type="button"
onClick={() => setShowArchiveConfirm(false)}
className="px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40 rounded transition-colors"
>
Cancel
</button>
</div>
)}
{/* Delete confirmation */}
{showDeleteConfirm && (
<div className="flex items-center gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg px-3 py-2">
<span className="text-sm text-red-700 dark:text-red-300">Permanently delete?</span>
<button
type="button"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="px-2 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded transition-colors disabled:opacity-50"
>
{deleteMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Yes, Delete'}
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="px-2 py-1 text-xs font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/40 rounded transition-colors"
>
Cancel
</button>
</div>
)}
</div>
{/* Right side - Save/Cancel */}
<div className="flex items-center gap-3">
<button
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 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm font-medium rounded-lg transition-colors"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</form>
</div>

View File

@@ -186,17 +186,16 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
return {
max_users: getIntegerFeature(features, 'max_users') ?? TIER_DEFAULTS[planCode]?.max_users ?? 5,
max_resources: getIntegerFeature(features, 'max_resources') ?? TIER_DEFAULTS[planCode]?.max_resources ?? 10,
can_manage_oauth_credentials: getBooleanFeature(features, 'remove_branding') && getBooleanFeature(features, 'api_access'),
can_manage_oauth_credentials: getBooleanFeature(features, 'can_white_label') && getBooleanFeature(features, 'api_access'),
can_accept_payments: getBooleanFeature(features, 'payment_processing'),
can_use_custom_domain: getBooleanFeature(features, 'custom_domain'),
can_white_label: getBooleanFeature(features, 'remove_branding'),
can_white_label: getBooleanFeature(features, 'can_white_label'),
can_api_access: getBooleanFeature(features, 'api_access'),
can_add_video_conferencing: getBooleanFeature(features, 'integrations_enabled'),
can_use_sms_reminders: getBooleanFeature(features, 'sms_enabled'),
can_use_masked_phone_numbers: getBooleanFeature(features, 'masked_calling_enabled'),
can_use_plugins: true, // Always enabled
can_use_tasks: true, // Always enabled
can_create_plugins: getBooleanFeature(features, 'api_access'),
can_use_automations: getBooleanFeature(features, 'can_use_automations') ?? true,
can_use_tasks: getBooleanFeature(features, 'can_use_tasks') ?? true,
can_create_automations: getBooleanFeature(features, 'can_create_automations'),
can_use_webhooks: getBooleanFeature(features, 'integrations_enabled'),
can_use_calendar_sync: getBooleanFeature(features, 'integrations_enabled'),
can_export_data: getBooleanFeature(features, 'advanced_reporting'),

View File

@@ -32,20 +32,12 @@ const mockUser = {
last_name: 'User',
role: 'platform_support',
is_active: true,
permissions: {
can_approve_plugins: false,
can_whitelist_urls: false,
},
};
const mockSuperuser = {
id: 2,
email: 'admin@example.com',
role: 'superuser',
permissions: {
can_approve_plugins: true,
can_whitelist_urls: true,
},
};
const createWrapper = () => {
@@ -331,32 +323,6 @@ describe('EditPlatformUserModal', () => {
});
});
describe('Permissions Section', () => {
it('shows permissions section for superuser', () => {
render(
React.createElement(EditPlatformUserModal, {
isOpen: true,
onClose: vi.fn(),
user: mockUser,
}),
{ wrapper: createWrapper() }
);
expect(screen.getByText('Special Permissions')).toBeInTheDocument();
});
it('shows can approve plugins permission', () => {
render(
React.createElement(EditPlatformUserModal, {
isOpen: true,
onClose: vi.fn(),
user: mockUser,
}),
{ wrapper: createWrapper() }
);
expect(screen.getByText(/Approve Plugins/i)).toBeInTheDocument();
});
});
describe('Account Status', () => {
it('shows status toggle', () => {
render(

View File

@@ -253,7 +253,7 @@ const SystemEmailTemplates: React.FC = () => {
...item,
props: {
...item.props,
id: `${item.type}-${index}-${crypto.randomUUID().substring(0, 8)}`,
id: `${item.type}-${index}-${Math.random().toString(36).substring(2, 10)}`,
},
};
}

View File

@@ -36,14 +36,12 @@ export interface PlanPermissions {
webhooks: boolean;
api_access: boolean;
custom_domain: boolean;
custom_branding: boolean;
remove_branding: boolean;
white_label: boolean;
custom_oauth: boolean;
automations: boolean;
can_create_automations: boolean;
tasks: boolean;
export_data: boolean;
video_conferencing: boolean;
two_factor_auth: boolean;
masked_calling: boolean;
pos_system: boolean;
@@ -1130,4 +1128,58 @@ export interface StaffEmailStats {
count: number;
unread: number;
}[];
}
// --- Platform Email Template Types (Puck-based, superuser only) ---
export type PlatformEmailType =
| 'tenant_invitation'
| 'trial_expiration_warning'
| 'trial_expired'
| 'plan_upgrade'
| 'plan_downgrade'
| 'subscription_cancelled'
| 'payment_failed'
| 'payment_succeeded';
export type PlatformEmailCategory =
| 'invitation'
| 'trial'
| 'subscription'
| 'billing';
export interface PlatformEmailTemplate {
email_type: PlatformEmailType;
subject_template: string;
puck_data: Record<string, any>;
is_active: boolean;
is_customized: boolean;
display_name: string;
description: string;
category: PlatformEmailCategory;
created_at?: string;
updated_at?: string;
}
export interface PlatformEmailTemplateDetail extends PlatformEmailTemplate {
available_tags: PlatformEmailTag[];
created_by?: number | null;
created_by_email?: string | null;
}
export interface PlatformEmailTag {
name: string;
description: string;
syntax: string;
}
export interface PlatformEmailTemplatePreview {
subject: string;
html: string;
}
export interface PlatformEmailTemplateUpdate {
subject_template: string;
puck_data: Record<string, any>;
is_active?: boolean;
}

View File

@@ -39,3 +39,13 @@ MAIL_SERVER_SSH_USER=poduck
MAIL_SERVER_DOCKER_CONTAINER=mailserver
MAIL_SERVER_SSH_KEY_PATH=/app/.ssh/id_ed25519
MAIL_SERVER_SSH_KNOWN_HOSTS_PATH=/app/.ssh/known_hosts
# SMTP Email Configuration
# ------------------------------------------------------------------------------
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
EMAIL_HOST=mail.talova.net
EMAIL_PORT=587
EMAIL_HOST_USER=noreply@smoothschedule.com
EMAIL_HOST_PASSWORD=chaff/starry
EMAIL_USE_TLS=True
DEFAULT_FROM_EMAIL=noreply@smoothschedule.com

View File

@@ -54,9 +54,7 @@ Annual pricing = ~10x monthly (2 months free)
| `advanced_reporting` | - | - | - | Yes | Yes |
| `team_permissions` | - | - | - | Yes | Yes |
| `audit_logs` | - | - | - | Yes | Yes |
| `custom_branding` | - | - | - | Yes | Yes |
| `white_label` | - | - | - | - | Yes |
| `remove_branding` | - | - | - | - | Yes |
| `can_white_label` | - | - | - | Yes | Yes |
| `multi_location` | - | - | - | - | Yes |
| `priority_support` | - | - | - | - | Yes |
| `dedicated_account_manager` | - | - | - | - | Yes |
@@ -88,7 +86,7 @@ Annual pricing = ~10x monthly (2 months free)
| **Advanced Reporting** | $15/mo, $150/yr | Enables `advanced_reporting` | No | Starter, Growth |
| **API Access** | $20/mo, $200/yr | Enables `api_access`, 5K API calls/day | No | Starter, Growth |
| **Masked Calling** | $39/mo, $390/yr | Enables `masked_calling_enabled` | No | Starter, Growth |
| **White Label** | $99/mo, $990/yr | Enables `white_label`, `remove_branding`, `custom_branding` | No | Pro only |
| **White Label** | $99/mo, $990/yr | Enables `can_white_label` (custom branding + remove branding) | No | Pro only |
**Stackable add-ons:** Integer values multiply by quantity purchased.
@@ -330,15 +328,13 @@ The system seeds 30 features (20 boolean, 10 integer):
| `email_enabled` | Can send email notifications |
| `masked_calling_enabled` | Can use masked phone calls |
| `api_access` | Can access REST API |
| `custom_branding` | Can customize branding |
| `remove_branding` | Can remove "Powered by" |
| `custom_domain` | Can use custom domain |
| `can_white_label` | Customize branding and remove "Powered by" |
| `multi_location` | Can manage multiple locations |
| `advanced_reporting` | Access to analytics dashboard |
| `priority_support` | Priority support queue |
| `dedicated_account_manager` | Has dedicated AM |
| `sla_guarantee` | SLA commitments |
| `white_label` | Full white-label capabilities |
| `team_permissions` | Granular team permissions |
| `audit_logs` | Access to audit logs |
| `integrations_enabled` | Can use third-party integrations |

View File

@@ -4,4 +4,4 @@ set -o errexit
set -o nounset
exec watchfiles --filter python celery.__main__.main --args '-A config.celery_app worker -l INFO'
exec watchfiles --filter python celery.__main__.main --args '-A config.celery_app worker -l INFO --pool=solo'

View File

@@ -92,6 +92,12 @@ CACHES = {
EMAIL_BACKEND = env(
"DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend",
)
EMAIL_HOST = env("EMAIL_HOST", default="")
EMAIL_PORT = env.int("EMAIL_PORT", default=587)
EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", default="")
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=True)
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", default="noreply@smoothschedule.com")
# WhiteNoise
# ------------------------------------------------------------------------------
@@ -109,7 +115,9 @@ EMAIL_BACKEND = env(
INSTALLED_APPS += ["django_extensions"]
# CELERY
# ------------------------------------------------------------------------------
# Run tasks synchronously in development (no need for celery worker)
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-always-eager
CELERY_TASK_ALWAYS_EAGER = True
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates
CELERY_TASK_EAGER_PROPAGATES = True

View File

@@ -12,6 +12,8 @@ from smoothschedule.billing.api.views import (
InvoiceDetailView,
InvoiceListView,
PlanCatalogView,
QuotaStatusView,
QuotaDismissBannerView,
# Admin ViewSets
FeatureViewSet,
PlanViewSet,
@@ -32,6 +34,12 @@ urlpatterns = [
# /api/me/ endpoints (current user/business context)
path("me/entitlements/", EntitlementsView.as_view(), name="me-entitlements"),
path("me/subscription/", CurrentSubscriptionView.as_view(), name="me-subscription"),
path("me/quota/", QuotaStatusView.as_view(), name="me-quota"),
path(
"me/quota/dismiss-banner/",
QuotaDismissBannerView.as_view(),
name="me-quota-dismiss-banner",
),
# /api/billing/ endpoints (public catalog)
path("billing/plans/", PlanCatalogView.as_view(), name="plan-catalog"),
path("billing/addons/", AddOnCatalogView.as_view(), name="addon-catalog"),

View File

@@ -36,6 +36,8 @@ from smoothschedule.billing.models import (
Subscription,
)
from smoothschedule.billing.services.entitlements import EntitlementService
from smoothschedule.billing.services.quota import QuotaService
from smoothschedule.billing.services.storage import StorageService
from smoothschedule.platform.admin.permissions import IsPlatformAdmin
@@ -57,6 +59,114 @@ class EntitlementsView(APIView):
return Response(entitlements)
class QuotaStatusView(APIView):
"""
GET /api/me/quota/
Returns the current business's quota usage status including:
- Appointment quota (monthly, billing cycle based)
- Flow execution count (monthly)
- API request quota (daily)
- Warning banner visibility
POST /api/me/quota/dismiss-banner/
Dismisses the quota warning banner for the current user/month.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
tenant = getattr(request.user, "tenant", None)
if not tenant:
return Response(
{"detail": "No tenant context"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get monthly quota status (appointments, flow executions)
quota_status = QuotaService.get_quota_status(
tenant, user=request.user
)
# Get daily API usage
api_usage = QuotaService.get_api_usage_status(tenant)
# Get storage status
storage_status = StorageService.get_storage_status(tenant)
return Response({
# Billing period
"billing_period": {
"year": quota_status.year,
"month": quota_status.month,
},
# Appointment quota
"appointments": {
"count": quota_status.appointment_count,
"limit": quota_status.quota_limit,
"is_unlimited": quota_status.is_unlimited,
"usage_percentage": round(quota_status.usage_percentage, 1),
"remaining": quota_status.remaining_appointments,
"is_at_warning_threshold": quota_status.is_at_warning_threshold,
"is_over_quota": quota_status.is_over_quota,
"overage_count": quota_status.overage_count,
"overage_amount_cents": quota_status.overage_amount_cents,
},
# Flow executions
"flow_executions": {
"count": quota_status.flow_execution_count,
"amount_cents": quota_status.flow_execution_amount_cents,
},
# API requests (daily)
"api_requests": api_usage,
# Storage quota
"storage": {
"current_size_mb": round(storage_status.current_size_mb, 2),
"current_size_gb": round(storage_status.current_size_gb, 3),
"peak_size_mb": round(storage_status.peak_size_mb, 2),
"quota_limit_mb": storage_status.quota_limit_mb,
"quota_limit_gb": round(storage_status.quota_limit_gb, 2),
"is_unlimited": storage_status.is_unlimited,
"usage_percentage": round(storage_status.usage_percentage, 1),
"remaining_mb": round(storage_status.remaining_mb, 2) if storage_status.remaining_mb else None,
"is_at_warning_threshold": storage_status.is_at_warning_threshold,
"is_over_quota": storage_status.is_over_quota,
"overage_mb": round(storage_status.overage_mb, 2),
"overage_amount_cents": storage_status.overage_amount_cents,
"warning_email_sent": storage_status.warning_email_sent,
"last_measured_at": storage_status.last_measured_at,
},
# Warning state
"warning": {
"show_banner": quota_status.show_warning_banner,
"email_sent": quota_status.warning_email_sent,
},
})
class QuotaDismissBannerView(APIView):
"""
POST /api/me/quota/dismiss-banner/
Dismisses the quota warning banner for the current user/billing period.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
tenant = getattr(request.user, "tenant", None)
if not tenant:
return Response(
{"detail": "No tenant context"},
status=status.HTTP_400_BAD_REQUEST,
)
QuotaService.dismiss_warning_banner(request.user, tenant)
return Response({"success": True, "message": "Banner dismissed"})
class CurrentSubscriptionView(APIView):
"""
GET /api/me/subscription/

View File

@@ -66,9 +66,8 @@ FEATURES = [
# --- Customization ---
{"code": "online_booking", "name": "Online Booking", "description": "Allow customers to book appointments online", "feature_type": "boolean", "category": "customization", "tenant_field_name": "online_booking", "display_order": 10},
{"code": "custom_branding", "name": "Custom Branding", "description": "Customize branding colors, logo, and styling", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_customize_booking_page", "display_order": 20},
{"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": "can_white_label", "name": "White Label", "description": "Customize branding and remove SmoothSchedule branding", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 40},
{"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},
# --- Automations ---
@@ -77,25 +76,50 @@ FEATURES = [
{"code": "can_create_automations", "name": "Create Automations", "description": "Build custom automations", "feature_type": "boolean", "category": "automations", "tenant_field_name": "can_create_automations", "display_order": 30, "depends_on": "can_use_automations"},
# --- Advanced Features ---
# TODO: Implement api_access enforcement - Apply HasFeaturePermission('api_access') to public API v1 views
# in platform/api/views.py to block API access for tenants without this feature.
{"code": "api_access", "name": "API Access", "description": "Access the public API for integrations", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_api_access", "display_order": 10},
{"code": "integrations_enabled", "name": "Integrations", "description": "Connect with third-party services and apps", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_use_webhooks", "display_order": 20},
{"code": "can_use_calendar_sync", "name": "Calendar Sync", "description": "Sync with Google Calendar, etc.", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_use_calendar_sync", "display_order": 30},
# TODO: Implement can_export_data enforcement - Block data export endpoints (CSV/Excel export)
# for tenants without this feature. Add HasFeaturePermission('can_export_data') to export views.
{"code": "can_export_data", "name": "Data Export", "description": "Export data to CSV/Excel", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_export_data", "display_order": 40},
{"code": "can_add_video_conferencing", "name": "Video Conferencing", "description": "Add video links to appointments", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_add_video_conferencing", "display_order": 50},
# TODO: Implement custom_fields feature - Allow services to have custom intake fields that customers
# fill out during booking. Create CustomField model, link to Service, display in booking flow.
{"code": "custom_fields", "name": "Custom Fields", "description": "Add custom intake fields to services for customer booking", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_use_custom_fields", "display_order": 50},
{"code": "advanced_reporting", "name": "Advanced Analytics", "description": "Detailed reporting and analytics", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "advanced_reporting", "display_order": 60},
{"code": "can_use_contracts", "name": "Contracts", "description": "Create and manage e-signature contracts", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_use_contracts", "display_order": 70},
# TODO: Implement mobile_app_access enforcement - Block mobile app authentication for tenants
# without this feature. Check feature in mobile app login endpoint.
{"code": "mobile_app_access", "name": "Mobile App", "description": "Access the mobile app for on-the-go management", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_use_mobile_app", "display_order": 80},
# TODO: Implement audit_logs/can_download_logs enforcement - Block audit log download endpoint
# for tenants without this feature. Add HasFeaturePermission to audit log export view.
{"code": "audit_logs", "name": "Audit Logs", "description": "Track all changes with detailed audit logs", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_download_logs", "display_order": 90},
{"code": "multi_location", "name": "Multi-Location", "description": "Manage multiple business locations", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "multi_location", "display_order": 100},
# --- Communication (Advanced) ---
# TODO: Implement proxy_number_enabled feature - Assign dedicated phone numbers to staff members
# for customer communication. Integrate with Twilio to provision and manage proxy numbers.
{"code": "proxy_number_enabled", "name": "Proxy Phone Numbers", "description": "Assign dedicated phone numbers to staff for customer communication", "feature_type": "boolean", "category": "communication", "tenant_field_name": "can_use_proxy_numbers", "display_order": 50},
# --- Scheduling ---
# TODO: Implement recurring_appointments enforcement - Block creating recurring events for tenants
# without this feature. Check feature in Event create/update when recurrence_rule is set.
{"code": "recurring_appointments", "name": "Recurring Appointments", "description": "Schedule recurring appointments", "feature_type": "boolean", "category": "scheduling", "tenant_field_name": "can_book_repeated_events", "display_order": 10},
# --- Enterprise & Security ---
{"code": "can_manage_oauth", "name": "Manage OAuth", "description": "Configure custom OAuth credentials", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "can_manage_oauth_credentials", "display_order": 10},
{"code": "team_permissions", "name": "Team Permissions", "description": "Advanced role-based permissions for team members", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "can_require_2fa", "display_order": 20},
{"code": "sla_guarantee", "name": "SSO / SAML", "description": "Single sign-on integration", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "sso_enabled", "display_order": 30},
# TODO: Implement can_require_2fa enforcement - Allow tenant admins to require 2FA for all team
# members. Add settings UI and enforce 2FA on login for affected users.
{"code": "can_require_2fa", "name": "Require 2FA", "description": "Require two-factor authentication for team members", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "can_require_2fa", "display_order": 20},
# TODO: Implement sso_enabled feature - Enable SSO/SAML authentication for enterprise tenants.
# Integrate with SAML provider, add SSO configuration UI, handle SSO login flow.
{"code": "sso_enabled", "name": "Single Sign-On (SSO)", "description": "Enable SSO/SAML authentication for team members", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "sso_enabled", "display_order": 30},
# TODO: Implement priority_support feature - Show priority indicator on support tickets for
# tenants with this feature. Update TicketSerializer to include priority_support flag.
{"code": "priority_support", "name": "Priority Support", "description": "Get priority customer support response", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "priority_support", "display_order": 40},
{"code": "team_permissions", "name": "Team Permissions", "description": "Advanced role-based permissions for team members", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "team_permissions", "display_order": 50},
{"code": "sla_guarantee", "name": "SLA Guarantee", "description": "Service level agreement guarantee for uptime and support", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "sla_guarantee", "display_order": 60},
]
@@ -252,7 +276,7 @@ PLANS = [
"advanced_reporting": True,
"team_permissions": True,
"audit_logs": True,
"custom_branding": True,
"can_white_label": True,
"can_use_email_templates": True,
"can_use_automations": True,
"can_use_tasks": True,
@@ -260,7 +284,6 @@ PLANS = [
"can_process_refunds": True,
"can_use_calendar_sync": True,
"can_export_data": True,
"can_add_video_conferencing": True,
"can_create_packages": True,
"can_use_pos": True,
"can_use_contracts": True,
@@ -308,8 +331,7 @@ PLANS = [
"advanced_reporting": True,
"team_permissions": True,
"audit_logs": True,
"custom_branding": True,
"remove_branding": True,
"can_white_label": True,
"multi_location": True,
"priority_support": True,
"sla_guarantee": True,
@@ -320,7 +342,6 @@ PLANS = [
"can_process_refunds": True,
"can_use_calendar_sync": True,
"can_export_data": True,
"can_add_video_conferencing": True,
"can_create_packages": True,
"can_use_pos": True,
"can_use_contracts": True,
@@ -407,14 +428,14 @@ ADDONS = [
],
},
{
"code": "remove_branding_addon",
"name": "Remove Branding",
"description": "Remove all SmoothSchedule branding from customer-facing pages",
"code": "white_label_addon",
"name": "White Label",
"description": "Customize branding and remove SmoothSchedule branding from customer-facing pages",
"price_monthly_cents": 9900,
"price_one_time_cents": 0, # Recurring only
"is_stackable": False,
"features": [
{"code": "remove_branding", "bool_value": True},
{"code": "can_white_label", "bool_value": True},
],
},
]

View File

@@ -12,12 +12,13 @@ class Command(BaseCommand):
help = 'Set up periodic Celery Beat tasks for billing operations'
def handle(self, *args, **options):
from django_celery_beat.models import PeriodicTask, CrontabSchedule
from django_celery_beat.models import PeriodicTask, CrontabSchedule, IntervalSchedule
self.stdout.write('Setting up billing periodic tasks...')
# Create crontab schedule
# Daily at 1 AM - check custom tier grace periods
# =================================================================
# Schedule: Daily at 1 AM - check custom tier grace periods
# =================================================================
schedule_1am, _ = CrontabSchedule.objects.get_or_create(
minute='0',
hour='1',
@@ -26,12 +27,12 @@ class Command(BaseCommand):
month_of_year='*',
)
# Create periodic task
task, created = PeriodicTask.objects.update_or_create(
name='billing-check-grace-periods',
defaults={
'task': 'smoothschedule.billing.tasks.check_subscription_grace_periods',
'crontab': schedule_1am,
'interval': None,
'description': 'Check custom tier grace periods and manage subscription lapses (runs daily at 1 AM)',
'enabled': True,
}
@@ -40,9 +41,35 @@ class Command(BaseCommand):
status = 'Created' if created else 'Updated'
self.stdout.write(self.style.SUCCESS(f" {status}: {task.name}"))
# =================================================================
# Schedule: Every hour - measure tenant storage
# =================================================================
schedule_hourly, _ = IntervalSchedule.objects.get_or_create(
every=1,
period=IntervalSchedule.HOURS,
)
task, created = PeriodicTask.objects.update_or_create(
name='billing-measure-storage',
defaults={
'task': 'smoothschedule.billing.tasks.measure_all_tenant_storage',
'interval': schedule_hourly,
'crontab': None,
'description': 'Measure database storage for all tenants (runs hourly)',
'enabled': True,
}
)
status = 'Created' if created else 'Updated'
self.stdout.write(self.style.SUCCESS(f" {status}: {task.name}"))
self.stdout.write(self.style.SUCCESS('\nBilling tasks set up successfully!'))
self.stdout.write('\nTasks configured:')
self.stdout.write(' - billing-check-grace-periods: Daily at 1 AM')
self.stdout.write(' - Clears grace period when subscription becomes active')
self.stdout.write(' - Starts grace period when subscription becomes inactive')
self.stdout.write(' - Deletes custom tiers after 30-day grace period expires')
self.stdout.write(' - billing-measure-storage: Every hour')
self.stdout.write(' - Measures PostgreSQL schema sizes for each tenant')
self.stdout.write(' - Updates StorageUsage records with current measurements')
self.stdout.write(' - Sends warning emails at 90% threshold')

View File

@@ -0,0 +1,55 @@
# Generated by Django 5.2.8 on 2025-12-30 18:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0013_add_tenant_custom_tier'),
('core', '0031_add_cancellation_policy_fields'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MonthlyQuotaUsage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.PositiveIntegerField()),
('month', models.PositiveIntegerField()),
('appointment_count', models.PositiveIntegerField(default=0)),
('quota_limit', models.PositiveIntegerField(default=0, help_text='Quota limit at the time (0 = unlimited)')),
('overage_count', models.PositiveIntegerField(default=0, help_text='Number of appointments over the quota')),
('overage_billed', models.BooleanField(default=False, help_text='Whether overages have been billed')),
('warning_email_sent', models.BooleanField(default=False, help_text='Whether the 90% quota warning email was sent')),
('warning_email_sent_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quota_usages', to='core.tenant')),
],
options={
'verbose_name': 'Monthly Quota Usage',
'verbose_name_plural': 'Monthly Quota Usages',
'ordering': ['-year', '-month'],
'unique_together': {('business', 'year', 'month')},
},
),
migrations.CreateModel(
name='QuotaBannerDismissal',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.PositiveIntegerField()),
('month', models.PositiveIntegerField()),
('dismissed_at', models.DateTimeField(auto_now_add=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quota_banner_dismissals', to='core.tenant')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quota_banner_dismissals', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-dismissed_at'],
'unique_together': {('user', 'business', 'year', 'month')},
},
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-12-30 18:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0014_add_quota_tracking'),
]
operations = [
migrations.AddField(
model_name='monthlyquotausage',
name='flow_execution_count',
field=models.PositiveIntegerField(default=0, help_text='Number of automation flow executions (excludes UI testing)'),
),
migrations.AddField(
model_name='monthlyquotausage',
name='flow_executions_billed',
field=models.BooleanField(default=False, help_text='Whether flow executions have been billed'),
),
migrations.AlterField(
model_name='monthlyquotausage',
name='overage_billed',
field=models.BooleanField(default=False, help_text='Whether appointment overages have been billed'),
),
migrations.AlterField(
model_name='monthlyquotausage',
name='quota_limit',
field=models.PositiveIntegerField(default=0, help_text='Appointment quota limit at the time (0 = unlimited)'),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.8 on 2025-12-30 18:06
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0015_add_flow_execution_tracking'),
('core', '0031_add_cancellation_policy_fields'),
]
operations = [
migrations.CreateModel(
name='DailyApiUsage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('request_count', models.PositiveIntegerField(default=0)),
('quota_limit', models.PositiveIntegerField(default=0, help_text='API request quota limit at the time (0 = unlimited)')),
('limit_reached_notified', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_api_usages', to='core.tenant')),
],
options={
'verbose_name': 'Daily API Usage',
'verbose_name_plural': 'Daily API Usages',
'ordering': ['-date'],
'unique_together': {('business', 'date')},
},
),
]

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.2.8 on 2025-12-30 18:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0016_add_daily_api_usage'),
('core', '0031_add_cancellation_policy_fields'),
]
operations = [
migrations.CreateModel(
name='StorageUsage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.PositiveIntegerField()),
('month', models.PositiveIntegerField()),
('current_size_bytes', models.BigIntegerField(default=0, help_text='Current storage usage in bytes')),
('peak_size_bytes', models.BigIntegerField(default=0, help_text='Peak storage usage during this billing period (for billing)')),
('quota_limit_mb', models.PositiveIntegerField(default=0, help_text='Storage quota limit in MB (0 = unlimited)')),
('table_sizes', models.JSONField(blank=True, default=dict, help_text='Breakdown of storage by table name')),
('warning_email_sent', models.BooleanField(default=False)),
('warning_email_sent_at', models.DateTimeField(blank=True, null=True)),
('last_measured_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='storage_usages', to='core.tenant')),
],
options={
'verbose_name': 'Storage Usage',
'verbose_name_plural': 'Storage Usages',
'ordering': ['-year', '-month'],
'unique_together': {('business', 'year', 'month')},
},
),
]

View File

@@ -6,6 +6,7 @@ This enables centralized plan management and simpler queries.
"""
from datetime import timedelta
from decimal import Decimal
from django.db import models
from django.utils import timezone
@@ -678,3 +679,427 @@ class TenantCustomTier(models.Model):
)
remaining = grace_end - timezone.now()
return max(0, remaining.days)
class MonthlyQuotaUsage(models.Model):
"""
Tracks monthly quota usage per tenant for appointments and automations.
This model stores the count of scheduled appointments and automation
flow executions per month, with warning emails and overage tracking.
Overage pricing:
- Appointments: $0.10 per appointment over the quota limit
- Flow executions: $0.005 per execution (no quota, just usage-based billing)
Warning threshold: 90% of quota (10% remaining).
"""
APPOINTMENT_OVERAGE_PRICE_CENTS = 10 # $0.10 per overage appointment
FLOW_EXECUTION_PRICE_CENTS = Decimal("0.5") # $0.005 per execution (0.5 cents)
business = models.ForeignKey(
"core.Tenant",
on_delete=models.CASCADE,
related_name="quota_usages",
)
# Year and month for this record
year = models.PositiveIntegerField()
month = models.PositiveIntegerField()
# Appointment usage tracking
appointment_count = models.PositiveIntegerField(default=0)
quota_limit = models.PositiveIntegerField(
default=0,
help_text="Appointment quota limit at the time (0 = unlimited)"
)
# Appointment overage tracking
overage_count = models.PositiveIntegerField(
default=0,
help_text="Number of appointments over the quota"
)
overage_billed = models.BooleanField(
default=False,
help_text="Whether appointment overages have been billed"
)
# Automation flow execution tracking
# Note: Flow executions don't have a quota, but are billed per execution
# Testing through UI should NOT count against this
flow_execution_count = models.PositiveIntegerField(
default=0,
help_text="Number of automation flow executions (excludes UI testing)"
)
flow_executions_billed = models.BooleanField(
default=False,
help_text="Whether flow executions have been billed"
)
# Warning email tracking
warning_email_sent = models.BooleanField(
default=False,
help_text="Whether the 90% quota warning email was sent"
)
warning_email_sent_at = models.DateTimeField(null=True, blank=True)
# Banner dismissal tracking (stored per user in separate model)
# This tracks if the tenant has dismissed the banner for this month
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ["business", "year", "month"]
ordering = ["-year", "-month"]
verbose_name = "Monthly Quota Usage"
verbose_name_plural = "Monthly Quota Usages"
def __str__(self):
return f"{self.business.name} - {self.year}/{self.month:02d}: {self.appointment_count}/{self.quota_limit or ''}"
@property
def is_unlimited(self) -> bool:
"""Check if this tenant has unlimited appointments."""
return self.quota_limit == 0
@property
def usage_percentage(self) -> float:
"""Get usage as a percentage of quota (0-100+)."""
if self.is_unlimited:
return 0.0
return (self.appointment_count / self.quota_limit) * 100
@property
def is_at_warning_threshold(self) -> bool:
"""Check if usage is at or above 90% warning threshold."""
if self.is_unlimited:
return False
return self.usage_percentage >= 90
@property
def is_over_quota(self) -> bool:
"""Check if usage is over the quota limit."""
if self.is_unlimited:
return False
return self.appointment_count > self.quota_limit
@property
def remaining_appointments(self) -> int | None:
"""Get remaining appointments before hitting quota. None if unlimited."""
if self.is_unlimited:
return None
return max(0, self.quota_limit - self.appointment_count)
@property
def overage_amount_cents(self) -> int:
"""Calculate overage charges in cents."""
return self.overage_count * self.APPOINTMENT_OVERAGE_PRICE_CENTS
class QuotaBannerDismissal(models.Model):
"""
Tracks when users dismiss the quota warning banner.
Users can dismiss the banner once per month. The banner will
reappear in the next month or if quota usage increases significantly.
"""
user = models.ForeignKey(
"users.User",
on_delete=models.CASCADE,
related_name="quota_banner_dismissals",
)
business = models.ForeignKey(
"core.Tenant",
on_delete=models.CASCADE,
related_name="quota_banner_dismissals",
)
# Year and month this dismissal applies to
year = models.PositiveIntegerField()
month = models.PositiveIntegerField()
dismissed_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["user", "business", "year", "month"]
ordering = ["-dismissed_at"]
def __str__(self):
return f"{self.user.email} dismissed banner for {self.business.name} - {self.year}/{self.month:02d}"
class DailyApiUsage(models.Model):
"""
Tracks daily API request usage per tenant.
API requests from off-platform (public API) count against the
max_api_requests_per_day quota. This is a daily limit that resets
at midnight UTC.
"""
business = models.ForeignKey(
"core.Tenant",
on_delete=models.CASCADE,
related_name="daily_api_usages",
)
# Date for this record (UTC)
date = models.DateField()
# API request count
request_count = models.PositiveIntegerField(default=0)
# Quota limit at the time (0 = unlimited)
quota_limit = models.PositiveIntegerField(
default=0,
help_text="API request quota limit at the time (0 = unlimited)"
)
# Whether tenant has been notified of reaching quota today
limit_reached_notified = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ["business", "date"]
ordering = ["-date"]
verbose_name = "Daily API Usage"
verbose_name_plural = "Daily API Usages"
def __str__(self):
return f"{self.business.name} - {self.date}: {self.request_count}/{self.quota_limit or ''}"
@property
def is_unlimited(self) -> bool:
"""Check if this tenant has unlimited API requests."""
return self.quota_limit == 0
@property
def is_over_quota(self) -> bool:
"""Check if usage is over the quota limit."""
if self.is_unlimited:
return False
return self.request_count >= self.quota_limit
@property
def remaining_requests(self) -> int | None:
"""Get remaining requests before hitting quota. None if unlimited."""
if self.is_unlimited:
return None
return max(0, self.quota_limit - self.request_count)
@property
def usage_percentage(self) -> float:
"""Get usage as a percentage of quota (0-100+)."""
if self.is_unlimited:
return 0.0
return (self.request_count / self.quota_limit) * 100
class StorageUsage(models.Model):
"""
Tracks database storage usage per tenant.
Storage is measured periodically by a Celery task that queries
PostgreSQL's pg_total_relation_size for the tenant's schema.
Storage quotas are monthly limits (like appointments) based on billing cycle.
Overage charges apply when usage exceeds the plan limit.
"""
# Overage pricing: $0.50 per GB per month over limit
STORAGE_OVERAGE_PRICE_CENTS_PER_GB = 50
business = models.ForeignKey(
"core.Tenant",
on_delete=models.CASCADE,
related_name="storage_usages",
)
# Billing period (year/month based on subscription.current_period_start)
year = models.PositiveIntegerField()
month = models.PositiveIntegerField()
# Storage measurements in bytes
current_size_bytes = models.BigIntegerField(
default=0,
help_text="Current storage usage in bytes"
)
peak_size_bytes = models.BigIntegerField(
default=0,
help_text="Peak storage usage during this billing period (for billing)"
)
# Quota limit in MB (0 = unlimited)
quota_limit_mb = models.PositiveIntegerField(
default=0,
help_text="Storage quota limit in MB (0 = unlimited)"
)
# Table-level breakdown (JSON for detailed reporting)
table_sizes = models.JSONField(
default=dict,
blank=True,
help_text="Breakdown of storage by table name"
)
# Warning/notification tracking
warning_email_sent = models.BooleanField(default=False)
warning_email_sent_at = models.DateTimeField(null=True, blank=True)
# Measurement timestamps
last_measured_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ["business", "year", "month"]
ordering = ["-year", "-month"]
verbose_name = "Storage Usage"
verbose_name_plural = "Storage Usages"
def __str__(self):
return f"{self.business.name} - {self.year}/{self.month:02d}: {self.current_size_mb:.1f}MB/{self.quota_limit_mb or ''}MB"
# =========================================================================
# Size Conversion Properties
# =========================================================================
@property
def current_size_mb(self) -> float:
"""Current storage in megabytes."""
return self.current_size_bytes / (1024 * 1024)
@property
def current_size_gb(self) -> float:
"""Current storage in gigabytes."""
return self.current_size_bytes / (1024 * 1024 * 1024)
@property
def peak_size_mb(self) -> float:
"""Peak storage in megabytes."""
return self.peak_size_bytes / (1024 * 1024)
@property
def peak_size_gb(self) -> float:
"""Peak storage in gigabytes."""
return self.peak_size_bytes / (1024 * 1024 * 1024)
@property
def quota_limit_bytes(self) -> int:
"""Quota limit in bytes."""
return self.quota_limit_mb * 1024 * 1024
@property
def quota_limit_gb(self) -> float:
"""Quota limit in gigabytes."""
return self.quota_limit_mb / 1024
# =========================================================================
# Quota Status Properties
# =========================================================================
@property
def is_unlimited(self) -> bool:
"""Check if this tenant has unlimited storage."""
return self.quota_limit_mb == 0
@property
def is_over_quota(self) -> bool:
"""Check if current usage is over the quota limit."""
if self.is_unlimited:
return False
return self.current_size_bytes > self.quota_limit_bytes
@property
def is_at_warning_threshold(self) -> bool:
"""Check if usage is at or above 90% warning threshold."""
if self.is_unlimited:
return False
return self.usage_percentage >= 90
@property
def remaining_mb(self) -> float | None:
"""Get remaining storage in MB before hitting quota. None if unlimited."""
if self.is_unlimited:
return None
remaining_bytes = max(0, self.quota_limit_bytes - self.current_size_bytes)
return remaining_bytes / (1024 * 1024)
@property
def usage_percentage(self) -> float:
"""Get usage as a percentage of quota (0-100+)."""
if self.is_unlimited:
return 0.0
return (self.current_size_bytes / self.quota_limit_bytes) * 100
# =========================================================================
# Overage Calculation Properties
# =========================================================================
@property
def overage_bytes(self) -> int:
"""Get overage amount in bytes (based on peak usage for billing)."""
if self.is_unlimited:
return 0
return max(0, self.peak_size_bytes - self.quota_limit_bytes)
@property
def overage_mb(self) -> float:
"""Get overage amount in megabytes."""
return self.overage_bytes / (1024 * 1024)
@property
def overage_gb(self) -> float:
"""Get overage amount in gigabytes."""
return self.overage_bytes / (1024 * 1024 * 1024)
@property
def overage_amount_cents(self) -> int:
"""
Calculate overage charge in cents.
Charges are based on peak usage (not current) to prevent
gaming by cleaning up before billing.
Price: $0.50 per GB over limit per month.
"""
if self.is_unlimited or self.overage_bytes <= 0:
return 0
# Calculate GB overage and multiply by price
overage_gb = self.overage_gb
return int(overage_gb * self.STORAGE_OVERAGE_PRICE_CENTS_PER_GB)
# =========================================================================
# Update Methods
# =========================================================================
def update_measurement(self, size_bytes: int, table_sizes: dict | None = None) -> None:
"""
Update storage measurement with new size.
Args:
size_bytes: Current storage size in bytes
table_sizes: Optional breakdown by table name
"""
self.current_size_bytes = size_bytes
self.last_measured_at = timezone.now()
# Update peak if current is higher
if size_bytes > self.peak_size_bytes:
self.peak_size_bytes = size_bytes
if table_sizes:
self.table_sizes = table_sizes
self.save(update_fields=[
"current_size_bytes",
"peak_size_bytes",
"last_measured_at",
"table_sizes",
"updated_at",
])

View File

@@ -0,0 +1,594 @@
"""
QuotaService - Manages monthly quota tracking and enforcement.
This service handles:
- Tracking appointment counts per billing cycle per tenant
- Tracking automation flow executions per billing cycle
- Checking if tenants are approaching or exceeding quota
- Sending warning emails at 90% threshold
- Calculating overage charges ($0.10 per appointment over quota)
- Calculating flow execution charges ($0.005 per execution)
- Managing banner dismissal state
IMPORTANT: "Month" refers to billing cycle, not calendar month.
The billing cycle is based on the subscription's current_period_start date.
For example, if a tenant subscribed on Dec 15, their billing cycle is Dec 15 - Jan 14,
and their "month" for tracking is December 2024 (year=2024, month=12).
"""
from __future__ import annotations
import logging
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING, NamedTuple
from django.db import transaction
from django.db.models import F
from django.utils import timezone
from smoothschedule.billing.models import DailyApiUsage, MonthlyQuotaUsage, QuotaBannerDismissal
from smoothschedule.billing.services.entitlements import EntitlementService
if TYPE_CHECKING:
from smoothschedule.identity.core.models import Tenant
from smoothschedule.identity.users.models import User
logger = logging.getLogger(__name__)
class QuotaStatus(NamedTuple):
"""Current quota status for a tenant."""
year: int
month: int
appointment_count: int
quota_limit: int # 0 = unlimited
is_unlimited: bool
usage_percentage: float
remaining_appointments: int | None
is_at_warning_threshold: bool
is_over_quota: bool
overage_count: int
overage_amount_cents: int
warning_email_sent: bool
show_warning_banner: bool # Whether to show the banner to this user
# Flow execution tracking
flow_execution_count: int
flow_execution_amount_cents: int
class QuotaService:
"""Service for managing appointment and flow execution quota tracking."""
FEATURE_CODE = "max_appointments_per_month"
WARNING_THRESHOLD_PERCENT = 90
OVERAGE_PRICE_CENTS = 10
FLOW_EXECUTION_PRICE_CENTS = Decimal("0.5") # $0.005 per execution
@classmethod
def get_current_billing_period(cls, business: Tenant) -> tuple[int, int]:
"""
Get the current billing period (year, month) for a tenant.
The billing period is based on the subscription's current_period_start.
This ensures quota tracking aligns with billing cycles, not calendar months.
Returns:
Tuple of (year, month) representing the billing period.
Falls back to current calendar month if no active subscription.
"""
from smoothschedule.billing.models import Subscription
try:
subscription = Subscription.objects.get(business=business)
if subscription.is_active and subscription.current_period_start:
return (
subscription.current_period_start.year,
subscription.current_period_start.month,
)
except Subscription.DoesNotExist:
pass
# Fallback to current calendar month
now = timezone.now()
return (now.year, now.month)
@classmethod
def get_current_quota_limit(cls, business: Tenant) -> int:
"""
Get the current quota limit for a tenant.
Returns 0 for unlimited, or the integer limit.
"""
limit = EntitlementService.get_limit(business, cls.FEATURE_CODE)
# None or 0 means unlimited
return limit if limit is not None else 0
@classmethod
def get_or_create_monthly_usage(
cls, business: Tenant, year: int | None = None, month: int | None = None
) -> MonthlyQuotaUsage:
"""
Get or create the monthly usage record for a tenant.
If year/month not provided, uses current billing period (based on subscription).
"""
if year is None or month is None:
billing_year, billing_month = cls.get_current_billing_period(business)
if year is None:
year = billing_year
if month is None:
month = billing_month
quota_limit = cls.get_current_quota_limit(business)
usage, created = MonthlyQuotaUsage.objects.get_or_create(
business=business,
year=year,
month=month,
defaults={"quota_limit": quota_limit},
)
# Update quota limit if it changed (e.g., plan upgrade)
if not created and usage.quota_limit != quota_limit:
usage.quota_limit = quota_limit
usage.save(update_fields=["quota_limit", "updated_at"])
return usage
@classmethod
def get_quota_status(
cls,
business: Tenant,
user: User | None = None,
year: int | None = None,
month: int | None = None,
) -> QuotaStatus:
"""
Get the current quota status for a tenant.
If user is provided, also checks if they've dismissed the banner.
If year/month not provided, uses current billing period.
"""
if year is None or month is None:
billing_year, billing_month = cls.get_current_billing_period(business)
if year is None:
year = billing_year
if month is None:
month = billing_month
usage = cls.get_or_create_monthly_usage(business, year, month)
# Check if user has dismissed the banner
show_banner = False
if usage.is_at_warning_threshold and user is not None:
dismissed = QuotaBannerDismissal.objects.filter(
user=user,
business=business,
year=year,
month=month,
).exists()
show_banner = not dismissed
elif usage.is_at_warning_threshold:
show_banner = True
# Calculate flow execution charges (0.5 cents = $0.005 per execution)
flow_execution_amount_cents = int(
usage.flow_execution_count * cls.FLOW_EXECUTION_PRICE_CENTS
)
return QuotaStatus(
year=usage.year,
month=usage.month,
appointment_count=usage.appointment_count,
quota_limit=usage.quota_limit,
is_unlimited=usage.is_unlimited,
usage_percentage=usage.usage_percentage,
remaining_appointments=usage.remaining_appointments,
is_at_warning_threshold=usage.is_at_warning_threshold,
is_over_quota=usage.is_over_quota,
overage_count=usage.overage_count,
overage_amount_cents=usage.overage_amount_cents,
warning_email_sent=usage.warning_email_sent,
show_warning_banner=show_banner,
flow_execution_count=usage.flow_execution_count,
flow_execution_amount_cents=flow_execution_amount_cents,
)
@classmethod
@transaction.atomic
def increment_appointment_count(
cls,
business: Tenant,
count: int = 1,
event_start_time: datetime | None = None,
) -> QuotaStatus:
"""
Increment the appointment count for a tenant.
This is called when a new event is scheduled. Counts against the
current billing period, not the event's date.
Returns the updated quota status and handles:
- Incrementing the count
- Updating overage count if over quota
- Triggering warning email if at threshold
Args:
business: The tenant
count: Number of appointments to add (default 1)
event_start_time: Unused - kept for backward compatibility
Returns:
Updated QuotaStatus
"""
# Always use billing period, not event date
year, month = cls.get_current_billing_period(business)
usage = cls.get_or_create_monthly_usage(business, year, month)
# Increment count
usage.appointment_count = F("appointment_count") + count
usage.save(update_fields=["appointment_count", "updated_at"])
usage.refresh_from_db()
# Update overage count if over quota
if not usage.is_unlimited and usage.appointment_count > usage.quota_limit:
overage = usage.appointment_count - usage.quota_limit
if overage > usage.overage_count:
usage.overage_count = overage
usage.save(update_fields=["overage_count", "updated_at"])
# Check if we need to send warning email
if (
usage.is_at_warning_threshold
and not usage.warning_email_sent
and not usage.is_unlimited
):
cls._send_warning_email(business, usage)
return cls.get_quota_status(business, year=year, month=month)
@classmethod
@transaction.atomic
def decrement_appointment_count(
cls,
business: Tenant,
count: int = 1,
event_start_time: datetime | None = None,
) -> QuotaStatus:
"""
Decrement the appointment count when an event is canceled/deleted.
Note: Overage count is NOT decremented. Once an overage occurs,
it's recorded for billing purposes even if appointments are later canceled.
Args:
business: The tenant
count: Number of appointments to remove (default 1)
event_start_time: Unused - kept for backward compatibility
Returns:
Updated QuotaStatus
"""
# Always use billing period
year, month = cls.get_current_billing_period(business)
try:
usage = MonthlyQuotaUsage.objects.get(
business=business, year=year, month=month
)
# Decrement but don't go below 0
new_count = max(0, usage.appointment_count - count)
usage.appointment_count = new_count
usage.save(update_fields=["appointment_count", "updated_at"])
except MonthlyQuotaUsage.DoesNotExist:
# No usage record for this month, nothing to decrement
pass
return cls.get_quota_status(business, year=year, month=month)
@classmethod
def dismiss_warning_banner(
cls,
user: User,
business: Tenant,
year: int | None = None,
month: int | None = None,
) -> None:
"""
Record that a user has dismissed the warning banner.
The banner won't show again for this user/business/month combo.
If year/month not provided, uses current billing period.
"""
if year is None or month is None:
billing_year, billing_month = cls.get_current_billing_period(business)
if year is None:
year = billing_year
if month is None:
month = billing_month
QuotaBannerDismissal.objects.get_or_create(
user=user,
business=business,
year=year,
month=month,
)
@classmethod
@transaction.atomic
def increment_flow_execution_count(
cls,
business: Tenant,
count: int = 1,
is_test: bool = False,
) -> MonthlyQuotaUsage:
"""
Increment the flow execution count for a tenant.
This is called when an automation flow executes. Testing through
the UI should NOT count against the quota.
Args:
business: The tenant
count: Number of executions to add (default 1)
is_test: If True, this is a UI test and should not be counted
Returns:
Updated MonthlyQuotaUsage
"""
if is_test:
logger.debug(f"Skipping flow execution count for test run: {business.name}")
return cls.get_or_create_monthly_usage(business)
year, month = cls.get_current_billing_period(business)
usage = cls.get_or_create_monthly_usage(business, year, month)
# Increment count
usage.flow_execution_count = F("flow_execution_count") + count
usage.save(update_fields=["flow_execution_count", "updated_at"])
usage.refresh_from_db()
logger.info(
f"Incremented flow execution count for {business.name}: "
f"{usage.flow_execution_count} executions in {year}/{month:02d}"
)
return usage
@classmethod
def _send_warning_email(cls, business: Tenant, usage: MonthlyQuotaUsage) -> None:
"""
Send quota warning email to business owner(s).
This is called when usage reaches 90% of quota.
"""
from smoothschedule.communication.messaging.email_service import EmailService
try:
# Get business owners/admins to email
owners = business.users.filter(role__in=["owner", "manager"])
if not owners.exists():
logger.warning(
f"No owners/managers to send quota warning email for {business.name}"
)
return
# Calculate stats for email
remaining = usage.remaining_appointments or 0
percentage = round(usage.usage_percentage)
for owner in owners:
try:
EmailService.send_quota_warning_email(
to_email=owner.email,
to_name=owner.name or owner.email,
business_name=business.name,
current_count=usage.appointment_count,
quota_limit=usage.quota_limit,
remaining=remaining,
percentage=percentage,
overage_price_cents=cls.OVERAGE_PRICE_CENTS,
)
except Exception as e:
logger.error(
f"Failed to send quota warning email to {owner.email}: {e}"
)
# Mark warning email as sent
usage.warning_email_sent = True
usage.warning_email_sent_at = timezone.now()
usage.save(update_fields=["warning_email_sent", "warning_email_sent_at", "updated_at"])
logger.info(
f"Sent quota warning email for {business.name} "
f"({usage.appointment_count}/{usage.quota_limit})"
)
except Exception as e:
logger.error(f"Failed to send quota warning emails for {business.name}: {e}")
@classmethod
def recalculate_monthly_usage(
cls,
business: Tenant,
year: int | None = None,
month: int | None = None,
) -> QuotaStatus:
"""
Recalculate the monthly appointment count from the Event table.
This is useful for fixing drift or after bulk operations.
"""
from django.db.models import Count
now = timezone.now()
if year is None:
year = now.year
if month is None:
month = now.month
# Import here to avoid circular imports
from django.db import connection
# Count events for this month in the tenant's schema
# Events are in tenant schema, so we need to use the tenant connection
with connection.cursor() as cursor:
cursor.execute(f'SET search_path TO "{business.schema_name}"')
from smoothschedule.scheduling.schedule.models import Event
# Count scheduled events for this month
# Only count events that are not canceled
start_of_month = timezone.make_aware(
datetime(year, month, 1), timezone.get_current_timezone()
)
if month == 12:
end_of_month = timezone.make_aware(
datetime(year + 1, 1, 1), timezone.get_current_timezone()
)
else:
end_of_month = timezone.make_aware(
datetime(year, month + 1, 1), timezone.get_current_timezone()
)
count = Event.objects.filter(
start_time__gte=start_of_month,
start_time__lt=end_of_month,
).exclude(
status__in=[Event.Status.CANCELED]
).count()
# Update the usage record
usage = cls.get_or_create_monthly_usage(business, year, month)
usage.appointment_count = count
# Recalculate overage
if not usage.is_unlimited and count > usage.quota_limit:
usage.overage_count = count - usage.quota_limit
else:
usage.overage_count = 0
usage.save(update_fields=["appointment_count", "overage_count", "updated_at"])
return cls.get_quota_status(business, year=year, month=month)
# =========================================================================
# API Request Tracking (Daily)
# =========================================================================
API_FEATURE_CODE = "max_api_requests_per_day"
@classmethod
def get_api_request_quota_limit(cls, business: Tenant) -> int:
"""
Get the daily API request quota limit for a tenant.
Returns 0 for unlimited, or the integer limit.
"""
limit = EntitlementService.get_limit(business, cls.API_FEATURE_CODE)
# None or 0 means unlimited
return limit if limit is not None else 0
@classmethod
def get_or_create_daily_api_usage(
cls, business: Tenant, date: datetime | None = None
) -> DailyApiUsage:
"""
Get or create the daily API usage record for a tenant.
If date not provided, uses current UTC date.
"""
if date is None:
date = timezone.now().date()
elif hasattr(date, 'date'):
date = date.date()
quota_limit = cls.get_api_request_quota_limit(business)
usage, created = DailyApiUsage.objects.get_or_create(
business=business,
date=date,
defaults={"quota_limit": quota_limit},
)
# Update quota limit if it changed (e.g., plan upgrade)
if not created and usage.quota_limit != quota_limit:
usage.quota_limit = quota_limit
usage.save(update_fields=["quota_limit", "updated_at"])
return usage
@classmethod
@transaction.atomic
def increment_api_request_count(
cls,
business: Tenant,
count: int = 1,
) -> tuple[DailyApiUsage, bool]:
"""
Increment the API request count for a tenant.
This is called for each API request from the public API.
Does NOT block requests if over quota - just tracks usage.
Args:
business: The tenant
count: Number of requests to add (default 1)
Returns:
Tuple of (DailyApiUsage, is_over_quota)
"""
usage = cls.get_or_create_daily_api_usage(business)
# Increment count
usage.request_count = F("request_count") + count
usage.save(update_fields=["request_count", "updated_at"])
usage.refresh_from_db()
return usage, usage.is_over_quota
@classmethod
def check_api_quota(cls, business: Tenant) -> tuple[bool, int | None]:
"""
Check if a tenant is within their API quota.
Args:
business: The tenant
Returns:
Tuple of (is_allowed, remaining_requests)
- is_allowed: True if request should be allowed
- remaining_requests: Number of requests remaining (None if unlimited)
"""
usage = cls.get_or_create_daily_api_usage(business)
if usage.is_unlimited:
return True, None
remaining = usage.remaining_requests
is_allowed = remaining > 0
return is_allowed, remaining
@classmethod
def get_api_usage_status(cls, business: Tenant) -> dict:
"""
Get the current API usage status for a tenant.
Returns:
Dictionary with usage info
"""
usage = cls.get_or_create_daily_api_usage(business)
return {
"date": usage.date.isoformat(),
"request_count": usage.request_count,
"quota_limit": usage.quota_limit,
"is_unlimited": usage.is_unlimited,
"is_over_quota": usage.is_over_quota,
"remaining_requests": usage.remaining_requests,
"usage_percentage": usage.usage_percentage,
}

View File

@@ -0,0 +1,335 @@
"""
StorageService - Measures and tracks database storage usage per tenant.
This service handles:
- Measuring PostgreSQL schema sizes for each tenant
- Tracking storage usage against quotas
- Sending warning emails at 90% threshold
- Calculating overage charges ($0.50 per GB over quota)
Storage is measured periodically by a Celery task and cached in the
StorageUsage model. This avoids expensive real-time queries.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, NamedTuple
from django.db import connection
from django.utils import timezone
from smoothschedule.billing.models import StorageUsage
from smoothschedule.billing.services.entitlements import EntitlementService
if TYPE_CHECKING:
from smoothschedule.identity.core.models import Tenant
logger = logging.getLogger(__name__)
class StorageStatus(NamedTuple):
"""Current storage status for a tenant."""
year: int
month: int
current_size_bytes: int
current_size_mb: float
current_size_gb: float
peak_size_bytes: int
peak_size_mb: float
quota_limit_mb: int
quota_limit_gb: float
is_unlimited: bool
usage_percentage: float
remaining_mb: float | None
is_at_warning_threshold: bool
is_over_quota: bool
overage_mb: float
overage_amount_cents: int
warning_email_sent: bool
last_measured_at: str | None
table_sizes: dict
class StorageService:
"""Service for measuring and tracking database storage usage."""
FEATURE_CODE = "max_storage_mb"
WARNING_THRESHOLD_PERCENT = 90
OVERAGE_PRICE_CENTS_PER_GB = 50 # $0.50 per GB
@classmethod
def get_storage_quota_limit(cls, business: Tenant) -> int:
"""
Get the storage quota limit in MB for a tenant.
Returns 0 for unlimited, or the integer limit in MB.
"""
limit = EntitlementService.get_limit(business, cls.FEATURE_CODE)
# None or 0 means unlimited
return limit if limit is not None else 0
@classmethod
def get_current_billing_period(cls, business: Tenant) -> tuple[int, int]:
"""
Get the current billing period (year, month) for a tenant.
Uses the same logic as QuotaService for consistency.
"""
from smoothschedule.billing.services.quota import QuotaService
return QuotaService.get_current_billing_period(business)
@classmethod
def get_or_create_storage_usage(
cls, business: Tenant, year: int | None = None, month: int | None = None
) -> StorageUsage:
"""
Get or create the storage usage record for a tenant.
If year/month not provided, uses current billing period.
"""
if year is None or month is None:
billing_year, billing_month = cls.get_current_billing_period(business)
if year is None:
year = billing_year
if month is None:
month = billing_month
quota_limit_mb = cls.get_storage_quota_limit(business)
usage, created = StorageUsage.objects.get_or_create(
business=business,
year=year,
month=month,
defaults={"quota_limit_mb": quota_limit_mb},
)
# Update quota limit if it changed (e.g., plan upgrade)
if not created and usage.quota_limit_mb != quota_limit_mb:
usage.quota_limit_mb = quota_limit_mb
usage.save(update_fields=["quota_limit_mb", "updated_at"])
return usage
@classmethod
def measure_schema_size(cls, schema_name: str) -> tuple[int, dict]:
"""
Measure the size of a PostgreSQL schema.
Args:
schema_name: The schema name to measure
Returns:
Tuple of (total_size_bytes, table_sizes_dict)
"""
table_sizes = {}
total_size = 0
try:
with connection.cursor() as cursor:
# Get size of each table in the schema
cursor.execute("""
SELECT
tablename,
pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(tablename)) as size
FROM pg_tables
WHERE schemaname = %s
ORDER BY size DESC
""", [schema_name])
for row in cursor.fetchall():
table_name, size = row
table_sizes[table_name] = size
total_size += size
# Also include indexes not accounted for in table sizes
cursor.execute("""
SELECT
COALESCE(sum(pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(indexname))), 0)
FROM pg_indexes
WHERE schemaname = %s
""", [schema_name])
# Note: pg_total_relation_size already includes indexes,
# so we don't need to add this separately
except Exception as e:
logger.error(f"Failed to measure schema size for {schema_name}: {e}")
return total_size, table_sizes
@classmethod
def measure_tenant_storage(cls, business: Tenant) -> StorageUsage:
"""
Measure and update storage for a single tenant.
Args:
business: The tenant to measure
Returns:
Updated StorageUsage record
"""
# Measure the schema
total_size, table_sizes = cls.measure_schema_size(business.schema_name)
# Get or create usage record
usage = cls.get_or_create_storage_usage(business)
# Update the measurement
usage.update_measurement(total_size, table_sizes)
# Check if we need to send warning email
if (
usage.is_at_warning_threshold
and not usage.warning_email_sent
and not usage.is_unlimited
):
cls._send_warning_email(business, usage)
logger.info(
f"Measured storage for {business.name}: "
f"{usage.current_size_mb:.1f}MB / {usage.quota_limit_mb or 'unlimited'}MB"
)
return usage
@classmethod
def measure_all_tenants(cls) -> list[StorageUsage]:
"""
Measure storage for all active tenants.
Called by Celery task periodically.
Returns:
List of updated StorageUsage records
"""
from smoothschedule.identity.core.models import Tenant
results = []
tenants = Tenant.objects.exclude(schema_name='public').filter(is_active=True)
for tenant in tenants:
try:
usage = cls.measure_tenant_storage(tenant)
results.append(usage)
except Exception as e:
logger.error(f"Failed to measure storage for {tenant.name}: {e}")
logger.info(f"Measured storage for {len(results)} tenants")
return results
@classmethod
def get_storage_status(
cls,
business: Tenant,
year: int | None = None,
month: int | None = None,
) -> StorageStatus:
"""
Get the current storage status for a tenant.
If year/month not provided, uses current billing period.
"""
if year is None or month is None:
billing_year, billing_month = cls.get_current_billing_period(business)
if year is None:
year = billing_year
if month is None:
month = billing_month
usage = cls.get_or_create_storage_usage(business, year, month)
return StorageStatus(
year=usage.year,
month=usage.month,
current_size_bytes=usage.current_size_bytes,
current_size_mb=usage.current_size_mb,
current_size_gb=usage.current_size_gb,
peak_size_bytes=usage.peak_size_bytes,
peak_size_mb=usage.peak_size_mb,
quota_limit_mb=usage.quota_limit_mb,
quota_limit_gb=usage.quota_limit_gb,
is_unlimited=usage.is_unlimited,
usage_percentage=usage.usage_percentage,
remaining_mb=usage.remaining_mb,
is_at_warning_threshold=usage.is_at_warning_threshold,
is_over_quota=usage.is_over_quota,
overage_mb=usage.overage_mb,
overage_amount_cents=usage.overage_amount_cents,
warning_email_sent=usage.warning_email_sent,
last_measured_at=usage.last_measured_at.isoformat() if usage.last_measured_at else None,
table_sizes=usage.table_sizes,
)
@classmethod
def _send_warning_email(cls, business: Tenant, usage: StorageUsage) -> None:
"""
Send storage warning email to business owner(s).
This is called when usage reaches 90% of quota.
"""
from smoothschedule.communication.messaging.email_service import EmailService
try:
# Get business owners/admins to email
owners = business.users.filter(role__in=["owner", "manager"])
if not owners.exists():
logger.warning(
f"No owners/managers to send storage warning email for {business.name}"
)
return
# Calculate stats for email
remaining = usage.remaining_mb or 0
percentage = round(usage.usage_percentage)
for owner in owners:
try:
EmailService.send_storage_warning_email(
to_email=owner.email,
to_name=owner.name or owner.email,
business_name=business.name,
current_mb=round(usage.current_size_mb, 1),
quota_limit_mb=usage.quota_limit_mb,
remaining_mb=round(remaining, 1),
percentage=percentage,
overage_price_cents_per_gb=cls.OVERAGE_PRICE_CENTS_PER_GB,
)
except Exception as e:
logger.error(
f"Failed to send storage warning email to {owner.email}: {e}"
)
# Mark warning email as sent
usage.warning_email_sent = True
usage.warning_email_sent_at = timezone.now()
usage.save(update_fields=["warning_email_sent", "warning_email_sent_at", "updated_at"])
logger.info(
f"Sent storage warning email for {business.name} "
f"({usage.current_size_mb:.1f}MB/{usage.quota_limit_mb}MB)"
)
except Exception as e:
logger.error(f"Failed to send storage warning emails for {business.name}: {e}")
@classmethod
def format_size(cls, size_bytes: int) -> str:
"""
Format a size in bytes to a human-readable string.
Args:
size_bytes: Size in bytes
Returns:
Formatted string like "1.5 GB" or "256 MB"
"""
if size_bytes >= 1024 * 1024 * 1024: # >= 1 GB
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
elif size_bytes >= 1024 * 1024: # >= 1 MB
return f"{size_bytes / (1024 * 1024):.1f} MB"
elif size_bytes >= 1024: # >= 1 KB
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes} bytes"

View File

@@ -2,7 +2,7 @@
Celery tasks for billing operations.
These tasks run periodically to manage subscription-related operations like
grace period tracking for custom tiers.
grace period tracking for custom tiers and storage usage measurement.
"""
from datetime import timedelta
@@ -13,6 +13,89 @@ import logging
logger = logging.getLogger(__name__)
@shared_task
def measure_all_tenant_storage():
"""
Measure database storage for all active tenants.
This task should run hourly to:
1. Query PostgreSQL schema sizes for each tenant
2. Update StorageUsage records with current measurements
3. Track peak usage for billing purposes
4. Send warning emails at 90% threshold
Returns:
dict: Summary of measurements taken
"""
from smoothschedule.billing.services.storage import StorageService
results = {
'tenants_measured': 0,
'warnings_sent': 0,
'errors': [],
}
try:
usages = StorageService.measure_all_tenants()
results['tenants_measured'] = len(usages)
# Count warnings sent
for usage in usages:
if usage.warning_email_sent and usage.warning_email_sent_at:
# Check if warning was sent in this run (within last minute)
if (timezone.now() - usage.warning_email_sent_at).seconds < 60:
results['warnings_sent'] += 1
logger.info(
f"Storage measurement complete: {results['tenants_measured']} tenants measured, "
f"{results['warnings_sent']} warnings sent"
)
except Exception as e:
error_msg = f"Error measuring storage: {str(e)}"
logger.error(error_msg, exc_info=True)
results['errors'].append(error_msg)
return results
@shared_task
def measure_tenant_storage(tenant_id: int):
"""
Measure database storage for a single tenant.
This can be triggered on-demand for real-time usage checks.
Args:
tenant_id: The ID of the tenant to measure
Returns:
dict: Storage measurement results
"""
from smoothschedule.identity.core.models import Tenant
from smoothschedule.billing.services.storage import StorageService
try:
tenant = Tenant.objects.get(id=tenant_id)
usage = StorageService.measure_tenant_storage(tenant)
return {
'tenant_id': tenant_id,
'tenant_name': tenant.name,
'current_size_mb': usage.current_size_mb,
'quota_limit_mb': usage.quota_limit_mb,
'usage_percentage': usage.usage_percentage,
'is_over_quota': usage.is_over_quota,
}
except Tenant.DoesNotExist:
logger.error(f"Tenant {tenant_id} not found")
return {'error': f"Tenant {tenant_id} not found"}
except Exception as e:
logger.error(f"Error measuring storage for tenant {tenant_id}: {e}")
return {'error': str(e)}
@shared_task
def check_subscription_grace_periods():
"""

View File

@@ -0,0 +1,582 @@
"""
Tests for QuotaService - Quota Tracking and Enforcement.
Quota Types:
- Daily reset: API calls (max_api_requests_per_day)
- Monthly reset (billing cycle): Appointments, Emails, SMS, Automation Runs
- No reset (permanent max): max_users, max_resources, max_locations, etc.
These tests verify:
1. Billing period determination (based on subscription start date)
2. Quota status calculations
3. Warning threshold logic (90%)
4. Banner dismissal tracking
5. Daily vs monthly reset behavior
"""
from datetime import date, datetime, timedelta, timezone as dt_timezone
from decimal import Decimal
from unittest.mock import Mock, patch, MagicMock, PropertyMock
import pytest
from django.utils import timezone
from smoothschedule.billing.models import (
DailyApiUsage,
MonthlyQuotaUsage,
QuotaBannerDismissal,
Subscription,
)
from smoothschedule.billing.services.quota import QuotaService, QuotaStatus
class TestBillingPeriodDetermination:
"""Tests for billing period determination."""
def test_get_billing_period_from_active_subscription(self):
"""Should return year/month from subscription's current_period_start."""
mock_tenant = Mock()
mock_subscription = Mock()
mock_subscription.is_active = True
mock_subscription.current_period_start = datetime(2024, 6, 15, tzinfo=dt_timezone.utc)
with patch.object(
Subscription.objects, 'get', return_value=mock_subscription
):
year, month = QuotaService.get_current_billing_period(mock_tenant)
assert year == 2024
assert month == 6
def test_get_billing_period_falls_back_to_calendar_month(self):
"""Should return current calendar month if no subscription."""
mock_tenant = Mock()
with patch.object(
Subscription.objects, 'get', side_effect=Subscription.DoesNotExist
):
with patch.object(timezone, 'now') as mock_now:
mock_now.return_value = datetime(2024, 12, 25, tzinfo=dt_timezone.utc)
year, month = QuotaService.get_current_billing_period(mock_tenant)
assert year == 2024
assert month == 12
def test_get_billing_period_inactive_subscription_uses_calendar(self):
"""Should fall back to calendar month if subscription is inactive."""
mock_tenant = Mock()
mock_subscription = Mock()
mock_subscription.is_active = False
with patch.object(
Subscription.objects, 'get', return_value=mock_subscription
):
with patch.object(timezone, 'now') as mock_now:
mock_now.return_value = datetime(2024, 11, 10, tzinfo=dt_timezone.utc)
year, month = QuotaService.get_current_billing_period(mock_tenant)
assert year == 2024
assert month == 11
class TestQuotaStatusCalculation:
"""Tests for QuotaStatus calculation without DB access."""
def test_quota_status_includes_all_required_fields(self):
"""QuotaStatus should include all required fields."""
fields = QuotaStatus._fields
expected_fields = {
'year', 'month', 'appointment_count', 'quota_limit',
'is_unlimited', 'usage_percentage', 'remaining_appointments',
'is_at_warning_threshold', 'is_over_quota', 'overage_count',
'overage_amount_cents', 'warning_email_sent', 'show_warning_banner',
'flow_execution_count', 'flow_execution_amount_cents',
}
assert set(fields) == expected_fields
def test_flow_execution_pricing_calculation(self):
"""Should calculate $0.005 per flow execution correctly."""
# 1000 executions * 0.5 cents = 500 cents = $5.00
flow_count = 1000
expected_cents = int(flow_count * QuotaService.FLOW_EXECUTION_PRICE_CENTS)
assert expected_cents == 500
def test_overage_pricing_calculation(self):
"""Should calculate $0.10 per overage appointment."""
# 10 overage * 10 cents = 100 cents = $1.00
overage_count = 10
expected_cents = overage_count * QuotaService.OVERAGE_PRICE_CENTS
assert expected_cents == 100
class TestMonthlyQuotaUsageModel:
"""Tests for MonthlyQuotaUsage model properties."""
def test_is_unlimited_when_quota_zero(self):
"""quota_limit=0 means unlimited."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=100,
quota_limit=0
)
assert usage.is_unlimited is True
def test_is_not_unlimited_when_quota_set(self):
"""quota_limit>0 means limited."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=50,
quota_limit=100
)
assert usage.is_unlimited is False
def test_usage_percentage_calculation(self):
"""Should calculate percentage correctly."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=75,
quota_limit=100
)
assert usage.usage_percentage == 75.0
def test_usage_percentage_zero_when_unlimited(self):
"""Should return 0% for unlimited plans."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=1000,
quota_limit=0
)
assert usage.usage_percentage == 0.0
def test_is_at_warning_threshold_at_90_percent(self):
"""Should trigger warning at 90%."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=90,
quota_limit=100
)
assert usage.is_at_warning_threshold is True
def test_is_not_at_warning_threshold_below_90(self):
"""Should not trigger warning below 90%."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=89,
quota_limit=100
)
assert usage.is_at_warning_threshold is False
def test_is_over_quota(self):
"""Should detect when over quota."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=101,
quota_limit=100
)
assert usage.is_over_quota is True
def test_is_not_over_quota(self):
"""Should detect when under quota."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=99,
quota_limit=100
)
assert usage.is_over_quota is False
def test_remaining_appointments_calculation(self):
"""Should calculate remaining correctly."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=75,
quota_limit=100
)
assert usage.remaining_appointments == 25
def test_remaining_appointments_none_when_unlimited(self):
"""Should return None for unlimited plans."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=1000,
quota_limit=0
)
assert usage.remaining_appointments is None
def test_overage_amount_cents_calculation(self):
"""Should calculate overage charges."""
usage = MonthlyQuotaUsage(
year=2024, month=6,
appointment_count=110,
quota_limit=100,
overage_count=10
)
# 10 overage * 10 cents = 100 cents
assert usage.overage_amount_cents == 100
class TestDailyApiUsageModel:
"""Tests for DailyApiUsage model properties."""
def test_is_unlimited_when_quota_zero(self):
"""quota_limit=0 means unlimited."""
usage = DailyApiUsage(
date=date(2024, 6, 15),
request_count=1000,
quota_limit=0
)
assert usage.is_unlimited is True
def test_is_over_quota(self):
"""Should detect when at or over quota."""
usage = DailyApiUsage(
date=date(2024, 6, 15),
request_count=1000,
quota_limit=1000
)
assert usage.is_over_quota is True
def test_remaining_requests(self):
"""Should calculate remaining requests."""
usage = DailyApiUsage(
date=date(2024, 6, 15),
request_count=750,
quota_limit=1000
)
assert usage.remaining_requests == 250
def test_usage_percentage(self):
"""Should calculate usage percentage."""
usage = DailyApiUsage(
date=date(2024, 6, 15),
request_count=500,
quota_limit=1000
)
assert usage.usage_percentage == 50.0
class TestQuotaLimitRetrieval:
"""Tests for quota limit retrieval from entitlements."""
def test_get_current_quota_limit_returns_integer(self):
"""Should return integer limit from entitlements."""
mock_tenant = Mock()
with patch(
'smoothschedule.billing.services.quota.EntitlementService.get_limit',
return_value=100
):
limit = QuotaService.get_current_quota_limit(mock_tenant)
assert limit == 100
def test_get_current_quota_limit_returns_zero_for_none(self):
"""Should return 0 (unlimited) when entitlement returns None."""
mock_tenant = Mock()
with patch(
'smoothschedule.billing.services.quota.EntitlementService.get_limit',
return_value=None
):
limit = QuotaService.get_current_quota_limit(mock_tenant)
assert limit == 0
def test_get_api_request_quota_limit_returns_integer(self):
"""Should return integer limit for API requests."""
mock_tenant = Mock()
with patch(
'smoothschedule.billing.services.quota.EntitlementService.get_limit',
return_value=1000
):
limit = QuotaService.get_api_request_quota_limit(mock_tenant)
assert limit == 1000
class TestBannerDismissalLogic:
"""Tests for quota warning banner dismissal."""
def test_show_banner_when_at_threshold_and_not_dismissed(self):
"""Should show banner when at threshold and not dismissed."""
mock_tenant = Mock()
mock_user = Mock()
mock_usage = Mock()
mock_usage.year = 2024
mock_usage.month = 6
mock_usage.appointment_count = 95
mock_usage.quota_limit = 100
mock_usage.is_unlimited = False
mock_usage.usage_percentage = 95.0
mock_usage.remaining_appointments = 5
mock_usage.is_at_warning_threshold = True
mock_usage.is_over_quota = False
mock_usage.overage_count = 0
mock_usage.overage_amount_cents = 0
mock_usage.warning_email_sent = True
mock_usage.flow_execution_count = 0
with patch.object(
QuotaService, 'get_current_billing_period', return_value=(2024, 6)
), patch.object(
QuotaService, 'get_or_create_monthly_usage', return_value=mock_usage
), patch.object(
QuotaBannerDismissal.objects, 'filter'
) as mock_filter:
mock_filter.return_value.exists.return_value = False # Not dismissed
status = QuotaService.get_quota_status(mock_tenant, user=mock_user)
assert status.show_warning_banner is True
def test_hide_banner_when_dismissed(self):
"""Should hide banner when already dismissed."""
mock_tenant = Mock()
mock_user = Mock()
mock_usage = Mock()
mock_usage.year = 2024
mock_usage.month = 6
mock_usage.appointment_count = 95
mock_usage.quota_limit = 100
mock_usage.is_unlimited = False
mock_usage.usage_percentage = 95.0
mock_usage.remaining_appointments = 5
mock_usage.is_at_warning_threshold = True
mock_usage.is_over_quota = False
mock_usage.overage_count = 0
mock_usage.overage_amount_cents = 0
mock_usage.warning_email_sent = True
mock_usage.flow_execution_count = 0
with patch.object(
QuotaService, 'get_current_billing_period', return_value=(2024, 6)
), patch.object(
QuotaService, 'get_or_create_monthly_usage', return_value=mock_usage
), patch.object(
QuotaBannerDismissal.objects, 'filter'
) as mock_filter:
mock_filter.return_value.exists.return_value = True # Dismissed
status = QuotaService.get_quota_status(mock_tenant, user=mock_user)
assert status.show_warning_banner is False
def test_hide_banner_when_below_threshold(self):
"""Should hide banner when below warning threshold."""
mock_tenant = Mock()
mock_usage = Mock()
mock_usage.year = 2024
mock_usage.month = 6
mock_usage.appointment_count = 50
mock_usage.quota_limit = 100
mock_usage.is_unlimited = False
mock_usage.usage_percentage = 50.0
mock_usage.remaining_appointments = 50
mock_usage.is_at_warning_threshold = False # Below threshold
mock_usage.is_over_quota = False
mock_usage.overage_count = 0
mock_usage.overage_amount_cents = 0
mock_usage.warning_email_sent = False
mock_usage.flow_execution_count = 0
with patch.object(
QuotaService, 'get_current_billing_period', return_value=(2024, 6)
), patch.object(
QuotaService, 'get_or_create_monthly_usage', return_value=mock_usage
):
status = QuotaService.get_quota_status(mock_tenant)
assert status.show_warning_banner is False
class TestQuotaApiRequestTracking:
"""Tests for API request quota checking (non-DB tests)."""
def test_check_api_quota_allows_when_under_limit(self):
"""Should allow requests when under quota."""
mock_tenant = Mock()
mock_usage = Mock()
mock_usage.is_unlimited = False
mock_usage.remaining_requests = 50
with patch.object(
QuotaService, 'get_or_create_daily_api_usage', return_value=mock_usage
):
is_allowed, remaining = QuotaService.check_api_quota(mock_tenant)
assert is_allowed is True
assert remaining == 50
def test_check_api_quota_blocks_when_at_limit(self):
"""Should block when at quota limit."""
mock_tenant = Mock()
mock_usage = Mock()
mock_usage.is_unlimited = False
mock_usage.remaining_requests = 0
with patch.object(
QuotaService, 'get_or_create_daily_api_usage', return_value=mock_usage
):
is_allowed, remaining = QuotaService.check_api_quota(mock_tenant)
assert is_allowed is False
assert remaining == 0
def test_check_api_quota_always_allows_unlimited(self):
"""Should always allow for unlimited plans."""
mock_tenant = Mock()
mock_usage = Mock()
mock_usage.is_unlimited = True
with patch.object(
QuotaService, 'get_or_create_daily_api_usage', return_value=mock_usage
):
is_allowed, remaining = QuotaService.check_api_quota(mock_tenant)
assert is_allowed is True
assert remaining is None
def test_get_api_usage_status_returns_dict(self):
"""Should return dictionary with usage info."""
mock_tenant = Mock()
mock_usage = Mock()
mock_usage.date = date(2024, 6, 15)
mock_usage.request_count = 500
mock_usage.quota_limit = 1000
mock_usage.is_unlimited = False
mock_usage.is_over_quota = False
mock_usage.remaining_requests = 500
mock_usage.usage_percentage = 50.0
with patch.object(
QuotaService, 'get_or_create_daily_api_usage', return_value=mock_usage
):
status = QuotaService.get_api_usage_status(mock_tenant)
assert status['date'] == '2024-06-15'
assert status['request_count'] == 500
assert status['quota_limit'] == 1000
assert status['is_unlimited'] is False
assert status['remaining_requests'] == 500
class TestFlowExecutionTracking:
"""Tests for flow execution tracking logic."""
def test_flow_execution_pricing_constant(self):
"""Should have correct pricing constant ($0.005 = 0.5 cents)."""
assert QuotaService.FLOW_EXECUTION_PRICE_CENTS == Decimal("0.5")
def test_flow_execution_price_calculation(self):
"""Should calculate price correctly for flow executions."""
# 2000 executions * 0.5 cents = 1000 cents = $10.00
executions = 2000
price_cents = int(executions * QuotaService.FLOW_EXECUTION_PRICE_CENTS)
assert price_cents == 1000
class TestQuotaResetBehavior:
"""Tests for quota reset behavior."""
def test_daily_api_quota_resets_each_day(self):
"""Each day should get a fresh usage record."""
# This tests the conceptual model - each date gets its own record
# meaning quotas reset daily
day1 = date(2024, 6, 15)
day2 = date(2024, 6, 16)
# These would be separate records in the DB
usage_day1 = DailyApiUsage(date=day1, request_count=1000, quota_limit=1000)
usage_day2 = DailyApiUsage(date=day2, request_count=0, quota_limit=1000)
assert usage_day1.is_over_quota is True # Day 1 exhausted
assert usage_day2.is_over_quota is False # Day 2 fresh
def test_monthly_quota_resets_each_billing_cycle(self):
"""Each billing period should get a fresh usage record."""
# December billing cycle (over limit - 101 > 100)
usage_dec = MonthlyQuotaUsage(year=2024, month=12, appointment_count=101, quota_limit=100)
# January billing cycle (new period - fresh start)
usage_jan = MonthlyQuotaUsage(year=2025, month=1, appointment_count=0, quota_limit=100)
assert usage_dec.is_over_quota is True # December over quota
assert usage_jan.is_over_quota is False # January fresh
def test_billing_period_based_on_subscription_start(self):
"""
Billing period should be based on subscription.current_period_start,
not calendar month.
"""
mock_tenant = Mock()
# Subscription started mid-month (Dec 15)
mock_subscription = Mock()
mock_subscription.is_active = True
mock_subscription.current_period_start = datetime(
2024, 12, 15, tzinfo=dt_timezone.utc
)
with patch.object(
Subscription.objects, 'get', return_value=mock_subscription
):
year, month = QuotaService.get_current_billing_period(mock_tenant)
# Should be December (when billing period started)
# not January (even if called in January)
assert year == 2024
assert month == 12
class TestQuotaWarningEmail:
"""Tests for quota warning email functionality."""
def test_send_quota_warning_email_builds_correct_context(self):
"""Should build correct context for quota warning email."""
from smoothschedule.communication.messaging.email_service import EmailService
from smoothschedule.communication.messaging.email_types import EmailType
with patch(
'smoothschedule.communication.messaging.email_service.send_system_email'
) as mock_send:
mock_send.return_value = True
result = EmailService.send_quota_warning_email(
to_email='owner@business.com',
to_name='Business Owner',
business_name='My Business',
current_count=90,
quota_limit=100,
remaining=10,
percentage=90,
overage_price_cents=10,
)
assert result is True
mock_send.assert_called_once()
# Verify the context
call_args = mock_send.call_args
assert call_args[1]['email_type'] == EmailType.QUOTA_WARNING
assert call_args[1]['to_email'] == 'owner@business.com'
context = call_args[1]['context']
assert context['owner_name'] == 'Business Owner'
assert context['business_name'] == 'My Business'
assert context['usage_percentage'] == 90
assert context['appointments_used'] == 90
assert context['appointments_limit'] == 100
assert context['appointments_remaining'] == 10
assert context['overage_price'] == '$0.10'
def test_send_quota_warning_email_handles_failure(self):
"""Should return False when email fails to send."""
from smoothschedule.communication.messaging.email_service import EmailService
with patch(
'smoothschedule.communication.messaging.email_service.send_system_email'
) as mock_send:
mock_send.return_value = False
result = EmailService.send_quota_warning_email(
to_email='owner@business.com',
to_name='Business Owner',
business_name='My Business',
current_count=90,
quota_limit=100,
remaining=10,
percentage=90,
overage_price_cents=10,
)
assert result is False

View File

@@ -0,0 +1,546 @@
"""
Tests for Storage Quota System.
Tests for:
- StorageUsage model properties
- StorageService methods
- Storage warning email
All tests use mocks - no database access.
"""
from datetime import datetime, timezone as dt_timezone
from unittest.mock import Mock, patch, MagicMock
import pytest
from smoothschedule.billing.models import StorageUsage
from smoothschedule.billing.services.storage import StorageService, StorageStatus
class TestStorageUsageModel:
"""Tests for StorageUsage model properties."""
def test_current_size_mb_calculation(self):
"""Should convert bytes to MB correctly."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 100, # 100 MB
quota_limit_mb=1000
)
assert usage.current_size_mb == 100.0
def test_current_size_gb_calculation(self):
"""Should convert bytes to GB correctly."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 1024 * 2, # 2 GB
quota_limit_mb=5000
)
assert usage.current_size_gb == 2.0
def test_quota_limit_gb_calculation(self):
"""Should convert quota limit MB to GB correctly."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=0,
quota_limit_mb=2048 # 2 GB
)
assert usage.quota_limit_gb == 2.0
def test_is_unlimited_when_quota_zero(self):
"""quota_limit_mb=0 means unlimited storage."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 500, # 500 MB
quota_limit_mb=0
)
assert usage.is_unlimited is True
def test_is_not_unlimited_when_quota_set(self):
"""quota_limit_mb>0 means limited storage."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 500,
quota_limit_mb=1000
)
assert usage.is_unlimited is False
def test_usage_percentage_calculation(self):
"""Should calculate percentage correctly."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 750, # 750 MB
quota_limit_mb=1000
)
assert usage.usage_percentage == 75.0
def test_usage_percentage_zero_when_unlimited(self):
"""Should return 0% for unlimited plans."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 1000,
quota_limit_mb=0
)
assert usage.usage_percentage == 0.0
def test_is_at_warning_threshold_at_90_percent(self):
"""Should trigger warning at 90%."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 900, # 900 MB
quota_limit_mb=1000
)
assert usage.is_at_warning_threshold is True
def test_is_not_at_warning_threshold_below_90(self):
"""Should not trigger warning below 90%."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 890, # 890 MB = 89%
quota_limit_mb=1000
)
assert usage.is_at_warning_threshold is False
def test_is_not_at_warning_threshold_when_unlimited(self):
"""Should not trigger warning for unlimited plans."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 10000, # 10 GB
quota_limit_mb=0 # Unlimited
)
assert usage.is_at_warning_threshold is False
def test_is_over_quota(self):
"""Should detect when over quota."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 1100, # 1100 MB
quota_limit_mb=1000
)
assert usage.is_over_quota is True
def test_is_not_over_quota(self):
"""Should detect when under quota."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 990, # 990 MB
quota_limit_mb=1000
)
assert usage.is_over_quota is False
def test_is_not_over_quota_when_unlimited(self):
"""Should never be over quota for unlimited plans."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 1024 * 100, # 100 GB
quota_limit_mb=0
)
assert usage.is_over_quota is False
def test_remaining_mb_calculation(self):
"""Should calculate remaining correctly."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 750, # 750 MB
quota_limit_mb=1000
)
assert usage.remaining_mb == 250.0
def test_remaining_mb_none_when_unlimited(self):
"""Should return None for unlimited plans."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 1000,
quota_limit_mb=0
)
assert usage.remaining_mb is None
def test_remaining_mb_zero_when_over_quota(self):
"""Should return 0 when over quota."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 1200, # 1200 MB
quota_limit_mb=1000
)
assert usage.remaining_mb == 0.0
def test_overage_mb_when_over_quota(self):
"""Should calculate overage MB based on peak_size_bytes."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 1200, # 1200 MB current
peak_size_bytes=1024 * 1024 * 1200, # 1200 MB peak (overage based on peak)
quota_limit_mb=1000
)
assert usage.overage_mb == 200.0
def test_overage_mb_zero_when_under_quota(self):
"""Should be 0 when under quota."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 800, # 800 MB
peak_size_bytes=1024 * 1024 * 800, # Peak also 800 MB
quota_limit_mb=1000
)
assert usage.overage_mb == 0.0
def test_overage_amount_cents_calculation(self):
"""Should calculate overage charges at $0.50 per GB based on peak usage."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 3072, # 3072 MB = 3 GB
peak_size_bytes=1024 * 1024 * 3072, # Peak also 3 GB
quota_limit_mb=1024 # 1 GB limit
)
# 2 GB overage * 50 cents = 100 cents = $1.00
assert usage.overage_amount_cents == 100
def test_overage_amount_cents_zero_when_under_quota(self):
"""Should be 0 cents when under quota."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 500, # 500 MB
peak_size_bytes=1024 * 1024 * 500,
quota_limit_mb=1000
)
assert usage.overage_amount_cents == 0
def test_peak_size_tracking(self):
"""Should track peak size in MB."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 500,
peak_size_bytes=1024 * 1024 * 750, # Peak was 750 MB
quota_limit_mb=1000
)
assert usage.peak_size_mb == 750.0
class TestStorageUsageUpdateMeasurement:
"""Tests for StorageUsage.update_measurement method."""
def test_update_measurement_updates_current_size(self):
"""Should update current size."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=0,
peak_size_bytes=0,
quota_limit_mb=1000
)
new_size = 1024 * 1024 * 500 # 500 MB
with patch.object(usage, 'save'): # Mock save to avoid DB
usage.update_measurement(new_size)
assert usage.current_size_bytes == new_size
def test_update_measurement_updates_peak_if_larger(self):
"""Should update peak if new size is larger."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=1024 * 1024 * 500,
peak_size_bytes=1024 * 1024 * 500,
quota_limit_mb=1000
)
new_size = 1024 * 1024 * 750 # 750 MB (larger than current peak)
with patch.object(usage, 'save'): # Mock save to avoid DB
usage.update_measurement(new_size)
assert usage.peak_size_bytes == new_size
def test_update_measurement_keeps_peak_if_smaller(self):
"""Should keep existing peak if new size is smaller."""
original_peak = 1024 * 1024 * 750 # 750 MB
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=original_peak,
peak_size_bytes=original_peak,
quota_limit_mb=1000
)
new_size = 1024 * 1024 * 500 # 500 MB (smaller than peak)
with patch.object(usage, 'save'): # Mock save to avoid DB
usage.update_measurement(new_size)
assert usage.peak_size_bytes == original_peak
assert usage.current_size_bytes == new_size
def test_update_measurement_with_table_sizes(self):
"""Should update table sizes if provided."""
usage = StorageUsage(
year=2024, month=6,
current_size_bytes=0,
peak_size_bytes=0,
quota_limit_mb=1000,
table_sizes={}
)
table_sizes = {
'users': 1024 * 1024 * 100,
'appointments': 1024 * 1024 * 200,
}
with patch.object(usage, 'save'): # Mock save to avoid DB
usage.update_measurement(1024 * 1024 * 300, table_sizes)
assert usage.table_sizes == table_sizes
class TestStorageServiceLimitRetrieval:
"""Tests for StorageService limit retrieval."""
def test_get_storage_quota_limit_returns_integer(self):
"""Should return integer limit from entitlements."""
mock_tenant = Mock()
with patch(
'smoothschedule.billing.services.storage.EntitlementService.get_limit',
return_value=1000
):
limit = StorageService.get_storage_quota_limit(mock_tenant)
assert limit == 1000
def test_get_storage_quota_limit_returns_zero_for_none(self):
"""Should return 0 (unlimited) when entitlement returns None."""
mock_tenant = Mock()
with patch(
'smoothschedule.billing.services.storage.EntitlementService.get_limit',
return_value=None
):
limit = StorageService.get_storage_quota_limit(mock_tenant)
assert limit == 0
class TestStorageServiceMeasureSchema:
"""Tests for StorageService.measure_schema_size."""
def test_measure_schema_size_returns_tuple(self):
"""Should return (total_size, table_sizes) tuple."""
mock_cursor = MagicMock()
mock_cursor.fetchall.return_value = [
('users', 1024 * 1024 * 100),
('appointments', 1024 * 1024 * 200),
]
with patch('smoothschedule.billing.services.storage.connection') as mock_conn:
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
total_size, table_sizes = StorageService.measure_schema_size('tenant_schema')
assert total_size == 1024 * 1024 * 300 # 300 MB total
assert table_sizes == {
'users': 1024 * 1024 * 100,
'appointments': 1024 * 1024 * 200,
}
def test_measure_schema_size_handles_empty_schema(self):
"""Should handle empty schema gracefully."""
mock_cursor = MagicMock()
mock_cursor.fetchall.return_value = []
with patch('smoothschedule.billing.services.storage.connection') as mock_conn:
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
total_size, table_sizes = StorageService.measure_schema_size('empty_schema')
assert total_size == 0
assert table_sizes == {}
def test_measure_schema_size_handles_exception(self):
"""Should return zeros on exception."""
with patch('smoothschedule.billing.services.storage.connection') as mock_conn:
mock_conn.cursor.return_value.__enter__.side_effect = Exception("DB error")
total_size, table_sizes = StorageService.measure_schema_size('bad_schema')
assert total_size == 0
assert table_sizes == {}
class TestStorageServiceGetStatus:
"""Tests for StorageService.get_storage_status."""
def test_get_storage_status_returns_named_tuple(self):
"""Should return StorageStatus named tuple with all fields."""
mock_tenant = Mock()
mock_tenant.schema_name = 'tenant_demo'
mock_usage = Mock()
mock_usage.year = 2024
mock_usage.month = 6
mock_usage.current_size_bytes = 1024 * 1024 * 500
mock_usage.current_size_mb = 500.0
mock_usage.current_size_gb = 0.488
mock_usage.peak_size_bytes = 1024 * 1024 * 600
mock_usage.peak_size_mb = 600.0
mock_usage.quota_limit_mb = 1000
mock_usage.quota_limit_gb = 0.977
mock_usage.is_unlimited = False
mock_usage.usage_percentage = 50.0
mock_usage.remaining_mb = 500.0
mock_usage.is_at_warning_threshold = False
mock_usage.is_over_quota = False
mock_usage.overage_mb = 0.0
mock_usage.overage_amount_cents = 0
mock_usage.warning_email_sent = False
mock_usage.last_measured_at = datetime(2024, 6, 15, 10, 0, 0, tzinfo=dt_timezone.utc)
mock_usage.table_sizes = {'users': 1000000}
with patch.object(
StorageService, 'get_current_billing_period', return_value=(2024, 6)
), patch.object(
StorageService, 'get_or_create_storage_usage', return_value=mock_usage
):
status = StorageService.get_storage_status(mock_tenant)
assert isinstance(status, StorageStatus)
assert status.year == 2024
assert status.month == 6
assert status.current_size_mb == 500.0
assert status.quota_limit_mb == 1000
assert status.is_unlimited is False
assert status.is_at_warning_threshold is False
class TestStorageStatusNamedTuple:
"""Tests for StorageStatus named tuple fields."""
def test_storage_status_includes_all_required_fields(self):
"""StorageStatus should include all required fields."""
fields = StorageStatus._fields
expected_fields = {
'year', 'month', 'current_size_bytes', 'current_size_mb',
'current_size_gb', 'peak_size_bytes', 'peak_size_mb',
'quota_limit_mb', 'quota_limit_gb', 'is_unlimited',
'usage_percentage', 'remaining_mb', 'is_at_warning_threshold',
'is_over_quota', 'overage_mb', 'overage_amount_cents',
'warning_email_sent', 'last_measured_at', 'table_sizes',
}
assert set(fields) == expected_fields
class TestStorageOveragePricing:
"""Tests for storage overage pricing constants."""
def test_overage_price_constant(self):
"""Should have correct pricing constant ($0.50 = 50 cents per GB)."""
assert StorageService.OVERAGE_PRICE_CENTS_PER_GB == 50
def test_warning_threshold_constant(self):
"""Should have correct warning threshold (90%)."""
assert StorageService.WARNING_THRESHOLD_PERCENT == 90
class TestStorageWarningEmail:
"""Tests for storage warning email functionality."""
def test_send_storage_warning_email_builds_correct_context(self):
"""Should build correct context for storage warning email."""
from smoothschedule.communication.messaging.email_service import EmailService
from smoothschedule.communication.messaging.email_types import EmailType
with patch(
'smoothschedule.communication.messaging.email_service.send_system_email'
) as mock_send:
mock_send.return_value = True
result = EmailService.send_storage_warning_email(
to_email='owner@business.com',
to_name='Business Owner',
business_name='My Business',
current_mb=900.0,
quota_limit_mb=1000,
remaining_mb=100.0,
percentage=90,
overage_price_cents_per_gb=50,
)
assert result is True
mock_send.assert_called_once()
# Verify the context
call_args = mock_send.call_args
assert call_args[1]['email_type'] == EmailType.STORAGE_WARNING
assert call_args[1]['to_email'] == 'owner@business.com'
context = call_args[1]['context']
assert context['owner_name'] == 'Business Owner'
assert context['business_name'] == 'My Business'
assert context['usage_percentage'] == 90
assert context['storage_used'] == '900.0 MB'
assert context['storage_limit'] == '1000 MB'
assert context['storage_remaining'] == '100.0 MB'
assert context['overage_price'] == '$0.50'
def test_send_storage_warning_email_formats_gb_sizes(self):
"""Should format sizes in GB when >= 1024 MB."""
from smoothschedule.communication.messaging.email_service import EmailService
with patch(
'smoothschedule.communication.messaging.email_service.send_system_email'
) as mock_send:
mock_send.return_value = True
EmailService.send_storage_warning_email(
to_email='owner@business.com',
to_name='Business Owner',
business_name='My Business',
current_mb=9216.0, # 9 GB
quota_limit_mb=10240, # 10 GB
remaining_mb=1024.0, # 1 GB
percentage=90,
overage_price_cents_per_gb=50,
)
context = mock_send.call_args[1]['context']
assert context['storage_used'] == '9.0 GB'
assert context['storage_limit'] == '10.0 GB'
assert context['storage_remaining'] == '1.0 GB'
def test_send_storage_warning_email_handles_failure(self):
"""Should return False when email fails to send."""
from smoothschedule.communication.messaging.email_service import EmailService
with patch(
'smoothschedule.communication.messaging.email_service.send_system_email'
) as mock_send:
mock_send.return_value = False
result = EmailService.send_storage_warning_email(
to_email='owner@business.com',
to_name='Business Owner',
business_name='My Business',
current_mb=900.0,
quota_limit_mb=1000,
remaining_mb=100.0,
percentage=90,
overage_price_cents_per_gb=50,
)
assert result is False
class TestStorageFormatSize:
"""Tests for StorageService.format_size helper."""
def test_format_size_bytes(self):
"""Should format bytes correctly."""
assert StorageService.format_size(500) == "500 bytes"
def test_format_size_kb(self):
"""Should format KB correctly."""
assert StorageService.format_size(1024 * 2) == "2.0 KB"
def test_format_size_mb(self):
"""Should format MB correctly."""
assert StorageService.format_size(1024 * 1024 * 100) == "100.0 MB"
def test_format_size_gb(self):
"""Should format GB correctly."""
assert StorageService.format_size(1024 * 1024 * 1024 * 2) == "2.0 GB"

View File

@@ -2,12 +2,16 @@
Tests for billing Celery tasks.
"""
from datetime import timedelta
from unittest.mock import Mock, patch
from unittest.mock import Mock, patch, MagicMock
import pytest
from django.utils import timezone
from smoothschedule.billing.models import TenantCustomTier
from smoothschedule.billing.tasks import check_subscription_grace_periods
from smoothschedule.billing.tasks import (
check_subscription_grace_periods,
measure_all_tenant_storage,
measure_tenant_storage,
)
class TestCheckSubscriptionGracePeriods:
@@ -204,3 +208,100 @@ class TestCheckSubscriptionGracePeriods:
assert result['grace_periods_started'] == 1 # Only ct2 succeeded
assert len(result['errors']) == 1
assert "Database error" in result['errors'][0]
class TestMeasureAllTenantStorage:
"""Tests for measure_all_tenant_storage Celery task."""
def test_task_returns_measurement_results(self):
"""Should return summary of measurements taken."""
mock_usage1 = Mock()
mock_usage1.warning_email_sent = False
mock_usage1.warning_email_sent_at = None
mock_usage2 = Mock()
mock_usage2.warning_email_sent = True
mock_usage2.warning_email_sent_at = timezone.now()
with patch(
'smoothschedule.billing.services.storage.StorageService.measure_all_tenants',
return_value=[mock_usage1, mock_usage2]
):
result = measure_all_tenant_storage()
assert result['tenants_measured'] == 2
assert result['warnings_sent'] == 1
assert result['errors'] == []
def test_task_handles_errors_gracefully(self):
"""Should log errors and return error summary."""
with patch(
'smoothschedule.billing.services.storage.StorageService.measure_all_tenants',
side_effect=Exception("Database connection failed")
):
result = measure_all_tenant_storage()
assert result['tenants_measured'] == 0
assert len(result['errors']) == 1
assert "Database connection failed" in result['errors'][0]
class TestMeasureTenantStorage:
"""Tests for measure_tenant_storage Celery task."""
def test_task_returns_measurement_data(self):
"""Should return storage measurement for specific tenant."""
mock_tenant = Mock()
mock_tenant.name = "Test Business"
mock_usage = Mock()
mock_usage.current_size_mb = 500.0
mock_usage.quota_limit_mb = 1000
mock_usage.usage_percentage = 50.0
mock_usage.is_over_quota = False
with patch(
'smoothschedule.identity.core.models.Tenant.objects.get',
return_value=mock_tenant
), patch(
'smoothschedule.billing.services.storage.StorageService.measure_tenant_storage',
return_value=mock_usage
):
result = measure_tenant_storage(tenant_id=1)
assert result['tenant_id'] == 1
assert result['tenant_name'] == "Test Business"
assert result['current_size_mb'] == 500.0
assert result['quota_limit_mb'] == 1000
assert result['usage_percentage'] == 50.0
assert result['is_over_quota'] is False
def test_task_handles_tenant_not_found(self):
"""Should return error when tenant doesn't exist."""
from smoothschedule.identity.core.models import Tenant
with patch.object(
Tenant.objects, 'get',
side_effect=Tenant.DoesNotExist
):
result = measure_tenant_storage(tenant_id=999)
assert 'error' in result
assert '999' in result['error']
def test_task_handles_measurement_error(self):
"""Should return error when measurement fails."""
mock_tenant = Mock()
mock_tenant.name = "Test Business"
with patch(
'smoothschedule.identity.core.models.Tenant.objects.get',
return_value=mock_tenant
), patch(
'smoothschedule.billing.services.storage.StorageService.measure_tenant_storage',
side_effect=Exception("Schema not found")
):
result = measure_tenant_storage(tenant_id=1)
assert 'error' in result
assert "Schema not found" in result['error']

View File

@@ -3,6 +3,14 @@ Default Email Templates
Provides default Puck templates for all email types.
These are used when a tenant hasn't customized their templates.
TODO: Implement PUCK Platform email templates for platform-level communications:
- Tenant invitation emails (currently using Django template in platform_admin/templates/)
- Platform announcements
- Subscription billing notifications
- Trial expiration warnings
- Plan upgrade/downgrade confirmations
These should be visually editable like tenant templates but managed at the platform level.
"""
DEFAULT_TEMPLATES = {
@@ -925,6 +933,180 @@ DEFAULT_TEMPLATES = {
'root': {}
}
},
# =========================================================================
# Quota Warning
# =========================================================================
'quota_warning': {
'subject_template': 'Approaching Appointment Limit - {{ usage_percentage }}% Used',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': 'SmoothSchedule',
'preheader': 'Your appointment quota is almost full'
}
},
{
'type': 'EmailHeading',
'props': {
'text': 'Appointment Quota Warning',
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': "Hi {{ owner_name }},\n\nYou've used {{ usage_percentage }}% of your monthly appointment quota for {{ business_name }}. Here's your current usage:",
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': '<strong>Appointments Used:</strong> {{ appointments_used }} of {{ appointments_limit }}<br><strong>Billing Period:</strong> {{ billing_period }}<br><strong>Remaining:</strong> {{ appointments_remaining }} appointments',
'backgroundColor': '#fef3c7'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Once you exceed your quota, additional appointments will be billed at {{ overage_price }} each.',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'md'}
},
{
'type': 'EmailText',
'props': {
'content': 'Consider upgrading your plan to get more appointments and avoid overage charges.',
'align': 'center'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'Upgrade Plan',
'href': '{{ upgrade_link }}',
'variant': 'primary',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'sm'}
},
{
'type': 'EmailButton',
'props': {
'text': 'View Usage Details',
'href': '{{ usage_link }}',
'variant': 'secondary',
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': 'support@smoothschedule.com'
}
}
],
'root': {}
}
},
# =========================================================================
# Storage Warning
# =========================================================================
'storage_warning': {
'subject_template': 'Approaching Storage Limit - {{ usage_percentage }}% Used',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': 'SmoothSchedule',
'preheader': 'Your database storage quota is almost full'
}
},
{
'type': 'EmailHeading',
'props': {
'text': 'Storage Quota Warning',
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': "Hi {{ owner_name }},\n\nYou've used {{ usage_percentage }}% of your database storage quota for {{ business_name }}. Here's your current usage:",
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': '<strong>Storage Used:</strong> {{ storage_used }} of {{ storage_limit }}<br><strong>Billing Period:</strong> {{ billing_period }}<br><strong>Remaining:</strong> {{ storage_remaining }}',
'backgroundColor': '#fef3c7'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Once you exceed your quota, additional storage will be billed at {{ overage_price }} per GB.',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'md'}
},
{
'type': 'EmailText',
'props': {
'content': 'Consider upgrading your plan to get more storage and avoid overage charges.',
'align': 'center'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'Upgrade Plan',
'href': '{{ upgrade_link }}',
'variant': 'primary',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'sm'}
},
{
'type': 'EmailButton',
'props': {
'text': 'View Usage Details',
'href': '{{ usage_link }}',
'variant': 'secondary',
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': 'support@smoothschedule.com'
}
}
],
'root': {}
}
},
}

View File

@@ -17,6 +17,20 @@ Usage:
'appointment_time': '10:00 AM',
}
)
# Using the EmailService class
from smoothschedule.communication.messaging.email_service import EmailService
EmailService.send_quota_warning_email(
to_email='owner@business.com',
to_name='Business Owner',
business_name='My Business',
current_count=90,
quota_limit=100,
remaining=10,
percentage=90,
overage_price_cents=10,
)
"""
import logging
from typing import Dict, Any, Optional, List
@@ -293,3 +307,140 @@ def send_html_email(
html_message=html_message,
fail_silently=fail_silently,
)
class EmailService:
"""
Service class for sending various system emails.
Provides class methods for different email types with appropriate
context construction.
"""
@classmethod
def send_quota_warning_email(
cls,
to_email: str,
to_name: str,
business_name: str,
current_count: int,
quota_limit: int,
remaining: int,
percentage: int,
overage_price_cents: int,
) -> bool:
"""
Send a quota warning email to a business owner/manager.
Args:
to_email: Recipient email address
to_name: Recipient name (for personalization)
business_name: Business name
current_count: Current appointment count
quota_limit: Maximum appointments allowed
remaining: Remaining appointments before overage
percentage: Current usage percentage (e.g., 90)
overage_price_cents: Price per overage appointment in cents
Returns:
True if email sent successfully, False otherwise
"""
from django.conf import settings
# Format billing period for display
from django.utils import timezone
now = timezone.now()
billing_period = now.strftime("%B %Y")
# Build context for template
context = {
"owner_name": to_name,
"business_name": business_name,
"usage_percentage": percentage,
"appointments_used": current_count,
"appointments_limit": quota_limit,
"appointments_remaining": remaining,
"billing_period": billing_period,
"overage_price": f"${overage_price_cents / 100:.2f}",
"upgrade_link": f"{getattr(settings, 'FRONTEND_URL', 'https://smoothschedule.com')}/settings/billing",
"usage_link": f"{getattr(settings, 'FRONTEND_URL', 'https://smoothschedule.com')}/settings/billing/usage",
}
return send_system_email(
email_type=EmailType.QUOTA_WARNING,
to_email=to_email,
context=context,
fail_silently=True,
)
@classmethod
def send_storage_warning_email(
cls,
to_email: str,
to_name: str,
business_name: str,
current_mb: float,
quota_limit_mb: int,
remaining_mb: float,
percentage: int,
overage_price_cents_per_gb: int,
) -> bool:
"""
Send a storage warning email to a business owner/manager.
Args:
to_email: Recipient email address
to_name: Recipient name (for personalization)
business_name: Business name
current_mb: Current storage usage in MB
quota_limit_mb: Storage limit in MB
remaining_mb: Remaining storage before overage
percentage: Current usage percentage (e.g., 90)
overage_price_cents_per_gb: Price per GB overage in cents
Returns:
True if email sent successfully, False otherwise
"""
from django.conf import settings
# Format billing period for display
from django.utils import timezone
now = timezone.now()
billing_period = now.strftime("%B %Y")
# Format sizes for display
if quota_limit_mb >= 1024:
quota_display = f"{quota_limit_mb / 1024:.1f} GB"
else:
quota_display = f"{quota_limit_mb} MB"
if current_mb >= 1024:
current_display = f"{current_mb / 1024:.1f} GB"
else:
current_display = f"{current_mb:.1f} MB"
if remaining_mb >= 1024:
remaining_display = f"{remaining_mb / 1024:.1f} GB"
else:
remaining_display = f"{remaining_mb:.1f} MB"
# Build context for template
context = {
"owner_name": to_name,
"business_name": business_name,
"usage_percentage": percentage,
"storage_used": current_display,
"storage_limit": quota_display,
"storage_remaining": remaining_display,
"billing_period": billing_period,
"overage_price": f"${overage_price_cents_per_gb / 100:.2f}",
"upgrade_link": f"{getattr(settings, 'FRONTEND_URL', 'https://smoothschedule.com')}/settings/billing",
"usage_link": f"{getattr(settings, 'FRONTEND_URL', 'https://smoothschedule.com')}/settings/billing/usage",
}
return send_system_email(
email_type=EmailType.STORAGE_WARNING,
to_email=to_email,
context=context,
fail_silently=True,
)

View File

@@ -87,6 +87,29 @@ TICKET_TAGS: Dict[str, str] = {
'assignee_name': 'Name of assigned staff',
}
# Billing/quota-related tags
BILLING_TAGS: Dict[str, str] = {
# Owner info
'owner_name': 'Business owner/recipient name',
# Appointment quota
'usage_percentage': 'Current usage percentage (e.g., 90)',
'appointments_used': 'Number of appointments used this period',
'appointments_limit': 'Maximum appointments allowed',
'appointments_remaining': 'Remaining appointments before overage',
'billing_period': 'Current billing period (e.g., January 2025)',
'overage_price': 'Price per overage item (formatted with $)',
# Storage quota
'storage_used': 'Current storage usage (formatted, e.g., 450 MB)',
'storage_limit': 'Storage limit (formatted, e.g., 500 MB)',
'storage_remaining': 'Remaining storage (formatted)',
# Action links
'upgrade_link': 'Link to upgrade subscription plan',
'usage_link': 'Link to view usage details',
}
# =============================================================================
# Tag Mapping by Email Type
@@ -99,6 +122,7 @@ def get_tags_for_category(category: str) -> Dict[str, str]:
'contract': CONTRACT_TAGS,
'payment': PAYMENT_TAGS,
'ticket': TICKET_TAGS,
'billing': BILLING_TAGS,
'welcome': {}, # Only base tags
}
return category_tags.get(category, {})
@@ -253,6 +277,8 @@ def get_tag_info_for_email_type(email_type: EmailType) -> List[Dict[str, str]]:
category = 'Payment'
elif tag in TICKET_TAGS:
category = 'Support Ticket'
elif tag in BILLING_TAGS:
category = 'Billing & Quota'
else:
category = 'Other'
@@ -284,6 +310,7 @@ def get_all_tag_info() -> List[Dict[str, str]]:
(CONTRACT_TAGS, 'Contract'),
(PAYMENT_TAGS, 'Payment'),
(TICKET_TAGS, 'Support Ticket'),
(BILLING_TAGS, 'Billing & Quota'),
]
seen_tags = set()

View File

@@ -79,6 +79,15 @@ class EmailType(str, Enum):
TICKET_RESOLVED = 'ticket_resolved'
"""Sent when ticket is marked as resolved."""
# ==========================================================================
# Billing / Quota
# ==========================================================================
QUOTA_WARNING = 'quota_warning'
"""Sent to business owner when appointment quota reaches 90%."""
STORAGE_WARNING = 'storage_warning'
"""Sent to business owner when database storage reaches 90%."""
# ==========================================================================
# Utility Methods
# ==========================================================================
@@ -112,6 +121,9 @@ class EmailType(str, Enum):
cls.TICKET_ASSIGNED: 'ticket',
cls.TICKET_REPLY: 'ticket',
cls.TICKET_RESOLVED: 'ticket',
# Billing
cls.QUOTA_WARNING: 'billing',
cls.STORAGE_WARNING: 'billing',
}
return categories.get(email_type, 'other')
@@ -134,6 +146,8 @@ class EmailType(str, Enum):
cls.TICKET_ASSIGNED: 'Ticket Assigned',
cls.TICKET_REPLY: 'Ticket Reply',
cls.TICKET_RESOLVED: 'Ticket Resolved',
cls.QUOTA_WARNING: 'Quota Warning',
cls.STORAGE_WARNING: 'Storage Warning',
}
return display_names.get(email_type, email_type.value.replace('_', ' ').title())
@@ -156,6 +170,8 @@ class EmailType(str, Enum):
cls.TICKET_ASSIGNED: 'Notifies staff when a support ticket is assigned',
cls.TICKET_REPLY: 'Sent when someone replies to a support ticket',
cls.TICKET_RESOLVED: 'Sent when a support ticket is resolved',
cls.QUOTA_WARNING: 'Sent to business owner when appointment quota reaches 90%',
cls.STORAGE_WARNING: 'Sent to business owner when database storage reaches 90%',
}
return descriptions.get(email_type, '')

View File

@@ -250,35 +250,6 @@ class User(AbstractUser):
return True
return False
def can_approve_plugins(self):
"""
Check if user can approve/publish plugins to marketplace.
Only platform users with explicit permission (granted by superuser).
"""
# Superusers can always approve
if self.role == self.Role.SUPERUSER:
return True
# Platform managers/support can approve if granted permission
if self.role in [self.Role.PLATFORM_MANAGER, self.Role.PLATFORM_SUPPORT]:
return self.permissions.get('can_approve_plugins', False)
# All others cannot approve
return False
def can_whitelist_urls(self):
"""
Check if user can whitelist URLs for plugin API calls.
Only platform users with explicit permission (granted by superuser).
Can whitelist both per-user and platform-wide URLs.
"""
# Superusers can always whitelist
if self.role == self.Role.SUPERUSER:
return True
# Platform managers/support can whitelist if granted permission
if self.role in [self.Role.PLATFORM_MANAGER, self.Role.PLATFORM_SUPPORT]:
return self.permissions.get('can_whitelist_urls', False)
# All others cannot whitelist
return False
def can_self_approve_time_off(self):
"""
Check if user can self-approve time off requests.

View File

@@ -3,7 +3,7 @@ Unit tests for the User model.
Tests cover:
1. Role-related methods (is_platform_user, is_tenant_user, can_invite_staff, etc.)
2. Permission checking methods (can_access_tickets, can_approve_plugins, etc.)
2. Permission checking methods (can_access_tickets, can_self_approve_time_off, etc.)
3. Property methods (full_name)
4. The save() method validation logic (tenant requirements for different roles)
@@ -198,66 +198,6 @@ class TestCanAccessTickets:
assert user.can_access_tickets() is True
# =============================================================================
# Plugin Approval Permission Tests
# =============================================================================
class TestCanApprovePlugins:
"""Test can_approve_plugins() method."""
def test_returns_true_for_superuser(self):
user = create_user_instance(User.Role.SUPERUSER)
assert user.can_approve_plugins() is True
def test_returns_true_for_platform_manager_with_permission(self):
user = create_user_instance(User.Role.PLATFORM_MANAGER, permissions={'can_approve_plugins': True})
assert user.can_approve_plugins() is True
def test_returns_false_for_platform_manager_without_permission(self):
user = create_user_instance(User.Role.PLATFORM_MANAGER)
assert user.can_approve_plugins() is False
def test_returns_true_for_platform_support_with_permission(self):
user = create_user_instance(User.Role.PLATFORM_SUPPORT, permissions={'can_approve_plugins': True})
assert user.can_approve_plugins() is True
def test_returns_false_for_platform_support_without_permission(self):
user = create_user_instance(User.Role.PLATFORM_SUPPORT)
assert user.can_approve_plugins() is False
def test_returns_false_for_tenant_owner(self):
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_approve_plugins() is False
# =============================================================================
# URL Whitelist Permission Tests
# =============================================================================
class TestCanWhitelistUrls:
"""Test can_whitelist_urls() method."""
def test_returns_true_for_superuser(self):
user = create_user_instance(User.Role.SUPERUSER)
assert user.can_whitelist_urls() is True
def test_returns_true_for_platform_manager_with_permission(self):
user = create_user_instance(User.Role.PLATFORM_MANAGER, permissions={'can_whitelist_urls': True})
assert user.can_whitelist_urls() is True
def test_returns_false_for_platform_manager_without_permission(self):
user = create_user_instance(User.Role.PLATFORM_MANAGER)
assert user.can_whitelist_urls() is False
def test_returns_true_for_platform_support_with_permission(self):
user = create_user_instance(User.Role.PLATFORM_SUPPORT, permissions={'can_whitelist_urls': True})
assert user.can_whitelist_urls() is True
def test_returns_false_for_tenant_owner(self):
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_whitelist_urls() is False
# =============================================================================
# Time Off Self-Approval Tests
# =============================================================================

View File

@@ -268,99 +268,6 @@ class TestCanAccessTickets:
assert user.can_access_tickets() is True
# =============================================================================
# Plugin Approval Permission Tests
# =============================================================================
class TestCanApprovePlugins:
"""Test can_approve_plugins() method."""
def test_returns_true_for_superuser(self):
user = create_user_instance(User.Role.SUPERUSER)
assert user.can_approve_plugins() is True
def test_returns_true_for_platform_manager_with_permission(self):
user = create_user_instance(
User.Role.PLATFORM_MANAGER,
permissions={'can_approve_plugins': True}
)
assert user.can_approve_plugins() is True
def test_returns_false_for_platform_manager_without_permission(self):
user = create_user_instance(User.Role.PLATFORM_MANAGER)
assert user.can_approve_plugins() is False
def test_returns_true_for_platform_support_with_permission(self):
user = create_user_instance(
User.Role.PLATFORM_SUPPORT,
permissions={'can_approve_plugins': True}
)
assert user.can_approve_plugins() is True
def test_returns_false_for_platform_support_without_permission(self):
user = create_user_instance(User.Role.PLATFORM_SUPPORT)
assert user.can_approve_plugins() is False
def test_returns_false_for_platform_sales(self):
user = create_user_instance(User.Role.PLATFORM_SALES)
assert user.can_approve_plugins() is False
def test_returns_false_for_tenant_owner(self):
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_approve_plugins() is False
def test_returns_false_for_customer(self):
user = create_user_instance(User.Role.CUSTOMER)
assert user.can_approve_plugins() is False
# =============================================================================
# URL Whitelist Permission Tests
# =============================================================================
class TestCanWhitelistUrls:
"""Test can_whitelist_urls() method."""
def test_returns_true_for_superuser(self):
user = create_user_instance(User.Role.SUPERUSER)
assert user.can_whitelist_urls() is True
def test_returns_true_for_platform_manager_with_permission(self):
user = create_user_instance(
User.Role.PLATFORM_MANAGER,
permissions={'can_whitelist_urls': True}
)
assert user.can_whitelist_urls() is True
def test_returns_false_for_platform_manager_without_permission(self):
user = create_user_instance(User.Role.PLATFORM_MANAGER)
assert user.can_whitelist_urls() is False
def test_returns_true_for_platform_support_with_permission(self):
user = create_user_instance(
User.Role.PLATFORM_SUPPORT,
permissions={'can_whitelist_urls': True}
)
assert user.can_whitelist_urls() is True
def test_returns_false_for_platform_support_without_permission(self):
user = create_user_instance(User.Role.PLATFORM_SUPPORT)
assert user.can_whitelist_urls() is False
def test_returns_false_for_platform_sales(self):
user = create_user_instance(User.Role.PLATFORM_SALES)
assert user.can_whitelist_urls() is False
def test_returns_false_for_tenant_owner(self):
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_whitelist_urls() is False
def test_returns_false_for_customer(self):
user = create_user_instance(User.Role.CUSTOMER)
assert user.can_whitelist_urls() is False
# =============================================================================
# Time Off Self-Approval Tests
# =============================================================================

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.8 on 2025-12-31 14:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('platform_admin', '0014_add_routing_mode_and_email_models'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PlatformEmailTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email_type', models.CharField(choices=[('tenant_invitation', 'Tenant Invitation'), ('trial_expiration_warning', 'Trial Expiration Warning'), ('trial_expired', 'Trial Expired'), ('plan_upgrade', 'Plan Upgrade Confirmation'), ('plan_downgrade', 'Plan Downgrade Confirmation'), ('subscription_cancelled', 'Subscription Cancelled'), ('payment_failed', 'Payment Failed'), ('payment_succeeded', 'Payment Succeeded')], help_text='The type of platform email this template is for', max_length=50, unique=True)),
('subject_template', models.CharField(help_text='Email subject line with {{ tag }} placeholders', max_length=500)),
('puck_data', models.JSONField(default=dict, help_text='Puck editor JSON data for the email body')),
('is_active', models.BooleanField(default=True, help_text='Whether this template is currently in use')),
('is_customized', models.BooleanField(default=False, help_text='Whether this template has been customized from the default')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, help_text='User who created/last modified this template', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='platform_email_templates_created', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Platform Email Template',
'verbose_name_plural': 'Platform Email Templates',
'ordering': ['email_type'],
},
),
]

View File

@@ -0,0 +1,42 @@
# Generated by Django 5.2.8 on 2026-01-01 02:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('platform_admin', '0015_add_platform_email_template'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='platformemailtemplate',
name='email_type',
field=models.CharField(choices=[('tenant_invitation', 'Tenant Invitation'), ('trial_expiration_warning', 'Trial Expiration Warning'), ('trial_expired', 'Trial Expired'), ('plan_upgrade', 'Plan Upgrade Confirmation'), ('plan_downgrade', 'Plan Downgrade Confirmation'), ('subscription_cancelled', 'Subscription Cancelled'), ('payment_failed', 'Payment Failed'), ('payment_succeeded', 'Payment Succeeded'), ('platform_staff_invitation', 'Platform Staff Invitation'), ('platform_staff_welcome', 'Platform Staff Welcome')], help_text='The type of platform email this template is for', max_length=50, unique=True),
),
migrations.CreateModel(
name='PlatformStaffInvitation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(help_text='Email address to send invitation to', max_length=254)),
('role', models.CharField(choices=[('platform_manager', 'Platform Manager'), ('platform_support', 'Platform Support')], default='platform_support', help_text='Role the invited user will have', max_length=20)),
('token', models.CharField(max_length=64, unique=True)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('ACCEPTED', 'Accepted'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('accepted_at', models.DateTimeField(blank=True, null=True)),
('permissions', models.JSONField(blank=True, default=dict, help_text='Platform permission settings for the invited user')),
('personal_message', models.TextField(blank=True, help_text='Optional personal message to include in the invitation email')),
('accepted_user', models.ForeignKey(blank=True, help_text='User account created/activated when invitation was accepted', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='platform_staff_invitation_accepted', to=settings.AUTH_USER_MODEL)),
('invited_by', models.ForeignKey(help_text='User who sent the invitation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='platform_staff_invitations_sent', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['token'], name='platform_ad_token_e94488_idx'), models.Index(fields=['email', 'status'], name='platform_ad_email_f8e580_idx'), models.Index(fields=['status', 'expires_at'], name='platform_ad_status_276620_idx')],
},
),
]

View File

@@ -723,3 +723,383 @@ class PlatformEmailAddress(models.Model):
'username': self.email_address,
'password': self.password,
}
class PlatformEmailType(models.TextChoices):
"""
Email types for platform-level communications.
These are sent from the platform to tenants/owners, not tenant-to-customer.
"""
TENANT_INVITATION = 'tenant_invitation', _('Tenant Invitation')
TRIAL_EXPIRATION_WARNING = 'trial_expiration_warning', _('Trial Expiration Warning')
TRIAL_EXPIRED = 'trial_expired', _('Trial Expired')
PLAN_UPGRADE = 'plan_upgrade', _('Plan Upgrade Confirmation')
PLAN_DOWNGRADE = 'plan_downgrade', _('Plan Downgrade Confirmation')
SUBSCRIPTION_CANCELLED = 'subscription_cancelled', _('Subscription Cancelled')
PAYMENT_FAILED = 'payment_failed', _('Payment Failed')
PAYMENT_SUCCEEDED = 'payment_succeeded', _('Payment Succeeded')
# Staff onboarding emails
PLATFORM_STAFF_INVITATION = 'platform_staff_invitation', _('Platform Staff Invitation')
PLATFORM_STAFF_WELCOME = 'platform_staff_welcome', _('Platform Staff Welcome')
@classmethod
def get_display_name(cls, email_type: str) -> str:
"""Get human-readable display name for an email type."""
display_names = {
cls.TENANT_INVITATION: 'Tenant Invitation',
cls.TRIAL_EXPIRATION_WARNING: 'Trial Expiration Warning',
cls.TRIAL_EXPIRED: 'Trial Expired',
cls.PLAN_UPGRADE: 'Plan Upgrade Confirmation',
cls.PLAN_DOWNGRADE: 'Plan Downgrade Confirmation',
cls.SUBSCRIPTION_CANCELLED: 'Subscription Cancelled',
cls.PAYMENT_FAILED: 'Payment Failed',
cls.PAYMENT_SUCCEEDED: 'Payment Succeeded',
cls.PLATFORM_STAFF_INVITATION: 'Platform Staff Invitation',
cls.PLATFORM_STAFF_WELCOME: 'Platform Staff Welcome',
}
return display_names.get(email_type, email_type.replace('_', ' ').title())
@classmethod
def get_description(cls, email_type: str) -> str:
"""Get description of when this email type is sent."""
descriptions = {
cls.TENANT_INVITATION: 'Sent when inviting a new business owner to create their tenant on the platform.',
cls.TRIAL_EXPIRATION_WARNING: 'Sent a few days before a trial period expires to encourage subscription.',
cls.TRIAL_EXPIRED: 'Sent when a trial period has expired and the business needs to subscribe.',
cls.PLAN_UPGRADE: 'Sent when a business successfully upgrades their subscription plan.',
cls.PLAN_DOWNGRADE: 'Sent when a business downgrades their subscription plan.',
cls.SUBSCRIPTION_CANCELLED: 'Sent when a business cancels their subscription.',
cls.PAYMENT_FAILED: 'Sent when a subscription payment fails and action is needed.',
cls.PAYMENT_SUCCEEDED: 'Sent as a receipt after a successful subscription payment.',
cls.PLATFORM_STAFF_INVITATION: 'Sent when inviting a new platform staff member (manager or support).',
cls.PLATFORM_STAFF_WELCOME: 'Sent after a platform staff member completes their account setup.',
}
return descriptions.get(email_type, '')
@classmethod
def get_category(cls, email_type: str) -> str:
"""Get the category for an email type."""
categories = {
cls.TENANT_INVITATION: 'invitation',
cls.TRIAL_EXPIRATION_WARNING: 'trial',
cls.TRIAL_EXPIRED: 'trial',
cls.PLAN_UPGRADE: 'subscription',
cls.PLAN_DOWNGRADE: 'subscription',
cls.SUBSCRIPTION_CANCELLED: 'subscription',
cls.PAYMENT_FAILED: 'billing',
cls.PAYMENT_SUCCEEDED: 'billing',
cls.PLATFORM_STAFF_INVITATION: 'staff',
cls.PLATFORM_STAFF_WELCOME: 'staff',
}
return categories.get(email_type, 'other')
class PlatformEmailTemplate(models.Model):
"""
Platform-level email templates using the Puck visual editor.
These templates are for emails sent from the platform to tenant owners,
such as invitations, billing notifications, and subscription updates.
Unlike PuckEmailTemplate which is tenant-scoped, this model lives in the
public schema and has only one template per email type globally.
"""
email_type = models.CharField(
max_length=50,
choices=PlatformEmailType.choices,
unique=True,
help_text="The type of platform email this template is for"
)
subject_template = models.CharField(
max_length=500,
help_text="Email subject line with {{ tag }} placeholders"
)
puck_data = models.JSONField(
default=dict,
help_text="Puck editor JSON data for the email body"
)
is_active = models.BooleanField(
default=True,
help_text="Whether this template is currently in use"
)
# Track customization
is_customized = models.BooleanField(
default=False,
help_text="Whether this template has been customized from the default"
)
# Metadata
created_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='platform_email_templates_created',
help_text="User who created/last modified this template"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'platform_admin'
ordering = ['email_type']
verbose_name = 'Platform Email Template'
verbose_name_plural = 'Platform Email Templates'
def __str__(self):
return f"Platform Template: {self.get_email_type_display()}"
@property
def display_name(self) -> str:
"""Get human-readable display name."""
return PlatformEmailType.get_display_name(self.email_type)
@property
def description(self) -> str:
"""Get description of when this email is sent."""
return PlatformEmailType.get_description(self.email_type)
@property
def category(self) -> str:
"""Get the category for this email type."""
return PlatformEmailType.get_category(self.email_type)
def reset_to_default(self):
"""Reset this template to its default content."""
from .platform_email_templates import get_default_platform_template
default = get_default_platform_template(self.email_type)
self.subject_template = default['subject_template']
self.puck_data = default['puck_data']
self.is_customized = False
self.save()
def render(self, context: dict) -> dict:
"""
Render the template with the given context.
Args:
context: Dictionary of tag values to substitute
Returns:
Dict with 'subject', 'html', and 'text' keys
"""
from smoothschedule.communication.messaging.email_renderer import (
render_subject,
render_email_html,
render_email_plaintext,
)
return {
'subject': render_subject(self.subject_template, context),
'html': render_email_html(self.puck_data, context),
'text': render_email_plaintext(self.puck_data, context),
}
@classmethod
def get_or_create_for_type(cls, email_type: str) -> 'PlatformEmailTemplate':
"""
Get the template for an email type, creating from defaults if needed.
Args:
email_type: The PlatformEmailType value
Returns:
PlatformEmailTemplate instance
"""
from .platform_email_templates import get_default_platform_template
template, created = cls.objects.get_or_create(
email_type=email_type,
defaults={
**get_default_platform_template(email_type),
'is_customized': False,
}
)
return template
@classmethod
def ensure_all_templates_exist(cls):
"""
Ensure templates exist for all platform email types.
Creates any missing templates from defaults.
"""
from .platform_email_templates import get_default_platform_template
for email_type in PlatformEmailType.values:
cls.objects.get_or_create(
email_type=email_type,
defaults={
**get_default_platform_template(email_type),
'is_customized': False,
}
)
class PlatformStaffInvitation(models.Model):
"""
Invitation for new platform staff members (platform_manager, platform_support).
Flow:
1. Superuser creates invitation with email and role
2. System sends email with unique token link
3. Invitee clicks link, sets password, and their account is activated
"""
class Status(models.TextChoices):
PENDING = 'PENDING', _('Pending')
ACCEPTED = 'ACCEPTED', _('Accepted')
EXPIRED = 'EXPIRED', _('Expired')
CANCELLED = 'CANCELLED', _('Cancelled')
class StaffRole(models.TextChoices):
PLATFORM_MANAGER = 'platform_manager', _('Platform Manager')
PLATFORM_SUPPORT = 'platform_support', _('Platform Support')
# Invitation target
email = models.EmailField(help_text="Email address to send invitation to")
role = models.CharField(
max_length=20,
choices=StaffRole.choices,
default=StaffRole.PLATFORM_SUPPORT,
help_text="Role the invited user will have"
)
# Invitation metadata
invited_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
related_name='platform_staff_invitations_sent',
help_text="User who sent the invitation"
)
# Token for secure acceptance
token = models.CharField(max_length=64, unique=True)
# Status tracking
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
accepted_at = models.DateTimeField(null=True, blank=True)
# Link to created user (after acceptance)
accepted_user = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='platform_staff_invitation_accepted',
help_text="User account created/activated when invitation was accepted"
)
# Permissions configuration (stored as JSON for flexibility)
# Currently unused but kept for future extensibility
permissions = models.JSONField(
default=dict,
blank=True,
help_text="Platform permission settings for the invited user"
)
# Personal message to include in email
personal_message = models.TextField(
blank=True,
help_text="Optional personal message to include in the invitation email"
)
class Meta:
app_label = 'platform_admin'
ordering = ['-created_at']
indexes = [
models.Index(fields=['token']),
models.Index(fields=['email', 'status']),
models.Index(fields=['status', 'expires_at']),
]
def __str__(self):
return f"Platform staff invitation for {self.email} ({self.get_status_display()})"
def save(self, *args, **kwargs):
if not self.token:
self.token = secrets.token_urlsafe(32)
if not self.expires_at:
# Default expiration: 7 days
self.expires_at = timezone.now() + timedelta(days=7)
super().save(*args, **kwargs)
def is_valid(self):
"""Check if invitation can still be accepted"""
if self.status != self.Status.PENDING:
return False
if timezone.now() > self.expires_at:
return False
return True
def accept(self, user):
"""Mark invitation as accepted and link to user"""
self.status = self.Status.ACCEPTED
self.accepted_at = timezone.now()
self.accepted_user = user
self.save()
def cancel(self):
"""Cancel a pending invitation"""
if self.status == self.Status.PENDING:
self.status = self.Status.CANCELLED
self.save()
def get_role_display_name(self):
"""Get human-readable role name"""
role_names = {
self.StaffRole.PLATFORM_MANAGER: 'Platform Manager',
self.StaffRole.PLATFORM_SUPPORT: 'Platform Support',
}
return role_names.get(self.role, self.role)
def get_role_description(self):
"""Get role description for email templates"""
descriptions = {
self.StaffRole.PLATFORM_MANAGER: (
'Platform Managers have access to manage tenants, '
'view analytics, and oversee platform operations.'
),
self.StaffRole.PLATFORM_SUPPORT: (
'Platform Support staff can respond to support tickets, '
'help users troubleshoot issues, and access basic tenant information.'
),
}
return descriptions.get(self.role, '')
@classmethod
def create_invitation(cls, email, role, invited_by, permissions=None,
personal_message=''):
"""
Create a new platform staff invitation, cancelling any existing
pending invitations for the same email.
"""
# Cancel existing pending invitations for this email
cls.objects.filter(
email=email,
status=cls.Status.PENDING
).update(status=cls.Status.CANCELLED)
# Create new invitation
return cls.objects.create(
email=email,
role=role,
invited_by=invited_by,
permissions=permissions or {},
personal_message=personal_message,
)

View File

@@ -0,0 +1,820 @@
"""
Platform Email Templates
Default Puck templates for platform-level email communications.
These are used for emails sent from the platform to tenant owners,
such as invitations, billing notifications, and subscription updates.
"""
# =============================================================================
# Platform-Specific Tags
# =============================================================================
PLATFORM_TAGS = {
# Platform info
'platform_name': 'Platform name (SmoothSchedule)',
'platform_url': 'Platform website URL',
'platform_support_email': 'Platform support email address',
# Tenant/Business info
'tenant_name': 'Business/tenant name',
'tenant_subdomain': 'Business subdomain',
'owner_name': 'Business owner full name',
'owner_first_name': 'Business owner first name',
'owner_email': 'Business owner email address',
# Invitation-specific
'inviter_name': 'Name of person who sent the invitation',
'invitation_link': 'Link to accept the invitation',
'invitation_expires_at': 'When the invitation expires',
'personal_message': 'Personal message from inviter',
'suggested_business_name': 'Suggested business name',
# Staff-specific
'staff_name': 'Staff member full name',
'staff_first_name': 'Staff member first name',
'staff_email': 'Staff member email address',
'staff_role': 'Staff role (Platform Manager or Platform Support)',
'staff_role_description': 'Description of the staff role and permissions',
'login_link': 'Link to platform login page',
'set_password_link': 'Link for staff to set their password',
# Trial-specific
'trial_days_remaining': 'Days remaining in trial',
'trial_end_date': 'When the trial ends',
# Subscription/Plan info
'plan_name': 'Subscription plan name',
'plan_price': 'Plan price (formatted)',
'old_plan_name': 'Previous plan name (for changes)',
'new_plan_name': 'New plan name (for changes)',
# Billing/Payment info
'payment_amount': 'Payment amount (formatted)',
'payment_date': 'Payment date',
'invoice_link': 'Link to view invoice',
'invoice_number': 'Invoice number',
'next_billing_date': 'Next billing date',
'card_last_four': 'Last 4 digits of payment card',
'failure_reason': 'Reason for payment failure',
'update_payment_link': 'Link to update payment method',
# Date/time
'current_date': 'Current date',
'current_year': 'Current year',
}
def get_platform_tags():
"""Get all platform tags with descriptions."""
return [
{'name': name, 'description': desc, 'category': _get_tag_category(name)}
for name, desc in PLATFORM_TAGS.items()
]
def _get_tag_category(tag_name: str) -> str:
"""Get category for a tag."""
if tag_name.startswith('platform_'):
return 'Platform'
elif tag_name.startswith('tenant_') or tag_name.startswith('owner_'):
return 'Business'
elif tag_name.startswith('invitation_') or tag_name in ('inviter_name', 'personal_message', 'suggested_business_name'):
return 'Invitation'
elif tag_name.startswith('staff_') or tag_name in ('login_link', 'set_password_link'):
return 'Staff'
elif tag_name.startswith('trial_'):
return 'Trial'
elif tag_name.startswith('plan_') or tag_name in ('old_plan_name', 'new_plan_name'):
return 'Subscription'
elif tag_name.startswith('payment_') or tag_name.startswith('invoice_') or tag_name in ('card_last_four', 'failure_reason', 'update_payment_link', 'next_billing_date'):
return 'Billing'
else:
return 'Other'
# =============================================================================
# Default Templates
# =============================================================================
DEFAULT_PLATFORM_TEMPLATES = {
# =========================================================================
# Tenant Invitation
# =========================================================================
'tenant_invitation': {
'subject_template': "You're Invited to {{ platform_name }}!",
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': '{{ platform_name }}',
'preheader': '{{ inviter_name }} has invited you to create your business'
}
},
{
'type': 'EmailHeading',
'props': {
'text': "You're Invited!",
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Hi there,\n\n{{ inviter_name }} has invited you to create your business on {{ platform_name }}, the modern scheduling platform that helps you manage appointments, staff, and customers effortlessly.',
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': '<strong>Suggested Business Name:</strong> {{ suggested_business_name }}',
'backgroundColor': '#f3f4f6'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'Accept Invitation & Get Started',
'href': '{{ invitation_link }}',
'variant': 'primary',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'md'}
},
{
'type': 'EmailText',
'props': {
'content': 'This invitation expires on {{ invitation_expires_at }}.',
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
},
# =========================================================================
# Trial Expiration Warning
# =========================================================================
'trial_expiration_warning': {
'subject_template': 'Your {{ platform_name }} trial expires in {{ trial_days_remaining }} days',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': '{{ platform_name }}',
'preheader': 'Your trial is ending soon - subscribe to keep your data'
}
},
{
'type': 'EmailHeading',
'props': {
'text': 'Your Trial is Ending Soon',
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Hi {{ owner_first_name }},\n\nYour free trial for {{ tenant_name }} expires in {{ trial_days_remaining }} days on {{ trial_end_date }}.',
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': '<strong>What happens when your trial ends?</strong><br><br>• You will lose access to your account<br>• Your data will be preserved for 30 days<br>• Subscribe anytime to restore full access',
'backgroundColor': '#fef3c7'
}
},
{
'type': 'EmailText',
'props': {
'content': "Subscribe now to ensure uninterrupted service and keep all your appointments, customers, and settings.",
'align': 'center'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'Subscribe Now',
'href': '{{ platform_url }}/dashboard/settings/billing',
'variant': 'primary',
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
},
# =========================================================================
# Trial Expired
# =========================================================================
'trial_expired': {
'subject_template': 'Your {{ platform_name }} trial has expired',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': '{{ platform_name }}',
'preheader': 'Subscribe now to restore access to your account'
}
},
{
'type': 'EmailHeading',
'props': {
'text': 'Your Trial Has Expired',
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Hi {{ owner_first_name }},\n\nYour free trial for {{ tenant_name }} has expired as of {{ trial_end_date }}.',
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': "<strong>Don't worry - your data is safe!</strong><br><br>We'll keep your data for 30 days. Subscribe anytime within this period to restore full access to your account.",
'backgroundColor': '#fee2e2'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'Subscribe & Restore Access',
'href': '{{ platform_url }}/dashboard/settings/billing',
'variant': 'primary',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'md'}
},
{
'type': 'EmailText',
'props': {
'content': 'Have questions? Reply to this email or contact our support team.',
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
},
# =========================================================================
# Plan Upgrade Confirmation
# =========================================================================
'plan_upgrade': {
'subject_template': 'Welcome to {{ new_plan_name }}! Your upgrade is complete',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': '{{ platform_name }}',
'preheader': 'Your plan upgrade is complete'
}
},
{
'type': 'EmailHeading',
'props': {
'text': 'Upgrade Complete!',
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Hi {{ owner_first_name }},\n\nGreat news! Your {{ tenant_name }} account has been upgraded to the {{ new_plan_name }} plan.',
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': '<strong>Plan Details:</strong><br><br>• <strong>New Plan:</strong> {{ new_plan_name }}<br>• <strong>Price:</strong> {{ plan_price }}/month<br>• <strong>Next Billing Date:</strong> {{ next_billing_date }}',
'backgroundColor': '#d1fae5'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Your new features are now available. Explore your upgraded account!',
'align': 'center'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'Go to Dashboard',
'href': '{{ platform_url }}/dashboard',
'variant': 'primary',
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
},
# =========================================================================
# Plan Downgrade Confirmation
# =========================================================================
'plan_downgrade': {
'subject_template': 'Your plan change to {{ new_plan_name }} is confirmed',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': '{{ platform_name }}',
'preheader': 'Your plan change has been processed'
}
},
{
'type': 'EmailHeading',
'props': {
'text': 'Plan Change Confirmed',
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Hi {{ owner_first_name }},\n\nYour {{ tenant_name }} account has been changed from {{ old_plan_name }} to {{ new_plan_name }}.',
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': '<strong>Plan Details:</strong><br><br>• <strong>New Plan:</strong> {{ new_plan_name }}<br>• <strong>Price:</strong> {{ plan_price }}/month<br>• <strong>Effective Date:</strong> {{ next_billing_date }}',
'backgroundColor': '#dbeafe'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Some features may no longer be available on your new plan. If you have any questions, please contact our support team.',
'align': 'center'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'View Plan Details',
'href': '{{ platform_url }}/dashboard/settings/billing',
'variant': 'secondary',
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
},
# =========================================================================
# Subscription Cancelled
# =========================================================================
'subscription_cancelled': {
'subject_template': 'Your {{ platform_name }} subscription has been cancelled',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': '{{ platform_name }}',
'preheader': 'Your subscription cancellation is confirmed'
}
},
{
'type': 'EmailHeading',
'props': {
'text': 'Subscription Cancelled',
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': "Hi {{ owner_first_name }},\n\nWe're sorry to see you go. Your {{ tenant_name }} subscription has been cancelled.",
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': "<strong>What happens next?</strong><br><br>• Your account will remain active until {{ next_billing_date }}<br>• After that, you'll lose access to your account<br>• Your data will be preserved for 30 days<br>• You can resubscribe anytime to restore access",
'backgroundColor': '#f3f4f6'
}
},
{
'type': 'EmailText',
'props': {
'content': "Changed your mind? You can reactivate your subscription anytime before the end of your billing period.",
'align': 'center'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'Reactivate Subscription',
'href': '{{ platform_url }}/dashboard/settings/billing',
'variant': 'primary',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'md'}
},
{
'type': 'EmailText',
'props': {
'content': "We'd love to hear your feedback. Reply to this email to let us know how we could improve.",
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
},
# =========================================================================
# Payment Failed
# =========================================================================
'payment_failed': {
'subject_template': 'Action Required: Payment failed for {{ tenant_name }}',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': '{{ platform_name }}',
'preheader': 'Please update your payment method'
}
},
{
'type': 'EmailHeading',
'props': {
'text': 'Payment Failed',
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Hi {{ owner_first_name }},\n\nWe were unable to process your payment for {{ tenant_name }}.',
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': '<strong>Payment Details:</strong><br><br>• <strong>Amount:</strong> {{ payment_amount }}<br>• <strong>Card ending in:</strong> {{ card_last_four }}<br>• <strong>Reason:</strong> {{ failure_reason }}',
'backgroundColor': '#fee2e2'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Please update your payment method to avoid service interruption. We will automatically retry the payment once your card is updated.',
'align': 'center'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'Update Payment Method',
'href': '{{ update_payment_link }}',
'variant': 'primary',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'md'}
},
{
'type': 'EmailText',
'props': {
'content': 'If you need assistance, please contact our support team.',
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
},
# =========================================================================
# Payment Succeeded
# =========================================================================
'payment_succeeded': {
'subject_template': 'Payment Receipt - {{ platform_name }}',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': '{{ platform_name }}',
'preheader': 'Thank you for your payment'
}
},
{
'type': 'EmailHeading',
'props': {
'text': 'Payment Received',
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Hi {{ owner_first_name }},\n\nThank you for your payment. Here is your receipt:',
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': '<strong>Payment Details:</strong><br><br>• <strong>Amount:</strong> {{ payment_amount }}<br>• <strong>Date:</strong> {{ payment_date }}<br>• <strong>Invoice:</strong> {{ invoice_number }}<br>• <strong>Plan:</strong> {{ plan_name }}<br>• <strong>Next Billing Date:</strong> {{ next_billing_date }}',
'backgroundColor': '#d1fae5'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'View Invoice',
'href': '{{ invoice_link }}',
'variant': 'secondary',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'md'}
},
{
'type': 'EmailText',
'props': {
'content': 'Thank you for being a {{ platform_name }} customer!',
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
},
# =========================================================================
# Platform Staff Invitation
# =========================================================================
'platform_staff_invitation': {
'subject_template': "You're Invited to Join the {{ platform_name }} Team!",
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': '{{ platform_name }}',
'preheader': '{{ inviter_name }} has invited you to join as {{ staff_role }}'
}
},
{
'type': 'EmailHeading',
'props': {
'text': "You're Invited to Join Our Team!",
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Hi there,\n\n{{ inviter_name }} has invited you to join the {{ platform_name }} team as a {{ staff_role }}.',
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': '<strong>Your Role:</strong> {{ staff_role }}<br><br>{{ staff_role_description }}',
'backgroundColor': '#ede9fe'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'Accept Invitation & Set Password',
'href': '{{ set_password_link }}',
'variant': 'primary',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'md'}
},
{
'type': 'EmailText',
'props': {
'content': 'This invitation expires on {{ invitation_expires_at }}.',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': 'If you have any questions, please contact {{ inviter_name }} or reply to this email.',
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
},
# =========================================================================
# Platform Staff Welcome
# =========================================================================
'platform_staff_welcome': {
'subject_template': 'Welcome to the {{ platform_name }} Team, {{ staff_first_name }}!',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {
'businessName': '{{ platform_name }}',
'preheader': 'Your account is ready - get started today!'
}
},
{
'type': 'EmailHeading',
'props': {
'text': 'Welcome to the Team!',
'level': 'h1',
'align': 'center'
}
},
{
'type': 'EmailText',
'props': {
'content': 'Hi {{ staff_first_name }},\n\nWelcome to the {{ platform_name }} team! Your account is now active and you can start working right away.',
'align': 'left'
}
},
{
'type': 'EmailPanel',
'props': {
'content': '<strong>Your Account Details:</strong><br><br>• <strong>Email:</strong> {{ staff_email }}<br>• <strong>Role:</strong> {{ staff_role }}',
'backgroundColor': '#d1fae5'
}
},
{
'type': 'EmailText',
'props': {
'content': 'As a {{ staff_role }}, you have access to the platform dashboard where you can help manage tenants, support users, and more.',
'align': 'left'
}
},
{
'type': 'EmailButton',
'props': {
'text': 'Go to Platform Dashboard',
'href': '{{ login_link }}',
'variant': 'primary',
'align': 'center'
}
},
{
'type': 'EmailSpacer',
'props': {'size': 'md'}
},
{
'type': 'EmailText',
'props': {
'content': "If you need any help getting started, don't hesitate to reach out to the team.",
'align': 'center'
}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
},
}
def get_default_platform_template(email_type: str) -> dict:
"""
Get the default template for a platform email type.
Args:
email_type: The PlatformEmailType value
Returns:
Dict with 'subject_template' and 'puck_data' keys
"""
return DEFAULT_PLATFORM_TEMPLATES.get(email_type, {
'subject_template': f'{email_type.replace("_", " ").title()} Email',
'puck_data': {
'content': [
{
'type': 'EmailHeader',
'props': {'businessName': '{{ platform_name }}'}
},
{
'type': 'EmailText',
'props': {'content': 'Hello {{ owner_first_name }},'}
},
{
'type': 'EmailFooter',
'props': {
'email': '{{ platform_support_email }}'
}
}
],
'root': {}
}
})

View File

@@ -5,7 +5,11 @@ Serializers for platform-level operations (viewing tenants, users, metrics)
from rest_framework import serializers
from smoothschedule.identity.core.models import Tenant, Domain
from smoothschedule.identity.users.models import User
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress
from .models import (
TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress,
PlatformEmailTemplate, PlatformEmailType,
)
from .platform_email_templates import get_platform_tags
class PlatformSettingsSerializer(serializers.Serializer):
@@ -881,3 +885,109 @@ class PlatformEmailAddressUpdateSerializer(serializers.ModelSerializer):
return instance
# =============================================================================
# Platform Email Template Serializers
# =============================================================================
class PlatformEmailTemplateListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for listing platform email templates."""
display_name = serializers.SerializerMethodField()
description = serializers.SerializerMethodField()
category = serializers.SerializerMethodField()
class Meta:
model = PlatformEmailTemplate
fields = [
'email_type', 'subject_template', 'is_active', 'is_customized',
'display_name', 'description', 'category',
'created_at', 'updated_at',
]
read_only_fields = fields
def get_display_name(self, obj):
return PlatformEmailType.get_display_name(obj.email_type)
def get_description(self, obj):
return PlatformEmailType.get_description(obj.email_type)
def get_category(self, obj):
return PlatformEmailType.get_category(obj.email_type)
class PlatformEmailTemplateDetailSerializer(serializers.ModelSerializer):
"""Full serializer for platform email template with available tags."""
display_name = serializers.SerializerMethodField()
description = serializers.SerializerMethodField()
category = serializers.SerializerMethodField()
available_tags = serializers.SerializerMethodField()
created_by_email = serializers.SerializerMethodField()
class Meta:
model = PlatformEmailTemplate
fields = [
'email_type', 'subject_template', 'puck_data',
'is_active', 'is_customized',
'display_name', 'description', 'category', 'available_tags',
'created_by', 'created_by_email',
'created_at', 'updated_at',
]
read_only_fields = [
'email_type', 'is_customized',
'display_name', 'description', 'category', 'available_tags',
'created_by', 'created_by_email',
'created_at', 'updated_at',
]
def get_display_name(self, obj):
return PlatformEmailType.get_display_name(obj.email_type)
def get_description(self, obj):
return PlatformEmailType.get_description(obj.email_type)
def get_category(self, obj):
return PlatformEmailType.get_category(obj.email_type)
def get_available_tags(self, obj):
"""Return available template tags for this email type."""
# get_platform_tags() already returns a list of dicts with name, description, category
tags = get_platform_tags()
# Add syntax field to each tag
return [
{
'name': tag['name'],
'description': tag['description'],
'syntax': f'{{{{ {tag["name"]} }}}}',
}
for tag in tags
]
def get_created_by_email(self, obj):
if obj.created_by:
return obj.created_by.email
return None
def update(self, instance, validated_data):
"""Mark template as customized when updated."""
instance = super().update(instance, validated_data)
if not instance.is_customized:
instance.is_customized = True
instance.save(update_fields=['is_customized'])
return instance
class PlatformEmailTemplatePreviewSerializer(serializers.Serializer):
"""Serializer for previewing rendered platform email templates."""
context = serializers.DictField(
required=False,
default=dict,
help_text="Optional context variables for rendering the template"
)
def validate_context(self, value):
"""Ensure all context values are strings."""
for key, val in value.items():
if not isinstance(val, str):
value[key] = str(val)
return value

View File

@@ -470,3 +470,200 @@ def sync_staff_email_folder(email_address_id: int, folder_name: str = 'INBOX'):
exc_info=True
)
return {'success': False, 'error': str(e)}
# ============================================================================
# Platform Staff Invitation Tasks
# ============================================================================
@shared_task(bind=True, max_retries=3, name='platform.send_platform_staff_invitation_email')
def send_platform_staff_invitation_email(self, invitation_id: int):
"""
Send an invitation email to a prospective platform staff member.
Args:
invitation_id: ID of the PlatformStaffInvitation to send
"""
from .models import PlatformStaffInvitation, PlatformEmailTemplate, PlatformEmailType
try:
invitation = PlatformStaffInvitation.objects.select_related('invited_by').get(id=invitation_id)
except PlatformStaffInvitation.DoesNotExist:
logger.error(f"PlatformStaffInvitation {invitation_id} not found")
return {'success': False, 'error': 'Invitation not found'}
# Don't send if not pending
if invitation.status != PlatformStaffInvitation.Status.PENDING:
logger.info(f"Skipping email for platform staff invitation {invitation_id} - status is {invitation.status}")
return {'success': False, 'error': f'Invitation status is {invitation.status}'}
if not invitation.is_valid():
logger.info(f"Skipping email for platform staff invitation {invitation_id} - invitation expired")
return {'success': False, 'error': 'Invitation expired'}
try:
# Build the invitation URL
base_url = get_base_url()
set_password_url = f"{base_url}/platform-staff-invite?token={invitation.token}"
# Get the email template
template = PlatformEmailTemplate.get_or_create_for_type(
PlatformEmailType.PLATFORM_STAFF_INVITATION
)
# Build context for template
inviter_name = invitation.invited_by.get_full_name() or invitation.invited_by.email if invitation.invited_by else 'SmoothSchedule Team'
expires_at_str = invitation.expires_at.strftime('%B %d, %Y at %I:%M %p') if invitation.expires_at else 'in 7 days'
context = {
'platform_name': 'SmoothSchedule',
'platform_url': base_url,
'platform_support_email': 'support@smoothschedule.com',
'inviter_name': inviter_name,
'staff_role': invitation.get_role_display_name(),
'staff_role_description': invitation.get_role_description(),
'set_password_link': set_password_url,
'invitation_expires_at': expires_at_str,
'personal_message': invitation.personal_message or '',
}
# Render the template
rendered = template.render(context)
# Build plain text version with the link explicitly included
plain_text_lines = [
f"You're Invited to Join SmoothSchedule as {invitation.get_role_display_name()}",
"",
f"{inviter_name} has invited you to join the SmoothSchedule platform team.",
"",
f"Your Role: {invitation.get_role_display_name()}",
f"{invitation.get_role_description()}",
"",
]
if invitation.personal_message:
plain_text_lines.extend([
"Personal Message:",
f'"{invitation.personal_message}"',
"",
])
plain_text_lines.extend([
"To accept this invitation and set up your account, visit:",
set_password_url,
"",
f"This invitation expires on {expires_at_str}.",
"",
"If you have any questions, please contact support@smoothschedule.com.",
"",
"---",
"SmoothSchedule Platform Team",
])
plain_text_body = "\n".join(plain_text_lines)
# Send email
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
email = EmailMultiAlternatives(
subject=rendered['subject'],
body=plain_text_body,
from_email=from_email,
to=[invitation.email],
)
email.attach_alternative(rendered['html'], "text/html")
email.send()
logger.info(f"Sent platform staff invitation email to {invitation.email}")
return {'success': True, 'email': invitation.email}
except Exception as e:
logger.error(f"Failed to send platform staff invitation email for {invitation_id}: {e}", exc_info=True)
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
@shared_task(bind=True, max_retries=3, name='platform.send_platform_staff_welcome_email')
def send_platform_staff_welcome_email(self, user_id: int, role: str):
"""
Send a welcome email to a newly onboarded platform staff member.
Args:
user_id: ID of the User who just joined
role: The role they were assigned
"""
from smoothschedule.identity.users.models import User
from .models import PlatformEmailTemplate, PlatformEmailType, PlatformStaffInvitation
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
logger.error(f"User {user_id} not found for welcome email")
return {'success': False, 'error': 'User not found'}
try:
# Get the email template
template = PlatformEmailTemplate.get_or_create_for_type(
PlatformEmailType.PLATFORM_STAFF_WELCOME
)
# Get role display name
role_names = {
PlatformStaffInvitation.StaffRole.PLATFORM_MANAGER: 'Platform Manager',
PlatformStaffInvitation.StaffRole.PLATFORM_SUPPORT: 'Platform Support',
}
role_display = role_names.get(role, role)
# Build context for template
base_url = get_base_url()
staff_name = user.get_full_name() or user.email
staff_first_name = user.first_name or 'there'
context = {
'platform_name': 'SmoothSchedule',
'platform_url': base_url,
'platform_support_email': 'support@smoothschedule.com',
'staff_name': staff_name,
'staff_first_name': staff_first_name,
'staff_email': user.email,
'staff_role': role_display,
'login_link': base_url,
}
# Render the template
rendered = template.render(context)
# Build plain text version
plain_text_lines = [
f"Welcome to SmoothSchedule, {staff_first_name}!",
"",
f"Your account has been created as a {role_display}.",
"",
"You can now log in to the platform dashboard at:",
base_url,
"",
"If you have any questions, please contact support@smoothschedule.com.",
"",
"---",
"SmoothSchedule Platform Team",
]
plain_text_body = "\n".join(plain_text_lines)
# Send email
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
email = EmailMultiAlternatives(
subject=rendered['subject'],
body=plain_text_body,
from_email=from_email,
to=[user.email],
)
email.attach_alternative(rendered['html'], "text/html")
email.send()
logger.info(f"Sent platform staff welcome email to {user.email}")
return {'success': True, 'email': user.email}
except Exception as e:
logger.error(f"Failed to send platform staff welcome email for user {user_id}: {e}", exc_info=True)
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))

View File

@@ -1785,46 +1785,6 @@ class TestPlatformUserViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert 'Invalid role' in response.data['detail']
def test_partial_update_merges_permissions(self):
"""Test partial_update merges permissions"""
request = self.factory.patch('/api/platform/users/1/', {
'permissions': {
'can_approve_plugins': True,
'can_whitelist_urls': True
}
}, format='json')
request.user = Mock(
is_authenticated=True,
role=User.Role.SUPERUSER,
permissions={'can_approve_plugins': True, 'can_whitelist_urls': True}
)
# Add .data attribute for DRF compatibility
request.data = {
'permissions': {
'can_approve_plugins': True,
'can_whitelist_urls': True
}
}
mock_user = Mock(
role=User.Role.PLATFORM_MANAGER,
permissions={'existing_perm': True}
)
mock_serializer = Mock()
mock_serializer.data = {'id': 1}
view = self.viewset()
view.request = request
view.get_object = Mock(return_value=mock_user)
view.get_serializer = Mock(return_value=mock_serializer)
response = view.partial_update(request)
assert response.status_code == status.HTTP_200_OK
assert mock_user.permissions['can_approve_plugins'] is True
assert mock_user.permissions['can_whitelist_urls'] is True
assert mock_user.permissions['existing_perm'] is True
def test_partial_update_sets_password(self):
"""Test partial_update can set password"""
request = self.factory.patch('/api/platform/users/1/', {

View File

@@ -17,6 +17,8 @@ from .views import (
StripeWebhookRotateSecretView,
OAuthSettingsView,
PlatformEmailAddressViewSet,
PlatformEmailTemplateViewSet,
PlatformStaffInvitationViewSet,
)
app_name = 'platform'
@@ -27,6 +29,8 @@ router.register(r'users', PlatformUserViewSet, basename='user')
router.register(r'tenant-invitations', TenantInvitationViewSet, basename='tenant-invitation')
router.register(r'subscription-plans', SubscriptionPlanViewSet, basename='subscription-plan')
router.register(r'email-addresses', PlatformEmailAddressViewSet, basename='email-address')
router.register(r'email-templates', PlatformEmailTemplateViewSet, basename='email-template')
router.register(r'staff-invitations', PlatformStaffInvitationViewSet, basename='staff-invitation')
urlpatterns = [
path('', include(router.urls)),
@@ -54,4 +58,16 @@ urlpatterns = [
TenantInvitationViewSet.as_view({'post': 'accept'}),
name='tenant-invitation-accept'
),
# Public endpoints for platform staff invitations
path(
'staff-invitations/token/<str:token>/',
PlatformStaffInvitationViewSet.as_view({'get': 'retrieve_by_token'}),
name='staff-invitation-retrieve-by-token'
),
path(
'staff-invitations/token/<str:token>/accept/',
PlatformStaffInvitationViewSet.as_view({'post': 'accept'}),
name='staff-invitation-accept'
),
]

View File

@@ -19,7 +19,10 @@ from smoothschedule.identity.core.models import Tenant, Domain
from smoothschedule.identity.users.models import User
from smoothschedule.billing.models import TenantCustomTier
from smoothschedule.billing.api.serializers import TenantCustomTierSerializer
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress
from .models import (
TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress,
PlatformEmailTemplate, PlatformEmailType, PlatformStaffInvitation,
)
from .serializers import (
TenantSerializer,
TenantCreateSerializer,
@@ -40,6 +43,9 @@ from .serializers import (
PlatformEmailAddressSerializer,
PlatformEmailAddressCreateSerializer,
PlatformEmailAddressUpdateSerializer,
PlatformEmailTemplateListSerializer,
PlatformEmailTemplateDetailSerializer,
PlatformEmailTemplatePreviewSerializer,
)
from .permissions import IsPlatformAdmin, IsPlatformUser
@@ -1046,7 +1052,7 @@ class PlatformUserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by('-date_joined')
serializer_class = PlatformUserSerializer
permission_classes = [IsAuthenticated, IsPlatformAdmin]
http_method_names = ['get', 'post', 'patch', 'head', 'options'] # Allow GET, POST, and PATCH
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
def get_queryset(self):
"""Optionally filter by business or role"""
@@ -1074,7 +1080,73 @@ class PlatformUserViewSet(viewsets.ModelViewSet):
user.save(update_fields=['email_verified'])
return Response({'status': 'email verified'})
def destroy(self, request, *args, **kwargs):
"""
Delete a platform user.
Only superusers can delete users.
Users cannot delete themselves.
"""
instance = self.get_object()
# Only superusers can delete users
if request.user.role != User.Role.SUPERUSER:
return Response(
{"detail": "Only superusers can delete users."},
status=status.HTTP_403_FORBIDDEN
)
# Prevent self-deletion
if instance.id == request.user.id:
return Response(
{"detail": "You cannot delete your own account."},
status=status.HTTP_400_BAD_REQUEST
)
user_id = instance.id
# For tenant users, we need to handle related objects in their tenant schema
if instance.tenant:
from django_tenants.utils import schema_context
with schema_context(instance.tenant.schema_name):
# Unlink resources from this user
from smoothschedule.scheduling.schedule.models import Resource
Resource.objects.filter(user_id=user_id).update(user=None)
# Delete or unlink contracts
try:
from smoothschedule.scheduling.contracts.models import Contract
Contract.objects.filter(customer_id=user_id).delete()
except Exception:
pass # Table might not exist
# For platform staff (no tenant) or after cleaning up tenant relations,
# use raw SQL to avoid Django's collector trying to query non-existent tables
from django.db import connection
with connection.cursor() as cursor:
# Delete all related objects in public schema that reference user_id
# Order matters - delete FK references before the user
cursor.execute("DELETE FROM authtoken_token WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM account_emailaddress WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM activepieces_tenantactivepiecesuser WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM billing_quotabannerdismissal WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM django_admin_log WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM mfa_authenticator WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM socialaccount_socialaccount WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM staff_email_emailcontactsuggestion WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM staff_email_staffemailfolder WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM staff_email_staffemaillabel WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM users_emailverificationtoken WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM users_mfaverificationcode WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM users_trusteddevice WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM users_user_groups WHERE user_id = %s", [user_id])
cursor.execute("DELETE FROM users_user_user_permissions WHERE user_id = %s", [user_id])
# Platform-specific: clear invitation references and invited_by references
cursor.execute("UPDATE platform_admin_platformstaffinvitation SET accepted_user_id = NULL WHERE accepted_user_id = %s", [user_id])
cursor.execute("UPDATE platform_admin_platformstaffinvitation SET invited_by_id = NULL WHERE invited_by_id = %s", [user_id])
# Finally delete the user
cursor.execute("DELETE FROM users_user WHERE id = %s", [user_id])
return Response(status=status.HTTP_204_NO_CONTENT)
def partial_update(self, request, *args, **kwargs):
"""
@@ -1114,23 +1186,6 @@ class PlatformUserViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
setattr(instance, field, role_value)
elif field == 'permissions':
# Merge permissions - don't replace entirely
current_permissions = instance.permissions or {}
new_permissions = request.data[field]
# Only allow granting permissions that the current user has
for perm_key, perm_value in new_permissions.items():
if perm_key == 'can_approve_plugins':
# Only superusers or users with this permission can grant it
if user.role == User.Role.SUPERUSER or user.permissions.get('can_approve_plugins', False):
current_permissions[perm_key] = perm_value
elif perm_key == 'can_whitelist_urls':
# Only superusers or users with this permission can grant it
if user.role == User.Role.SUPERUSER or user.permissions.get('can_whitelist_urls', False):
current_permissions[perm_key] = perm_value
instance.permissions = current_permissions
else:
setattr(instance, field, request.data[field])
@@ -1624,3 +1679,492 @@ class PlatformEmailAddressViewSet(viewsets.ModelViewSet):
'skipped_count': len(skipped),
'message': f'Imported {len(imported)} email addresses, skipped {len(skipped)}',
})
class PlatformEmailTemplateViewSet(viewsets.ViewSet):
"""
ViewSet for managing platform email templates.
These are templates for platform-level emails (invitations, billing alerts, etc.)
managed via Puck visual editor.
Superusers only.
"""
permission_classes = [IsAuthenticated]
def _check_superuser(self, request):
"""Check that the request user is a superuser."""
if not request.user.is_superuser:
return Response(
{"detail": "Only superusers can manage platform email templates."},
status=status.HTTP_403_FORBIDDEN
)
return None
def list(self, request):
"""
GET /api/platform/email-templates/
List all platform email templates.
"""
error_response = self._check_superuser(request)
if error_response:
return error_response
# Ensure all templates exist
PlatformEmailTemplate.ensure_all_templates_exist()
templates = PlatformEmailTemplate.objects.all().order_by('email_type')
serializer = PlatformEmailTemplateListSerializer(templates, many=True)
return Response(serializer.data)
def retrieve(self, request, pk=None):
"""
GET /api/platform/email-templates/{type}/
Get a specific platform email template by type.
"""
error_response = self._check_superuser(request)
if error_response:
return error_response
try:
template = PlatformEmailTemplate.get_or_create_for_type(pk)
except ValueError as e:
return Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST
)
serializer = PlatformEmailTemplateDetailSerializer(template)
return Response(serializer.data)
def update(self, request, pk=None):
"""
PUT /api/platform/email-templates/{type}/
Update a platform email template.
"""
error_response = self._check_superuser(request)
if error_response:
return error_response
try:
template = PlatformEmailTemplate.get_or_create_for_type(pk)
except ValueError as e:
return Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST
)
serializer = PlatformEmailTemplateDetailSerializer(
template,
data=request.data,
partial=True
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@action(detail=True, methods=['post'])
def reset(self, request, pk=None):
"""
POST /api/platform/email-templates/{type}/reset/
Reset a platform email template to its default.
"""
error_response = self._check_superuser(request)
if error_response:
return error_response
try:
template = PlatformEmailTemplate.get_or_create_for_type(pk)
except ValueError as e:
return Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST
)
template.reset_to_default()
serializer = PlatformEmailTemplateDetailSerializer(template)
return Response({
"detail": "Template reset to default.",
"template": serializer.data
})
@action(detail=True, methods=['post'])
def preview(self, request, pk=None):
"""
POST /api/platform/email-templates/{type}/preview/
Preview a rendered platform email template.
"""
error_response = self._check_superuser(request)
if error_response:
return error_response
try:
template = PlatformEmailTemplate.get_or_create_for_type(pk)
except ValueError as e:
return Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST
)
# Validate incoming context
preview_serializer = PlatformEmailTemplatePreviewSerializer(data=request.data)
preview_serializer.is_valid(raise_exception=True)
# Get sample context
from .platform_email_templates import get_platform_tags
sample_context = {
tag: f"[{tag}]"
for tag in get_platform_tags().keys()
}
# Override with any provided context
sample_context.update(preview_serializer.validated_data.get('context', {}))
try:
rendered = template.render(sample_context)
return Response({
"subject": rendered['subject'],
"html": rendered['html'],
})
except Exception as e:
return Response(
{"detail": f"Failed to render template: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST
)
class PlatformStaffInvitationViewSet(viewsets.ViewSet):
"""
ViewSet for managing platform staff invitations.
Superusers can create, list, and manage invitations for platform_manager and platform_support roles.
"""
permission_classes = [IsAuthenticated]
def _check_superuser(self, request):
"""Check that the request user is a superuser."""
if not request.user.is_superuser:
return Response(
{"detail": "Only superusers can manage platform staff invitations."},
status=status.HTTP_403_FORBIDDEN
)
return None
def list(self, request):
"""
GET /api/platform/staff-invitations/
List all platform staff invitations.
"""
error_response = self._check_superuser(request)
if error_response:
return error_response
invitations = PlatformStaffInvitation.objects.all().order_by('-created_at')
# Filter by status if provided
status_filter = request.query_params.get('status')
if status_filter:
invitations = invitations.filter(status=status_filter.upper())
data = []
for inv in invitations:
data.append({
'id': inv.id,
'email': inv.email,
'role': inv.role,
'role_display': inv.get_role_display_name(),
'status': inv.status,
'status_display': inv.get_status_display(),
'invited_by': inv.invited_by.get_full_name() if inv.invited_by else None,
'invited_by_email': inv.invited_by.email if inv.invited_by else None,
'created_at': inv.created_at.isoformat(),
'expires_at': inv.expires_at.isoformat() if inv.expires_at else None,
'accepted_at': inv.accepted_at.isoformat() if inv.accepted_at else None,
'is_valid': inv.is_valid(),
})
return Response(data)
def create(self, request):
"""
POST /api/platform/staff-invitations/
Create a new platform staff invitation and send the invitation email.
"""
error_response = self._check_superuser(request)
if error_response:
return error_response
email = request.data.get('email')
role = request.data.get('role', PlatformStaffInvitation.StaffRole.PLATFORM_SUPPORT)
permissions = request.data.get('permissions', {})
personal_message = request.data.get('personal_message', '')
if not email:
return Response(
{"detail": "Email is required."},
status=status.HTTP_400_BAD_REQUEST
)
# Validate role
valid_roles = [r[0] for r in PlatformStaffInvitation.StaffRole.choices]
if role not in valid_roles:
return Response(
{"detail": f"Invalid role. Must be one of: {valid_roles}"},
status=status.HTTP_400_BAD_REQUEST
)
# Check if user with this email already exists with a platform role
existing_user = User.objects.filter(email=email).first()
if existing_user and existing_user.role in [
User.Role.SUPERUSER,
User.Role.PLATFORM_MANAGER,
User.Role.PLATFORM_SUPPORT,
]:
return Response(
{"detail": "A platform user with this email already exists."},
status=status.HTTP_400_BAD_REQUEST
)
# Create the invitation
invitation = PlatformStaffInvitation.create_invitation(
email=email,
role=role,
invited_by=request.user,
permissions=permissions,
personal_message=personal_message,
)
# Send invitation email via Celery task
from .tasks import send_platform_staff_invitation_email
send_platform_staff_invitation_email.delay(invitation.id)
return Response({
'id': invitation.id,
'email': invitation.email,
'role': invitation.role,
'role_display': invitation.get_role_display_name(),
'status': invitation.status,
'expires_at': invitation.expires_at.isoformat(),
'detail': 'Invitation created and email sent.',
}, status=status.HTTP_201_CREATED)
def retrieve(self, request, pk=None):
"""
GET /api/platform/staff-invitations/{id}/
Get a specific platform staff invitation.
"""
error_response = self._check_superuser(request)
if error_response:
return error_response
try:
invitation = PlatformStaffInvitation.objects.get(pk=pk)
except PlatformStaffInvitation.DoesNotExist:
return Response(
{"detail": "Invitation not found."},
status=status.HTTP_404_NOT_FOUND
)
return Response({
'id': invitation.id,
'email': invitation.email,
'role': invitation.role,
'role_display': invitation.get_role_display_name(),
'role_description': invitation.get_role_description(),
'status': invitation.status,
'status_display': invitation.get_status_display(),
'invited_by': invitation.invited_by.get_full_name() if invitation.invited_by else None,
'invited_by_email': invitation.invited_by.email if invitation.invited_by else None,
'created_at': invitation.created_at.isoformat(),
'expires_at': invitation.expires_at.isoformat() if invitation.expires_at else None,
'accepted_at': invitation.accepted_at.isoformat() if invitation.accepted_at else None,
'personal_message': invitation.personal_message,
'permissions': invitation.permissions,
'is_valid': invitation.is_valid(),
})
@action(detail=True, methods=['post'])
def resend(self, request, pk=None):
"""
POST /api/platform/staff-invitations/{id}/resend/
Resend the invitation email.
"""
error_response = self._check_superuser(request)
if error_response:
return error_response
try:
invitation = PlatformStaffInvitation.objects.get(pk=pk)
except PlatformStaffInvitation.DoesNotExist:
return Response(
{"detail": "Invitation not found."},
status=status.HTTP_404_NOT_FOUND
)
if not invitation.is_valid():
return Response(
{"detail": "Invitation is no longer valid. Please create a new invitation."},
status=status.HTTP_400_BAD_REQUEST
)
# Extend expiration and regenerate token
invitation.expires_at = timezone.now() + timedelta(days=7)
invitation.token = secrets.token_urlsafe(32)
invitation.save()
# Resend email
from .tasks import send_platform_staff_invitation_email
send_platform_staff_invitation_email.delay(invitation.id)
return Response({
'detail': 'Invitation email resent.',
'expires_at': invitation.expires_at.isoformat(),
})
@action(detail=True, methods=['post'])
def cancel(self, request, pk=None):
"""
POST /api/platform/staff-invitations/{id}/cancel/
Cancel a pending invitation.
"""
error_response = self._check_superuser(request)
if error_response:
return error_response
try:
invitation = PlatformStaffInvitation.objects.get(pk=pk)
except PlatformStaffInvitation.DoesNotExist:
return Response(
{"detail": "Invitation not found."},
status=status.HTTP_404_NOT_FOUND
)
if invitation.status != PlatformStaffInvitation.Status.PENDING:
return Response(
{"detail": "Only pending invitations can be cancelled."},
status=status.HTTP_400_BAD_REQUEST
)
invitation.cancel()
return Response({
'detail': 'Invitation cancelled.',
'status': invitation.status,
})
@action(detail=False, methods=['get'], url_path='token/(?P<token>[^/.]+)', permission_classes=[])
def retrieve_by_token(self, request, token=None):
"""
GET /api/platform/staff-invitations/token/{token}/
Public endpoint to retrieve invitation details by token.
Used by the accept invitation page.
"""
try:
invitation = PlatformStaffInvitation.objects.get(token=token)
except PlatformStaffInvitation.DoesNotExist:
return Response(
{"detail": "Invitation not found or invalid token."},
status=status.HTTP_404_NOT_FOUND
)
if not invitation.is_valid():
return Response(
{"detail": "This invitation has expired or is no longer valid."},
status=status.HTTP_400_BAD_REQUEST
)
return Response({
'email': invitation.email,
'role': invitation.role,
'role_display': invitation.get_role_display_name(),
'role_description': invitation.get_role_description(),
'invited_by': invitation.invited_by.get_full_name() if invitation.invited_by else None,
'personal_message': invitation.personal_message,
'expires_at': invitation.expires_at.isoformat() if invitation.expires_at else None,
})
@action(detail=False, methods=['post'], url_path='token/(?P<token>[^/.]+)/accept', permission_classes=[])
def accept(self, request, token=None):
"""
POST /api/platform/staff-invitations/token/{token}/accept/
Public endpoint to accept an invitation and set up the account.
"""
try:
invitation = PlatformStaffInvitation.objects.get(token=token)
except PlatformStaffInvitation.DoesNotExist:
return Response(
{"detail": "Invitation not found or invalid token."},
status=status.HTTP_404_NOT_FOUND
)
if not invitation.is_valid():
return Response(
{"detail": "This invitation has expired or is no longer valid."},
status=status.HTTP_400_BAD_REQUEST
)
# Validate required fields
password = request.data.get('password')
first_name = request.data.get('first_name')
last_name = request.data.get('last_name')
if not password:
return Response(
{"detail": "Password is required."},
status=status.HTTP_400_BAD_REQUEST
)
if len(password) < 8:
return Response(
{"detail": "Password must be at least 8 characters."},
status=status.HTTP_400_BAD_REQUEST
)
with transaction.atomic():
# Check if user already exists (they may have a tenant account)
user = User.objects.filter(email=invitation.email).first()
if user:
# Update existing user to platform role
user.role = invitation.role
user.is_staff = True
user.set_password(password)
if first_name:
user.first_name = first_name
if last_name:
user.last_name = last_name
# Merge permissions
user.permissions = {**user.permissions, **invitation.permissions}
user.email_verified = True
user.save()
else:
# Create new user
user = User.objects.create_user(
username=invitation.email,
email=invitation.email,
password=password,
first_name=first_name or '',
last_name=last_name or '',
role=invitation.role,
is_staff=True,
permissions=invitation.permissions,
email_verified=True,
)
# Mark invitation as accepted
invitation.accept(user)
# Send welcome email
from .tasks import send_platform_staff_welcome_email
send_platform_staff_welcome_email.delay(user.id, invitation.role)
# Generate auth token for automatic login
from rest_framework.authtoken.models import Token
token, _ = Token.objects.get_or_create(user=user)
return Response({
'detail': 'Account created successfully.',
'email': user.email,
'role': user.role,
'access': token.key,
'refresh': token.key, # Using same token since we use Token auth, not JWT
}, status=status.HTTP_201_CREATED)

View File

@@ -8,18 +8,27 @@ Rate Limits:
- Global: 1000 requests per hour per token
- Burst: 100 requests per minute (allows short bursts of traffic)
Quota Tracking:
- Daily API requests are tracked against the tenant's max_api_requests_per_day quota
- Requests are blocked when the daily quota is exceeded (if not unlimited)
Response Headers:
- X-RateLimit-Limit: Total requests allowed per hour
- X-RateLimit-Remaining: Requests remaining in current hour
- X-RateLimit-Reset: Unix timestamp when the limit resets
- X-RateLimit-Burst-Limit: Requests allowed per minute
- X-RateLimit-Burst-Remaining: Requests remaining in current minute
- X-Quota-Limit: Daily quota limit
- X-Quota-Remaining: Requests remaining in daily quota
"""
import logging
import time
from django.core.cache import cache
from rest_framework.throttling import BaseThrottle
logger = logging.getLogger(__name__)
class GlobalBurstRateThrottle(BaseThrottle):
"""
@@ -52,7 +61,8 @@ class GlobalBurstRateThrottle(BaseThrottle):
"""
Check if the request should be allowed.
Returns True if both hourly and minute limits allow the request.
Returns True if both hourly and minute limits allow the request
AND the tenant's daily API quota is not exceeded.
Stores rate limit info on the request for header generation.
"""
self.now = time.time()
@@ -90,7 +100,7 @@ class GlobalBurstRateThrottle(BaseThrottle):
'burst_remaining': minute_remaining,
}
# Must pass both checks
# Must pass both rate limit checks
if not hourly_allowed or not minute_allowed:
# Determine which limit was exceeded for wait time
if not hourly_allowed:
@@ -99,8 +109,73 @@ class GlobalBurstRateThrottle(BaseThrottle):
self.wait_time = minute_reset - self.now
return False
# Check and track daily API quota
tenant = getattr(request, 'tenant', None) or getattr(self.token, 'tenant', None)
if tenant:
quota_allowed, quota_remaining = self._check_and_track_quota(tenant, request)
if not quota_allowed:
# Return 429 with a wait until midnight
self.wait_time = self._seconds_until_midnight()
self.quota_exceeded = True
return False
return True
def _check_and_track_quota(self, tenant, request):
"""
Check daily API quota and increment the counter.
Args:
tenant: The tenant making the request
request: The HTTP request object
Returns:
tuple: (allowed, remaining_requests)
"""
try:
from smoothschedule.billing.services.quota import QuotaService
# First check if allowed
is_allowed, remaining = QuotaService.check_api_quota(tenant)
if is_allowed:
# Increment the counter
usage, _ = QuotaService.increment_api_request_count(tenant)
# Store quota info for headers
request.quota_info = {
'limit': usage.quota_limit,
'remaining': max(0, usage.remaining_requests - 1), # -1 for this request
'is_unlimited': usage.is_unlimited,
}
logger.debug(
f"API request tracked for {tenant.name}: "
f"{usage.request_count}/{usage.quota_limit or 'unlimited'}"
)
else:
# Store quota info even when blocked
request.quota_info = {
'limit': QuotaService.get_api_request_quota_limit(tenant),
'remaining': 0,
'is_unlimited': False,
}
return is_allowed, remaining
except Exception as e:
# Don't block requests if quota tracking fails
logger.error(f"Failed to check/track API quota for {tenant.name}: {e}")
return True, None
def _seconds_until_midnight(self):
"""Calculate seconds until midnight UTC (when daily quota resets)."""
from django.utils import timezone
now = timezone.now()
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
midnight += timezone.timedelta(days=1)
return (midnight - now).total_seconds()
def _check_rate(self, scope, limit, duration):
"""
Check if request is within rate limit for the given scope/duration.
@@ -149,10 +224,10 @@ class GlobalBurstRateThrottle(BaseThrottle):
class RateLimitHeadersMixin:
"""
Mixin for views to add rate limit headers to responses.
Mixin for views to add rate limit and quota headers to responses.
Add this mixin to views that use GlobalBurstRateThrottle to
automatically include rate limit headers in all responses.
automatically include rate limit and quota headers in all responses.
Usage:
class MyView(RateLimitHeadersMixin, APIView):
@@ -160,9 +235,10 @@ class RateLimitHeadersMixin:
"""
def finalize_response(self, request, response, *args, **kwargs):
"""Add rate limit headers to the response."""
"""Add rate limit and quota headers to the response."""
response = super().finalize_response(request, response, *args, **kwargs)
# Rate limit headers
rate_limit_info = getattr(request, 'rate_limit_info', None)
if rate_limit_info:
response['X-RateLimit-Limit'] = rate_limit_info['limit']
@@ -171,19 +247,47 @@ class RateLimitHeadersMixin:
response['X-RateLimit-Burst-Limit'] = rate_limit_info['burst_limit']
response['X-RateLimit-Burst-Remaining'] = rate_limit_info['burst_remaining']
# Daily quota headers
quota_info = getattr(request, 'quota_info', None)
if quota_info:
if quota_info.get('is_unlimited'):
response['X-Quota-Limit'] = 'unlimited'
response['X-Quota-Remaining'] = 'unlimited'
else:
response['X-Quota-Limit'] = quota_info['limit']
response['X-Quota-Remaining'] = quota_info['remaining']
return response
def get_throttle_response_data(request):
def get_throttle_response_data(request, quota_exceeded=False):
"""
Get data for a 429 Too Many Requests response.
Args:
request: The HTTP request object
quota_exceeded: If True, the daily quota was exceeded (not rate limit)
Returns:
dict: Response data with error details and retry info
"""
if quota_exceeded:
# Daily quota exceeded
quota_info = getattr(request, 'quota_info', {})
from django.utils import timezone
now = timezone.now()
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
midnight += timezone.timedelta(days=1)
retry_after = int((midnight - now).total_seconds())
return {
'error': 'quota_exceeded',
'message': 'Daily API quota exceeded. Your quota resets at midnight UTC.',
'retry_after': retry_after,
'quota_limit': quota_info.get('limit', 0),
}
# Standard rate limit exceeded
rate_limit_info = getattr(request, 'rate_limit_info', {})
reset_time = rate_limit_info.get('reset', int(time.time()) + 60)
retry_after = max(1, reset_time - int(time.time()))

View File

@@ -169,14 +169,12 @@ def current_business_view(request):
'webhooks': tenant.has_feature('integrations_enabled'),
'api_access': tenant.has_feature('api_access'),
'custom_domain': tenant.has_feature('custom_domain'),
'custom_branding': tenant.has_feature('custom_branding'),
'remove_branding': tenant.has_feature('remove_branding'),
'white_label': tenant.has_feature('can_white_label'),
'custom_oauth': tenant.has_feature('can_manage_oauth'),
'automations': tenant.has_feature('can_use_automations'),
'can_create_automations': tenant.has_feature('can_create_automations'),
'tasks': tenant.has_feature('can_use_tasks'),
'export_data': tenant.has_feature('can_export_data'),
'video_conferencing': tenant.has_feature('can_add_video_conferencing'),
'two_factor_auth': tenant.has_feature('team_permissions'),
'masked_calling': tenant.has_feature('masked_calling_enabled'),
'pos_system': tenant.has_feature('can_use_pos'),

View File

@@ -24,6 +24,7 @@ from .serializers import (
)
from .services import LocationService
from .models import Service
from smoothschedule.billing.services.quota import QuotaService
from smoothschedule.identity.core.permissions import HasQuota
from smoothschedule.identity.core.mixins import (
TenantFilteredQuerySetMixin,
@@ -497,13 +498,20 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
def perform_create(self, serializer):
"""
Create event with automatic availability validation.
Create event with automatic availability validation and quota tracking.
The EventSerializer.validate() method calls AvailabilityService
to check if resources have capacity. If not, DRF automatically
returns 400 Bad Request with error details.
After successful creation, increments the tenant's appointment quota.
"""
serializer.save(created_by=self.request.user)
event = serializer.save(created_by=self.request.user)
# Track quota usage (if tenant context exists)
tenant = getattr(self.request, 'tenant', None)
if tenant:
QuotaService.increment_appointment_count(tenant, count=1)
def perform_update(self, serializer):
"""
@@ -514,6 +522,21 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
"""
serializer.save()
def perform_destroy(self, instance):
"""
Delete event and decrement quota.
Note: Overage counts are NOT decremented (once billed, stays billed).
"""
tenant = getattr(self.request, 'tenant', None)
# Delete the event
instance.delete()
# Decrement quota usage
if tenant:
QuotaService.decrement_appointment_count(tenant, count=1)
@action(detail=True, methods=['post'])
def set_status(self, request, pk=None):
"""
@@ -559,6 +582,8 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
status_machine = StatusMachine(tenant, request.user)
old_status = event.status
try:
event = status_machine.transition(
event=event,
@@ -570,6 +595,10 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
skip_notifications=skip_notifications,
)
# Decrement quota when event is canceled (unless it was already canceled)
if new_status == Event.Status.CANCELED and old_status != Event.Status.CANCELED:
QuotaService.decrement_appointment_count(tenant, count=1)
serializer = self.get_serializer(event)
return Response({
'success': True,