Compare commits
6 Commits
aca4a7426e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc63cf4fce | ||
|
|
f13a40e4bc | ||
|
|
1d1cfbb164 | ||
|
|
174cc94b42 | ||
|
|
edc896b10e | ||
|
|
76be5377d9 |
@@ -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 = {
|
||||
|
||||
@@ -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...</>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
150
frontend/src/components/AppointmentQuotaBanner.tsx
Normal file
150
frontend/src/components/AppointmentQuotaBanner.tsx
Normal 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;
|
||||
@@ -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>}
|
||||
|
||||
179
frontend/src/components/StorageQuotaBanner.tsx
Normal file
179
frontend/src/components/StorageQuotaBanner.tsx
Normal 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;
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
116
frontend/src/hooks/usePlatformEmailTemplates.ts
Normal file
116
frontend/src/hooks/usePlatformEmailTemplates.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
178
frontend/src/hooks/usePlatformStaffInvitations.ts
Normal file
178
frontend/src/hooks/usePlatformStaffInvitations.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
111
frontend/src/hooks/useQuotaStatus.ts
Normal file
111
frontend/src/hooks/useQuotaStatus.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
635
frontend/src/pages/platform/PlatformEmailTemplates.tsx
Normal file
635
frontend/src/pages/platform/PlatformEmailTemplates.tsx
Normal 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;
|
||||
@@ -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} • Invited by {inv.invited_by || 'Unknown'} • 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>
|
||||
);
|
||||
};
|
||||
|
||||
334
frontend/src/pages/platform/PlatformStaffInvitePage.tsx
Normal file
334
frontend/src/pages/platform/PlatformStaffInvitePage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
])
|
||||
|
||||
594
smoothschedule/smoothschedule/billing/services/quota.py
Normal file
594
smoothschedule/smoothschedule/billing/services/quota.py
Normal 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,
|
||||
}
|
||||
335
smoothschedule/smoothschedule/billing/services/storage.py
Normal file
335
smoothschedule/smoothschedule/billing/services/storage.py
Normal 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"
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
582
smoothschedule/smoothschedule/billing/tests/test_quota.py
Normal file
582
smoothschedule/smoothschedule/billing/tests/test_quota.py
Normal 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
|
||||
546
smoothschedule/smoothschedule/billing/tests/test_storage.py
Normal file
546
smoothschedule/smoothschedule/billing/tests/test_storage.py
Normal 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"
|
||||
@@ -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']
|
||||
|
||||
@@ -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': {}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, '')
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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': {}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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/', {
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user