Compare commits
12 Commits
47657e7076
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc63cf4fce | ||
|
|
f13a40e4bc | ||
|
|
1d1cfbb164 | ||
|
|
174cc94b42 | ||
|
|
edc896b10e | ||
|
|
76be5377d9 | ||
|
|
aca4a7426e | ||
|
|
9b251c696e | ||
|
|
35add28a48 | ||
|
|
0f57b30856 | ||
|
|
acff2028ea | ||
|
|
9689881ebb |
@@ -2,6 +2,7 @@ import { LockKeyhole } from 'lucide-react';
|
|||||||
import { ComponentType, SVGProps } from 'react';
|
import { ComponentType, SVGProps } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useEmbedding } from '@/components/embed-provider';
|
||||||
import { buttonVariants } from '@/components/ui/button';
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
import { Dot } from '@/components/ui/dot';
|
import { Dot } from '@/components/ui/dot';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
|
import { authenticationSession } from '@/lib/authentication-session';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type SidebarItemType = {
|
export type SidebarItemType = {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const AppSidebarHeader = () => {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: 'ghost', size: 'icon' }),
|
buttonVariants({ variant: 'ghost', size: 'icon' }),
|
||||||
'w-full flex items-center justify-center h-9',
|
'w-full flex items-center justify-center h-[52px]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -54,7 +54,7 @@ export const AppSidebarHeader = () => {
|
|||||||
alt={t('home')}
|
alt={t('home')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'object-contain',
|
'object-contain',
|
||||||
state === 'collapsed' ? 'h-5 w-5' : 'w-full h-9',
|
state === 'collapsed' ? 'h-5 w-5' : 'w-full h-[52px]',
|
||||||
)}
|
)}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,14 +9,22 @@ const AuthenticatePage = () => {
|
|||||||
|
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
const response = searchParams.get('response');
|
const response = searchParams.get('response');
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const redirectTo = searchParams.get('redirect') || '/flows';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (response) {
|
if (response) {
|
||||||
|
// Handle full response object (legacy)
|
||||||
const decodedResponse = JSON.parse(response);
|
const decodedResponse = JSON.parse(response);
|
||||||
authenticationSession.saveResponse(decodedResponse, false);
|
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...</>;
|
return <>Please wait...</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const ShowPoweredBy = ({ show, position = 'sticky' }: ShowPoweredByProps) => {
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className=" text-sm transition">Built with</div>
|
<div className=" text-sm transition">Powered by</div>
|
||||||
<div className="justify-center flex items-center gap-1">
|
<div className="justify-center flex items-center gap-1">
|
||||||
<svg
|
<svg
|
||||||
width={15}
|
width={15}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ export const authenticationSession = {
|
|||||||
ApStorage.getInstance().setItem(tokenKey, response.token);
|
ApStorage.getInstance().setItem(tokenKey, response.token);
|
||||||
window.dispatchEvent(new Event('storage'));
|
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 {
|
isJwtExpired(token: string): boolean {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
|
|
||||||
import { useEmbedding } from '../components/embed-provider';
|
import { useEmbedding } from '../components/embed-provider';
|
||||||
|
|
||||||
|
import { authenticationSession } from './authentication-session';
|
||||||
|
|
||||||
export const useNewWindow = () => {
|
export const useNewWindow = () => {
|
||||||
const { embedState } = useEmbedding();
|
const { embedState } = useEmbedding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
if (embedState.isEmbedded) {
|
if (embedState.isEmbedded) {
|
||||||
|
// In embedded mode, navigate within the iframe (don't open new tabs)
|
||||||
return (route: string, searchParams?: string) =>
|
return (route: string, searchParams?: string) =>
|
||||||
navigate({
|
navigate({
|
||||||
pathname: route,
|
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';
|
export const FROM_QUERY_PARAM = 'from';
|
||||||
/**State param is for oauth2 flow, it is used to redirect to the page after login*/
|
/**State param is for oauth2 flow, it is used to redirect to the page after login*/
|
||||||
export const STATE_QUERY_PARAM = 'state';
|
export const STATE_QUERY_PARAM = 'state';
|
||||||
|
|||||||
@@ -67,6 +67,6 @@ export const defaultTheme = generateTheme({
|
|||||||
primaryColor: '#6e41e2',
|
primaryColor: '#6e41e2',
|
||||||
websiteName: 'Automation Builder',
|
websiteName: 'Automation Builder',
|
||||||
fullLogoUrl: 'https://smoothschedule.nyc3.digitaloceanspaces.com/static/images/automation-builder-logo-light.svg',
|
fullLogoUrl: 'https://smoothschedule.nyc3.digitaloceanspaces.com/static/images/automation-builder-logo-light.svg',
|
||||||
favIconUrl: 'https://cdn.activepieces.com/brand/favicon.ico',
|
favIconUrl: 'https://smoothschedule.nyc3.digitaloceanspaces.com/static/images/logo-branding.png',
|
||||||
logoIconUrl: 'https://cdn.activepieces.com/brand/logo.svg',
|
logoIconUrl: 'https://smoothschedule.nyc3.digitaloceanspaces.com/static/images/logo-branding.png',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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
|
# Proxy Admin requests to Django
|
||||||
location /admin/ {
|
location /admin/ {
|
||||||
proxy_pass http://django:5000;
|
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 PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
|
||||||
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
|
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
|
||||||
const PlatformStaffEmail = React.lazy(() => import('./pages/platform/PlatformStaffEmail'));
|
const PlatformStaffEmail = React.lazy(() => import('./pages/platform/PlatformStaffEmail'));
|
||||||
|
const PlatformEmailTemplates = React.lazy(() => import('./pages/platform/PlatformEmailTemplates'));
|
||||||
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
|
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
|
||||||
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
|
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
|
||||||
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
|
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
|
||||||
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
|
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
|
||||||
|
const PlatformStaffInvitePage = React.lazy(() => import('./pages/platform/PlatformStaffInvitePage'));
|
||||||
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
|
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
|
||||||
const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage'));
|
const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage'));
|
||||||
const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page
|
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="/verify-email" element={<VerifyEmail />} />
|
||||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||||
|
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
|
||||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
@@ -411,6 +414,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||||
|
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
|
||||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<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) {
|
if (isPlatformSubdomain) {
|
||||||
const path = window.location.pathname;
|
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 not an allowed path, render nothing
|
||||||
if (!allowedPaths.includes(path)) {
|
if (!allowedPaths.includes(path)) {
|
||||||
@@ -435,6 +439,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/platform/login" element={<PlatformLoginPage />} />
|
<Route path="/platform/login" element={<PlatformLoginPage />} />
|
||||||
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
@@ -460,6 +465,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||||
|
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
|
||||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
@@ -599,6 +605,7 @@ const AppContent: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<Route path="/platform/settings" element={<PlatformSettings />} />
|
<Route path="/platform/settings" element={<PlatformSettings />} />
|
||||||
<Route path="/platform/billing" element={<BillingManagement />} />
|
<Route path="/platform/billing" element={<BillingManagement />} />
|
||||||
|
<Route path="/platform/email-templates" element={<PlatformEmailTemplates />} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Route path="/platform/profile" element={<ProfileSettings />} />
|
<Route path="/platform/profile" element={<ProfileSettings />} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Check, Sliders, Search, X } from 'lucide-react';
|
import { Check, Sliders, Search, X } from 'lucide-react';
|
||||||
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
|
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
|
||||||
|
import { isWipFeature } from '../featureCatalog';
|
||||||
|
|
||||||
export interface FeaturePickerProps {
|
export interface FeaturePickerProps {
|
||||||
/** Available features from the API */
|
/** 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"
|
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{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>
|
</span>
|
||||||
<code className="text-xs text-gray-400 dark:text-gray-500 block mt-0.5 font-mono">
|
<code className="text-xs text-gray-400 dark:text-gray-500 block mt-0.5 font-mono">
|
||||||
{feature.code}
|
{feature.code}
|
||||||
@@ -219,8 +225,13 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
|||||||
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
|
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{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>
|
</span>
|
||||||
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||||
{feature.code}
|
{feature.code}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface FeatureCatalogEntry {
|
|||||||
description: string;
|
description: string;
|
||||||
type: FeatureType;
|
type: FeatureType;
|
||||||
category: FeatureCategory;
|
category: FeatureCategory;
|
||||||
|
/** Feature is work-in-progress and not yet enforced */
|
||||||
|
wip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeatureCategory =
|
export type FeatureCategory =
|
||||||
@@ -66,13 +68,6 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'communication',
|
category: 'communication',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
code: 'proxy_number_enabled',
|
|
||||||
name: 'Proxy Phone Numbers',
|
|
||||||
description: 'Use proxy phone numbers for customer communication',
|
|
||||||
type: 'boolean',
|
|
||||||
category: 'communication',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Payments & Commerce
|
// Payments & Commerce
|
||||||
{
|
{
|
||||||
@@ -88,6 +83,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
description: 'Use Point of Sale (POS) system',
|
description: 'Use Point of Sale (POS) system',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'access',
|
category: 'access',
|
||||||
|
wip: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Scheduling & Booking
|
// Scheduling & Booking
|
||||||
@@ -97,27 +93,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
description: 'Schedule recurring appointments',
|
description: 'Schedule recurring appointments',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'scheduling',
|
category: 'scheduling',
|
||||||
},
|
wip: true,
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Access & Features
|
// Access & Features
|
||||||
@@ -127,13 +103,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
description: 'Access the public API for integrations',
|
description: 'Access the public API for integrations',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'access',
|
category: 'access',
|
||||||
},
|
wip: true,
|
||||||
{
|
|
||||||
code: 'can_use_analytics',
|
|
||||||
name: 'Analytics Dashboard',
|
|
||||||
description: 'Access business analytics and reporting',
|
|
||||||
type: 'boolean',
|
|
||||||
category: 'access',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'can_use_tasks',
|
code: 'can_use_tasks',
|
||||||
@@ -149,19 +119,13 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'access',
|
category: 'access',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
code: 'customer_portal',
|
|
||||||
name: 'Customer Portal',
|
|
||||||
description: 'Branded self-service portal for customers',
|
|
||||||
type: 'boolean',
|
|
||||||
category: 'access',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
code: 'custom_fields',
|
code: 'custom_fields',
|
||||||
name: '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',
|
type: 'boolean',
|
||||||
category: 'access',
|
category: 'access',
|
||||||
|
wip: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'can_export_data',
|
code: 'can_export_data',
|
||||||
@@ -169,44 +133,26 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
description: 'Export data (appointments, customers, etc.)',
|
description: 'Export data (appointments, customers, etc.)',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'access',
|
category: 'access',
|
||||||
|
wip: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'can_use_mobile_app',
|
code: 'mobile_app_access',
|
||||||
name: 'Mobile App',
|
name: 'Mobile App',
|
||||||
description: 'Access the mobile app for field employees',
|
description: 'Access the mobile app for field employees',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'access',
|
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
|
// 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',
|
code: 'can_manage_oauth_credentials',
|
||||||
name: 'Manage OAuth',
|
name: 'Manage OAuth',
|
||||||
@@ -217,21 +163,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
|
|
||||||
// Branding
|
// Branding
|
||||||
{
|
{
|
||||||
code: 'custom_branding',
|
code: 'custom_domain',
|
||||||
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',
|
|
||||||
name: 'Custom Domain',
|
name: 'Custom Domain',
|
||||||
description: 'Configure a custom domain for your booking page',
|
description: 'Configure a custom domain for your booking page',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -245,6 +177,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
description: 'Get priority customer support response',
|
description: 'Get priority customer support response',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'support',
|
category: 'support',
|
||||||
|
wip: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Security & Compliance
|
// Security & Compliance
|
||||||
@@ -254,6 +187,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
description: 'Require two-factor authentication for users',
|
description: 'Require two-factor authentication for users',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
|
wip: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'sso_enabled',
|
code: 'sso_enabled',
|
||||||
@@ -261,20 +195,15 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
|||||||
description: 'Enable SSO authentication for team members',
|
description: 'Enable SSO authentication for team members',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
|
wip: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'can_delete_data',
|
code: 'audit_logs',
|
||||||
name: 'Delete Data',
|
name: 'Audit Logs',
|
||||||
description: 'Permanently delete data',
|
description: 'Track changes and download audit logs',
|
||||||
type: 'boolean',
|
|
||||||
category: 'security',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'can_download_logs',
|
|
||||||
name: 'Download Logs',
|
|
||||||
description: 'Download system logs',
|
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
|
wip: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -406,6 +335,14 @@ export const isCanonicalFeature = (code: string): boolean => {
|
|||||||
return featureMap.has(code);
|
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
|
* 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 React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
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 { User } from '../types';
|
||||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||||
|
|
||||||
@@ -81,6 +81,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
|||||||
<Shield size={18} className="shrink-0" />
|
<Shield size={18} className="shrink-0" />
|
||||||
{!isCollapsed && <span>{t('nav.staff')}</span>}
|
{!isCollapsed && <span>{t('nav.staff')}</span>}
|
||||||
</Link>
|
</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">
|
<Link to="/platform/billing" className={getNavClass('/platform/billing')} title="Billing Management">
|
||||||
<CreditCard size={18} className="shrink-0" />
|
<CreditCard size={18} className="shrink-0" />
|
||||||
{!isCollapsed && <span>Billing</span>}
|
{!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',
|
key: 'branding',
|
||||||
features: [
|
features: [
|
||||||
{ code: 'custom_domain', label: 'Custom domain' },
|
{ code: 'custom_domain', label: 'Custom domain' },
|
||||||
{ code: 'custom_branding', label: 'Custom branding' },
|
{ code: 'can_white_label', label: 'White label branding' },
|
||||||
{ code: 'remove_branding', label: 'Remove branding' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Key, AlertCircle } from 'lucide-react';
|
import { Key, AlertCircle } from 'lucide-react';
|
||||||
import { useBillingFeatures, BillingFeature, FEATURE_CATEGORY_META } from '../../hooks/useBillingPlans';
|
import { useBillingFeatures, BillingFeature, FEATURE_CATEGORY_META } from '../../hooks/useBillingPlans';
|
||||||
|
import { isWipFeature } from '../../billing/featureCatalog';
|
||||||
|
|
||||||
export interface DynamicFeaturesEditorProps {
|
export interface DynamicFeaturesEditorProps {
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +63,11 @@ export interface DynamicFeaturesEditorProps {
|
|||||||
* Number of columns (default: 3)
|
* Number of columns (default: 3)
|
||||||
*/
|
*/
|
||||||
columns?: 2 | 3 | 4;
|
columns?: 2 | 3 | 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable all inputs (for read-only mode)
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
|
const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
|
||||||
@@ -74,6 +80,7 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
|
|||||||
headerTitle = 'Features & Permissions',
|
headerTitle = 'Features & Permissions',
|
||||||
showDescriptions = false,
|
showDescriptions = false,
|
||||||
columns = 3,
|
columns = 3,
|
||||||
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { data: features, isLoading, error } = useBillingFeatures();
|
const { data: features, isLoading, error } = useBillingFeatures();
|
||||||
|
|
||||||
@@ -223,12 +230,13 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
|
|||||||
|
|
||||||
if (feature.feature_type === 'boolean') {
|
if (feature.feature_type === 'boolean') {
|
||||||
const isChecked = currentValue === true;
|
const isChecked = currentValue === true;
|
||||||
|
const isInputDisabled = isDisabled || disabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={feature.code}
|
key={feature.code}
|
||||||
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
|
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'
|
? 'opacity-50 cursor-not-allowed'
|
||||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
@@ -237,12 +245,17 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={(e) => handleChange(feature, e.target.checked)}
|
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"
|
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">
|
<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}
|
{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>
|
</span>
|
||||||
{showDescriptions && feature.description && (
|
{showDescriptions && feature.description && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={feature.code}
|
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}
|
{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>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -275,7 +293,8 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
|
|||||||
const val = parseInt(e.target.value);
|
const val = parseInt(e.target.value);
|
||||||
handleChange(feature, val === -1 ? null : val);
|
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>
|
</div>
|
||||||
{showDescriptions && (
|
{showDescriptions && (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Key } from 'lucide-react';
|
import { Key } from 'lucide-react';
|
||||||
|
import { isWipFeature } from '../../billing/featureCatalog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission definition with metadata
|
* Permission definition with metadata
|
||||||
@@ -117,21 +118,21 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|||||||
category: 'customization',
|
category: 'customization',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'remove_branding',
|
key: 'white_label',
|
||||||
planKey: 'can_white_label',
|
planKey: 'can_white_label',
|
||||||
businessKey: 'can_white_label',
|
businessKey: 'can_white_label',
|
||||||
label: 'Remove Branding',
|
label: 'White Label',
|
||||||
description: 'Remove SmoothSchedule branding from customer-facing pages',
|
description: 'Customize branding and remove SmoothSchedule branding',
|
||||||
category: 'customization',
|
category: 'customization',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Plugins & Automation
|
// Automations
|
||||||
{
|
{
|
||||||
key: 'plugins',
|
key: 'automations',
|
||||||
planKey: 'can_use_plugins',
|
planKey: 'can_use_automations',
|
||||||
businessKey: 'can_use_plugins',
|
businessKey: 'can_use_automations',
|
||||||
label: 'Use Plugins',
|
label: 'Automations',
|
||||||
description: 'Install and use marketplace plugins',
|
description: 'Install and use marketplace automations',
|
||||||
category: 'plugins',
|
category: 'plugins',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,16 +142,16 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|||||||
label: 'Scheduled Tasks',
|
label: 'Scheduled Tasks',
|
||||||
description: 'Create automated scheduled tasks',
|
description: 'Create automated scheduled tasks',
|
||||||
category: 'plugins',
|
category: 'plugins',
|
||||||
dependsOn: 'plugins',
|
dependsOn: 'automations',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'create_plugins',
|
key: 'create_automations',
|
||||||
planKey: 'can_create_plugins',
|
planKey: 'can_create_automations',
|
||||||
businessKey: 'can_create_plugins',
|
businessKey: 'can_create_automations',
|
||||||
label: 'Create Plugins',
|
label: 'Create Automations',
|
||||||
description: 'Build custom plugins',
|
description: 'Build custom automations',
|
||||||
category: 'plugins',
|
category: 'plugins',
|
||||||
dependsOn: 'plugins',
|
dependsOn: 'automations',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Advanced Features
|
// Advanced Features
|
||||||
@@ -172,7 +173,7 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'calendar_sync',
|
key: 'calendar_sync',
|
||||||
planKey: 'calendar_sync',
|
planKey: 'can_use_calendar_sync',
|
||||||
businessKey: 'can_use_calendar_sync',
|
businessKey: 'can_use_calendar_sync',
|
||||||
label: 'Calendar Sync',
|
label: 'Calendar Sync',
|
||||||
description: 'Sync with Google Calendar, etc.',
|
description: 'Sync with Google Calendar, etc.',
|
||||||
@@ -186,14 +187,6 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|||||||
description: 'Export data to CSV/Excel',
|
description: 'Export data to CSV/Excel',
|
||||||
category: 'advanced',
|
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',
|
key: 'advanced_reporting',
|
||||||
planKey: 'advanced_reporting',
|
planKey: 'advanced_reporting',
|
||||||
@@ -240,8 +233,8 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|||||||
key: 'sso_enabled',
|
key: 'sso_enabled',
|
||||||
planKey: 'sso_enabled',
|
planKey: 'sso_enabled',
|
||||||
businessKey: 'sso_enabled',
|
businessKey: 'sso_enabled',
|
||||||
label: 'SSO / SAML',
|
label: 'Single Sign-On (SSO)',
|
||||||
description: 'Single sign-on integration',
|
description: 'Enable SSO/SAML authentication for team members',
|
||||||
category: 'enterprise',
|
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"
|
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">
|
<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}
|
{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>
|
</span>
|
||||||
{showDescriptions && def.description && (
|
{showDescriptions && def.description && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
<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
|
// Platform
|
||||||
can_api_access: 'api_access',
|
can_api_access: 'api_access',
|
||||||
can_use_custom_domain: 'custom_domain',
|
can_use_custom_domain: 'custom_domain',
|
||||||
can_white_label: 'remove_branding',
|
can_white_label: 'can_white_label',
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
can_accept_payments: 'payment_processing',
|
can_accept_payments: 'payment_processing',
|
||||||
@@ -328,8 +328,9 @@ export function planFeaturesToLegacyPermissions(
|
|||||||
case 'custom_domain':
|
case 'custom_domain':
|
||||||
permissions.can_use_custom_domain = value as boolean;
|
permissions.can_use_custom_domain = value as boolean;
|
||||||
break;
|
break;
|
||||||
case 'remove_branding':
|
case 'can_white_label':
|
||||||
permissions.can_white_label = value as boolean;
|
permissions.can_white_label = value as boolean;
|
||||||
|
permissions.can_customize_booking_page = value as boolean;
|
||||||
break;
|
break;
|
||||||
case 'payment_processing':
|
case 'payment_processing':
|
||||||
permissions.can_accept_payments = value as boolean;
|
permissions.can_accept_payments = value as boolean;
|
||||||
@@ -356,9 +357,6 @@ export function planFeaturesToLegacyPermissions(
|
|||||||
case 'audit_logs':
|
case 'audit_logs':
|
||||||
permissions.can_download_logs = value as boolean;
|
permissions.can_download_logs = value as boolean;
|
||||||
break;
|
break;
|
||||||
case 'custom_branding':
|
|
||||||
permissions.can_customize_booking_page = value as boolean;
|
|
||||||
break;
|
|
||||||
case 'recurring_appointments':
|
case 'recurring_appointments':
|
||||||
permissions.can_book_repeated_events = value as boolean;
|
permissions.can_book_repeated_events = value as boolean;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -60,13 +60,12 @@ export const useCurrentBusiness = () => {
|
|||||||
webhooks: false,
|
webhooks: false,
|
||||||
api_access: false,
|
api_access: false,
|
||||||
custom_domain: false,
|
custom_domain: false,
|
||||||
remove_branding: false,
|
white_label: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
automations: false,
|
automations: false,
|
||||||
can_create_automations: false,
|
can_create_automations: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
export_data: false,
|
export_data: false,
|
||||||
video_conferencing: false,
|
|
||||||
two_factor_auth: false,
|
two_factor_auth: false,
|
||||||
masked_calling: false,
|
masked_calling: false,
|
||||||
pos_system: false,
|
pos_system: false,
|
||||||
|
|||||||
@@ -81,14 +81,12 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
|
|||||||
webhooks: 'Webhooks',
|
webhooks: 'Webhooks',
|
||||||
api_access: 'API Access',
|
api_access: 'API Access',
|
||||||
custom_domain: 'Custom Domain',
|
custom_domain: 'Custom Domain',
|
||||||
custom_branding: 'Custom Branding',
|
white_label: 'White Label',
|
||||||
remove_branding: 'Remove Branding',
|
|
||||||
custom_oauth: 'Custom OAuth',
|
custom_oauth: 'Custom OAuth',
|
||||||
automations: 'Automations',
|
automations: 'Automations',
|
||||||
can_create_automations: 'Custom Automation Creation',
|
can_create_automations: 'Custom Automation Creation',
|
||||||
tasks: 'Scheduled Tasks',
|
tasks: 'Scheduled Tasks',
|
||||||
export_data: 'Data Export',
|
export_data: 'Data Export',
|
||||||
video_conferencing: 'Video Conferencing',
|
|
||||||
two_factor_auth: 'Two-Factor Authentication',
|
two_factor_auth: 'Two-Factor Authentication',
|
||||||
masked_calling: 'Masked Calling',
|
masked_calling: 'Masked Calling',
|
||||||
pos_system: 'POS System',
|
pos_system: 'POS System',
|
||||||
@@ -105,14 +103,12 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
|
|||||||
webhooks: 'Integrate with external services using webhooks',
|
webhooks: 'Integrate with external services using webhooks',
|
||||||
api_access: 'Access the SmoothSchedule API for custom integrations',
|
api_access: 'Access the SmoothSchedule API for custom integrations',
|
||||||
custom_domain: 'Use your own custom domain for your booking site',
|
custom_domain: 'Use your own custom domain for your booking site',
|
||||||
custom_branding: 'Customize branding colors, logo, and styling',
|
white_label: 'Customize branding and remove SmoothSchedule branding',
|
||||||
remove_branding: 'Remove SmoothSchedule branding from customer-facing pages',
|
|
||||||
custom_oauth: 'Configure your own OAuth credentials for social login',
|
custom_oauth: 'Configure your own OAuth credentials for social login',
|
||||||
automations: 'Automate repetitive tasks with custom workflows',
|
automations: 'Automate repetitive tasks with custom workflows',
|
||||||
can_create_automations: 'Create custom automations tailored to your business needs',
|
can_create_automations: 'Create custom automations tailored to your business needs',
|
||||||
tasks: 'Create scheduled tasks to automate execution',
|
tasks: 'Create scheduled tasks to automate execution',
|
||||||
export_data: 'Export your data to CSV or other formats',
|
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',
|
two_factor_auth: 'Require two-factor authentication for enhanced security',
|
||||||
masked_calling: 'Use masked phone numbers to protect privacy',
|
masked_calling: 'Use masked phone numbers to protect privacy',
|
||||||
pos_system: 'Process in-person payments with Point of Sale',
|
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",
|
"staff": "Staff",
|
||||||
"customer": "Customer"
|
"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": {
|
"settings": {
|
||||||
"title": "Platform Settings",
|
"title": "Platform Settings",
|
||||||
"description": "Configure platform-wide settings and integrations",
|
"description": "Configure platform-wide settings and integrations",
|
||||||
@@ -2777,8 +2845,7 @@
|
|||||||
"api_access": "API access",
|
"api_access": "API access",
|
||||||
"max_api_calls_per_day": "API calls/day",
|
"max_api_calls_per_day": "API calls/day",
|
||||||
"custom_domain": "Custom domain",
|
"custom_domain": "Custom domain",
|
||||||
"custom_branding": "Custom branding",
|
"can_white_label": "White label branding",
|
||||||
"remove_branding": "Remove branding",
|
|
||||||
"multi_location": "Multi-location management",
|
"multi_location": "Multi-location management",
|
||||||
"team_permissions": "Team permissions",
|
"team_permissions": "Team permissions",
|
||||||
"audit_logs": "Audit logs",
|
"audit_logs": "Audit logs",
|
||||||
@@ -3423,6 +3490,23 @@
|
|||||||
"autoArchiveWarning": "After the grace period, the oldest {{count}} {{type}} will be automatically archived.",
|
"autoArchiveWarning": "After the grace period, the oldest {{count}} {{type}} will be automatically archived.",
|
||||||
"noOverages": "You are within your plan limits.",
|
"noOverages": "You are within your plan limits.",
|
||||||
"resolved": "Resolved! Your usage is now within 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": {
|
"upgrade": {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import TrialBanner from '../components/TrialBanner';
|
|||||||
import SandboxBanner from '../components/SandboxBanner';
|
import SandboxBanner from '../components/SandboxBanner';
|
||||||
import QuotaWarningBanner from '../components/QuotaWarningBanner';
|
import QuotaWarningBanner from '../components/QuotaWarningBanner';
|
||||||
import QuotaOverageModal, { resetQuotaOverageModalDismissal } from '../components/QuotaOverageModal';
|
import QuotaOverageModal, { resetQuotaOverageModalDismissal } from '../components/QuotaOverageModal';
|
||||||
|
import AppointmentQuotaBanner from '../components/AppointmentQuotaBanner';
|
||||||
|
import StorageQuotaBanner from '../components/StorageQuotaBanner';
|
||||||
import { Business, User } from '../types';
|
import { Business, User } from '../types';
|
||||||
import MasqueradeBanner from '../components/MasqueradeBanner';
|
import MasqueradeBanner from '../components/MasqueradeBanner';
|
||||||
import OnboardingWizard from '../components/OnboardingWizard';
|
import OnboardingWizard from '../components/OnboardingWizard';
|
||||||
@@ -200,7 +202,7 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
|
|||||||
onStop={handleStopMasquerade}
|
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 && (
|
{user.quota_overages && user.quota_overages.length > 0 && (
|
||||||
<QuotaWarningBanner overages={user.quota_overages} />
|
<QuotaWarningBanner overages={user.quota_overages} />
|
||||||
)}
|
)}
|
||||||
@@ -208,6 +210,10 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
|
|||||||
{user.quota_overages && user.quota_overages.length > 0 && (
|
{user.quota_overages && user.quota_overages.length > 0 && (
|
||||||
<QuotaOverageModal overages={user.quota_overages} onDismiss={() => {}} />
|
<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 */}
|
{/* Sandbox mode banner */}
|
||||||
<SandboxBannerWrapper />
|
<SandboxBannerWrapper />
|
||||||
{/* Show trial banner if trial is active and payments not yet enabled */}
|
{/* 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
|
// Map settings pages to their required plan features
|
||||||
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = {
|
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/custom-domains': 'custom_domain',
|
||||||
'/dashboard/settings/api': 'api_access',
|
'/dashboard/settings/api': 'api_access',
|
||||||
'/dashboard/settings/authentication': 'custom_oauth',
|
'/dashboard/settings/authentication': 'custom_oauth',
|
||||||
|
|||||||
@@ -387,18 +387,6 @@ export default function Automations() {
|
|||||||
<RefreshCw className="h-5 w-5" />
|
<RefreshCw className="h-5 w-5" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const transformPuckDataForEditor = (puckData: any): any => {
|
|||||||
...item,
|
...item,
|
||||||
props: {
|
props: {
|
||||||
...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,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Send,
|
||||||
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { usePlatformUsers } from '../../hooks/usePlatform';
|
import { usePlatformUsers } from '../../hooks/usePlatform';
|
||||||
import { useCurrentUser } from '../../hooks/useAuth';
|
import { useCurrentUser } from '../../hooks/useAuth';
|
||||||
import EditPlatformUserModal from './components/EditPlatformUserModal';
|
import EditPlatformUserModal from './components/EditPlatformUserModal';
|
||||||
|
import {
|
||||||
|
usePlatformStaffInvitations,
|
||||||
|
useCreatePlatformStaffInvitation,
|
||||||
|
useResendPlatformStaffInvitation,
|
||||||
|
useCancelPlatformStaffInvitation,
|
||||||
|
type PlatformStaffRole,
|
||||||
|
type PlatformStaffInvitation,
|
||||||
|
} from '../../hooks/usePlatformStaffInvitations';
|
||||||
|
|
||||||
interface PlatformUser {
|
interface PlatformUser {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -32,11 +45,6 @@ interface PlatformUser {
|
|||||||
role: string;
|
role: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_superuser: boolean;
|
is_superuser: boolean;
|
||||||
permissions: {
|
|
||||||
can_approve_plugins?: boolean;
|
|
||||||
can_whitelist_urls?: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_login?: string;
|
last_login?: string;
|
||||||
}
|
}
|
||||||
@@ -47,8 +55,23 @@ const PlatformStaff: React.FC = () => {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedUser, setSelectedUser] = useState<PlatformUser | null>(null);
|
const [selectedUser, setSelectedUser] = useState<PlatformUser | null>(null);
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
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: 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)
|
// Filter to only show platform staff (not superusers, not business users)
|
||||||
const platformStaff = (allUsers || []).filter(
|
const platformStaff = (allUsers || []).filter(
|
||||||
@@ -92,6 +115,51 @@ const PlatformStaff: React.FC = () => {
|
|||||||
setIsEditModalOpen(true);
|
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) => {
|
const getRoleBadge = (role: string) => {
|
||||||
if (role === 'platform_manager') {
|
if (role === 'platform_manager') {
|
||||||
return (
|
return (
|
||||||
@@ -156,13 +224,10 @@ const PlatformStaff: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
||||||
onClick={() => {
|
onClick={() => setIsInviteModalOpen(true)}
|
||||||
// TODO: Implement create new staff member
|
|
||||||
alert('Create new staff member - coming soon');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Staff Member
|
Invite Staff Member
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +247,7 @@ const PlatformStaff: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* 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="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-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">
|
<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}
|
{platformStaff.filter((u: any) => u.role === 'platform_support').length}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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 */}
|
{/* Staff List */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
<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">
|
<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">
|
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Role
|
Role
|
||||||
</th>
|
</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">
|
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
@@ -254,27 +383,6 @@ const PlatformStaff: React.FC = () => {
|
|||||||
{/* Role */}
|
{/* Role */}
|
||||||
<td className="px-6 py-4">{getRoleBadge(user.role)}</td>
|
<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 */}
|
{/* Status */}
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{user.is_active ? (
|
{user.is_active ? (
|
||||||
@@ -341,6 +449,108 @@ const PlatformStaff: React.FC = () => {
|
|||||||
user={selectedUser}
|
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>
|
</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 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 { useUpdateBusiness, useChangeBusinessPlan } from '../../../hooks/usePlatform';
|
||||||
import {
|
import {
|
||||||
useBillingPlans,
|
useBillingPlans,
|
||||||
@@ -33,6 +33,9 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
const [loadingCustomTier, setLoadingCustomTier] = useState(false);
|
const [loadingCustomTier, setLoadingCustomTier] = useState(false);
|
||||||
const [deletingCustomTier, setDeletingCustomTier] = 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)
|
// Core form fields (non-feature fields only)
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -118,27 +121,33 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
setFeatureValues(featureDefaults);
|
setFeatureValues(featureDefaults);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset to plan defaults button handler
|
// Handle toggling custom features on/off
|
||||||
const handleResetToPlanDefaults = async () => {
|
const handleCustomFeaturesToggle = async (enabled: boolean) => {
|
||||||
if (!business) return;
|
if (!business) return;
|
||||||
|
|
||||||
// If custom tier exists, delete it
|
if (enabled) {
|
||||||
if (customTier) {
|
// Enable custom features - just toggle the state
|
||||||
setDeletingCustomTier(true);
|
// Custom tier will be created when saving
|
||||||
try {
|
setUseCustomFeatures(true);
|
||||||
await deleteCustomTier(business.id);
|
} else {
|
||||||
setCustomTier(null);
|
// Disable custom features - delete custom tier and reset to plan defaults
|
||||||
} catch (error) {
|
if (customTier) {
|
||||||
console.error('Failed to delete custom tier:', error);
|
setDeletingCustomTier(true);
|
||||||
|
try {
|
||||||
|
await deleteCustomTier(business.id);
|
||||||
|
setCustomTier(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete custom tier:', error);
|
||||||
|
setDeletingCustomTier(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDeletingCustomTier(false);
|
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
|
// Map tier name/code to plan code
|
||||||
@@ -191,6 +200,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
const tier = await getCustomTier(businessId);
|
const tier = await getCustomTier(businessId);
|
||||||
console.log('[loadCustomTier] Got tier:', tier ? 'exists' : 'null');
|
console.log('[loadCustomTier] Got tier:', tier ? 'exists' : 'null');
|
||||||
setCustomTier(tier);
|
setCustomTier(tier);
|
||||||
|
setUseCustomFeatures(!!tier); // Enable custom features if tier exists
|
||||||
|
|
||||||
if (tier && billingFeatures) {
|
if (tier && billingFeatures) {
|
||||||
// Custom tier exists - load features from custom tier
|
// 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
|
// 404 means no custom tier exists - this is expected, load plan defaults
|
||||||
console.log('[loadCustomTier] Error (likely 404):', error);
|
console.log('[loadCustomTier] Error (likely 404):', error);
|
||||||
setCustomTier(null);
|
setCustomTier(null);
|
||||||
|
setUseCustomFeatures(false);
|
||||||
if (business) {
|
if (business) {
|
||||||
const planCode = tierToPlanCode(business.tier);
|
const planCode = tierToPlanCode(business.tier);
|
||||||
console.log('[loadCustomTier] Loading plan defaults for:', planCode);
|
console.log('[loadCustomTier] Loading plan defaults for:', planCode);
|
||||||
@@ -269,19 +280,25 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
if (!business || !billingFeatures) return;
|
if (!business || !billingFeatures) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert featureValues (keyed by tenant_field_name) to feature codes for the backend
|
// Only save custom tier if custom features are enabled
|
||||||
const featuresForBackend: Record<string, boolean | number | null> = {};
|
if (useCustomFeatures) {
|
||||||
for (const feature of billingFeatures) {
|
// Convert featureValues (keyed by tenant_field_name) to feature codes for the backend
|
||||||
if (!feature.tenant_field_name) continue;
|
const featuresForBackend: Record<string, boolean | number | null> = {};
|
||||||
const value = featureValues[feature.tenant_field_name];
|
for (const feature of billingFeatures) {
|
||||||
if (value !== undefined) {
|
if (!feature.tenant_field_name) continue;
|
||||||
// Use feature.code as the key for the backend
|
const value = featureValues[feature.tenant_field_name];
|
||||||
featuresForBackend[feature.code] = value;
|
if (value !== undefined) {
|
||||||
|
// Use feature.code as the key for the backend
|
||||||
|
featuresForBackend[feature.code] = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Save feature values to custom tier
|
// Save feature values to custom tier
|
||||||
await updateCustomTier(business.id, featuresForBackend);
|
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)
|
// Extract only the fields that the update endpoint accepts (exclude plan_code and feature values)
|
||||||
const { plan_code, ...coreFields } = editForm;
|
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">
|
<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...
|
Loading...
|
||||||
</span>
|
</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">
|
<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} />
|
<AlertCircle size={12} />
|
||||||
Custom Tier
|
Custom Features
|
||||||
</span>
|
</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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -374,24 +391,9 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
|
|
||||||
{/* Subscription Plan */}
|
{/* Subscription Plan */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
Subscription Plan
|
||||||
Subscription Plan
|
</label>
|
||||||
</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>
|
|
||||||
<select
|
<select
|
||||||
value={editForm.plan_code}
|
value={editForm.plan_code}
|
||||||
onChange={(e) => handlePlanChange(e.target.value)}
|
onChange={(e) => handlePlanChange(e.target.value)}
|
||||||
@@ -417,12 +419,34 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{customTier
|
Changing plan will auto-update limits and permissions to plan defaults
|
||||||
? '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'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Limits & Quotas - Dynamic from billing system */}
|
||||||
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<DynamicFeaturesEditor
|
<DynamicFeaturesEditor
|
||||||
@@ -434,6 +458,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
headerTitle="Limits & Quotas"
|
headerTitle="Limits & Quotas"
|
||||||
showDescriptions
|
showDescriptions
|
||||||
columns={4}
|
columns={4}
|
||||||
|
disabled={!useCustomFeatures}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -468,6 +493,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
featureType="boolean"
|
featureType="boolean"
|
||||||
headerTitle="Features & Permissions"
|
headerTitle="Features & Permissions"
|
||||||
showDescriptions
|
showDescriptions
|
||||||
|
disabled={!useCustomFeatures}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* - Basic info (name, email, username)
|
* - Basic info (name, email, username)
|
||||||
* - Password reset
|
* - Password reset
|
||||||
* - Role assignment
|
* - Role assignment
|
||||||
* - Permissions (can_approve_plugins, etc.)
|
|
||||||
* - Account status (active/inactive)
|
* - Account status (active/inactive)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -22,6 +21,9 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Trash2,
|
||||||
|
Archive,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import apiClient from '../../../api/client';
|
import apiClient from '../../../api/client';
|
||||||
@@ -38,10 +40,6 @@ interface EditPlatformUserModalProps {
|
|||||||
last_name: string;
|
last_name: string;
|
||||||
role: string;
|
role: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
permissions: {
|
|
||||||
can_approve_plugins?: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,13 +59,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
|
|||||||
const canEditRole = currentRole === 'superuser' ||
|
const canEditRole = currentRole === 'superuser' ||
|
||||||
(currentRole === 'platform_manager' && targetRole === 'platform_support');
|
(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
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -78,15 +69,15 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
|
|||||||
is_active: user.is_active,
|
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 [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [passwordError, setPasswordError] = useState('');
|
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
|
// Update mutation
|
||||||
const updateMutation = useMutation({
|
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
|
// Reset form when user changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -110,13 +126,11 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
is_active: user.is_active,
|
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('');
|
setPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
setPasswordError('');
|
setPasswordError('');
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setShowArchiveConfirm(false);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -137,7 +151,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
|
|||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
...formData,
|
...formData,
|
||||||
permissions: permissions,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only include password if changed
|
// Only include password if changed
|
||||||
@@ -148,13 +161,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
|
|||||||
updateMutation.mutate(updateData);
|
updateMutation.mutate(updateData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePermissionToggle = (permission: string) => {
|
|
||||||
setPermissions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[permission]: !prev[permission as keyof typeof prev],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -293,56 +299,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Password Reset */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
<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 */}
|
{/* Actions */}
|
||||||
<div className="mt-6 flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-700">
|
<div className="mt-6 flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<button
|
{/* Left side - Destructive actions */}
|
||||||
type="button"
|
<div className="flex items-center gap-2">
|
||||||
onClick={onClose}
|
{canDelete && !showDeleteConfirm && !showArchiveConfirm && (
|
||||||
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"
|
<>
|
||||||
>
|
<button
|
||||||
Cancel
|
type="button"
|
||||||
</button>
|
onClick={() => setShowArchiveConfirm(true)}
|
||||||
<button
|
disabled={!user.is_active}
|
||||||
type="submit"
|
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"
|
||||||
disabled={updateMutation.isPending}
|
title={!user.is_active ? 'User is already archived' : 'Archive user'}
|
||||||
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"
|
>
|
||||||
>
|
<Archive className="w-4 h-4" />
|
||||||
<Save className="w-4 h-4" />
|
Archive
|
||||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
</button>
|
||||||
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -186,17 +186,16 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
|
|||||||
return {
|
return {
|
||||||
max_users: getIntegerFeature(features, 'max_users') ?? TIER_DEFAULTS[planCode]?.max_users ?? 5,
|
max_users: getIntegerFeature(features, 'max_users') ?? TIER_DEFAULTS[planCode]?.max_users ?? 5,
|
||||||
max_resources: getIntegerFeature(features, 'max_resources') ?? TIER_DEFAULTS[planCode]?.max_resources ?? 10,
|
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_accept_payments: getBooleanFeature(features, 'payment_processing'),
|
||||||
can_use_custom_domain: getBooleanFeature(features, 'custom_domain'),
|
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_api_access: getBooleanFeature(features, 'api_access'),
|
||||||
can_add_video_conferencing: getBooleanFeature(features, 'integrations_enabled'),
|
|
||||||
can_use_sms_reminders: getBooleanFeature(features, 'sms_enabled'),
|
can_use_sms_reminders: getBooleanFeature(features, 'sms_enabled'),
|
||||||
can_use_masked_phone_numbers: getBooleanFeature(features, 'masked_calling_enabled'),
|
can_use_masked_phone_numbers: getBooleanFeature(features, 'masked_calling_enabled'),
|
||||||
can_use_plugins: true, // Always enabled
|
can_use_automations: getBooleanFeature(features, 'can_use_automations') ?? true,
|
||||||
can_use_tasks: true, // Always enabled
|
can_use_tasks: getBooleanFeature(features, 'can_use_tasks') ?? true,
|
||||||
can_create_plugins: getBooleanFeature(features, 'api_access'),
|
can_create_automations: getBooleanFeature(features, 'can_create_automations'),
|
||||||
can_use_webhooks: getBooleanFeature(features, 'integrations_enabled'),
|
can_use_webhooks: getBooleanFeature(features, 'integrations_enabled'),
|
||||||
can_use_calendar_sync: getBooleanFeature(features, 'integrations_enabled'),
|
can_use_calendar_sync: getBooleanFeature(features, 'integrations_enabled'),
|
||||||
can_export_data: getBooleanFeature(features, 'advanced_reporting'),
|
can_export_data: getBooleanFeature(features, 'advanced_reporting'),
|
||||||
|
|||||||
@@ -32,20 +32,12 @@ const mockUser = {
|
|||||||
last_name: 'User',
|
last_name: 'User',
|
||||||
role: 'platform_support',
|
role: 'platform_support',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
permissions: {
|
|
||||||
can_approve_plugins: false,
|
|
||||||
can_whitelist_urls: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSuperuser = {
|
const mockSuperuser = {
|
||||||
id: 2,
|
id: 2,
|
||||||
email: 'admin@example.com',
|
email: 'admin@example.com',
|
||||||
role: 'superuser',
|
role: 'superuser',
|
||||||
permissions: {
|
|
||||||
can_approve_plugins: true,
|
|
||||||
can_whitelist_urls: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createWrapper = () => {
|
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', () => {
|
describe('Account Status', () => {
|
||||||
it('shows status toggle', () => {
|
it('shows status toggle', () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ const SystemEmailTemplates: React.FC = () => {
|
|||||||
...item,
|
...item,
|
||||||
props: {
|
props: {
|
||||||
...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;
|
webhooks: boolean;
|
||||||
api_access: boolean;
|
api_access: boolean;
|
||||||
custom_domain: boolean;
|
custom_domain: boolean;
|
||||||
custom_branding: boolean;
|
white_label: boolean;
|
||||||
remove_branding: boolean;
|
|
||||||
custom_oauth: boolean;
|
custom_oauth: boolean;
|
||||||
automations: boolean;
|
automations: boolean;
|
||||||
can_create_automations: boolean;
|
can_create_automations: boolean;
|
||||||
tasks: boolean;
|
tasks: boolean;
|
||||||
export_data: boolean;
|
export_data: boolean;
|
||||||
video_conferencing: boolean;
|
|
||||||
two_factor_auth: boolean;
|
two_factor_auth: boolean;
|
||||||
masked_calling: boolean;
|
masked_calling: boolean;
|
||||||
pos_system: boolean;
|
pos_system: boolean;
|
||||||
@@ -1130,4 +1128,58 @@ export interface StaffEmailStats {
|
|||||||
count: number;
|
count: number;
|
||||||
unread: 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_DOCKER_CONTAINER=mailserver
|
||||||
MAIL_SERVER_SSH_KEY_PATH=/app/.ssh/id_ed25519
|
MAIL_SERVER_SSH_KEY_PATH=/app/.ssh/id_ed25519
|
||||||
MAIL_SERVER_SSH_KNOWN_HOSTS_PATH=/app/.ssh/known_hosts
|
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 |
|
| `advanced_reporting` | - | - | - | Yes | Yes |
|
||||||
| `team_permissions` | - | - | - | Yes | Yes |
|
| `team_permissions` | - | - | - | Yes | Yes |
|
||||||
| `audit_logs` | - | - | - | Yes | Yes |
|
| `audit_logs` | - | - | - | Yes | Yes |
|
||||||
| `custom_branding` | - | - | - | Yes | Yes |
|
| `can_white_label` | - | - | - | Yes | Yes |
|
||||||
| `white_label` | - | - | - | - | Yes |
|
|
||||||
| `remove_branding` | - | - | - | - | Yes |
|
|
||||||
| `multi_location` | - | - | - | - | Yes |
|
| `multi_location` | - | - | - | - | Yes |
|
||||||
| `priority_support` | - | - | - | - | Yes |
|
| `priority_support` | - | - | - | - | Yes |
|
||||||
| `dedicated_account_manager` | - | - | - | - | 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 |
|
| **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 |
|
| **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 |
|
| **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.
|
**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 |
|
| `email_enabled` | Can send email notifications |
|
||||||
| `masked_calling_enabled` | Can use masked phone calls |
|
| `masked_calling_enabled` | Can use masked phone calls |
|
||||||
| `api_access` | Can access REST API |
|
| `api_access` | Can access REST API |
|
||||||
| `custom_branding` | Can customize branding |
|
|
||||||
| `remove_branding` | Can remove "Powered by" |
|
|
||||||
| `custom_domain` | Can use custom domain |
|
| `custom_domain` | Can use custom domain |
|
||||||
|
| `can_white_label` | Customize branding and remove "Powered by" |
|
||||||
| `multi_location` | Can manage multiple locations |
|
| `multi_location` | Can manage multiple locations |
|
||||||
| `advanced_reporting` | Access to analytics dashboard |
|
| `advanced_reporting` | Access to analytics dashboard |
|
||||||
| `priority_support` | Priority support queue |
|
| `priority_support` | Priority support queue |
|
||||||
| `dedicated_account_manager` | Has dedicated AM |
|
| `dedicated_account_manager` | Has dedicated AM |
|
||||||
| `sla_guarantee` | SLA commitments |
|
| `sla_guarantee` | SLA commitments |
|
||||||
| `white_label` | Full white-label capabilities |
|
|
||||||
| `team_permissions` | Granular team permissions |
|
| `team_permissions` | Granular team permissions |
|
||||||
| `audit_logs` | Access to audit logs |
|
| `audit_logs` | Access to audit logs |
|
||||||
| `integrations_enabled` | Can use third-party integrations |
|
| `integrations_enabled` | Can use third-party integrations |
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ set -o errexit
|
|||||||
set -o nounset
|
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(
|
EMAIL_BACKEND = env(
|
||||||
"DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend",
|
"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
|
# WhiteNoise
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
@@ -109,7 +115,9 @@ EMAIL_BACKEND = env(
|
|||||||
INSTALLED_APPS += ["django_extensions"]
|
INSTALLED_APPS += ["django_extensions"]
|
||||||
# CELERY
|
# 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
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates
|
||||||
CELERY_TASK_EAGER_PROPAGATES = True
|
CELERY_TASK_EAGER_PROPAGATES = True
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from smoothschedule.billing.api.views import (
|
|||||||
InvoiceDetailView,
|
InvoiceDetailView,
|
||||||
InvoiceListView,
|
InvoiceListView,
|
||||||
PlanCatalogView,
|
PlanCatalogView,
|
||||||
|
QuotaStatusView,
|
||||||
|
QuotaDismissBannerView,
|
||||||
# Admin ViewSets
|
# Admin ViewSets
|
||||||
FeatureViewSet,
|
FeatureViewSet,
|
||||||
PlanViewSet,
|
PlanViewSet,
|
||||||
@@ -32,6 +34,12 @@ urlpatterns = [
|
|||||||
# /api/me/ endpoints (current user/business context)
|
# /api/me/ endpoints (current user/business context)
|
||||||
path("me/entitlements/", EntitlementsView.as_view(), name="me-entitlements"),
|
path("me/entitlements/", EntitlementsView.as_view(), name="me-entitlements"),
|
||||||
path("me/subscription/", CurrentSubscriptionView.as_view(), name="me-subscription"),
|
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)
|
# /api/billing/ endpoints (public catalog)
|
||||||
path("billing/plans/", PlanCatalogView.as_view(), name="plan-catalog"),
|
path("billing/plans/", PlanCatalogView.as_view(), name="plan-catalog"),
|
||||||
path("billing/addons/", AddOnCatalogView.as_view(), name="addon-catalog"),
|
path("billing/addons/", AddOnCatalogView.as_view(), name="addon-catalog"),
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ from smoothschedule.billing.models import (
|
|||||||
Subscription,
|
Subscription,
|
||||||
)
|
)
|
||||||
from smoothschedule.billing.services.entitlements import EntitlementService
|
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
|
from smoothschedule.platform.admin.permissions import IsPlatformAdmin
|
||||||
|
|
||||||
|
|
||||||
@@ -57,6 +59,114 @@ class EntitlementsView(APIView):
|
|||||||
return Response(entitlements)
|
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):
|
class CurrentSubscriptionView(APIView):
|
||||||
"""
|
"""
|
||||||
GET /api/me/subscription/
|
GET /api/me/subscription/
|
||||||
|
|||||||
@@ -66,9 +66,8 @@ FEATURES = [
|
|||||||
|
|
||||||
# --- Customization ---
|
# --- 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": "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": "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},
|
{"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 ---
|
# --- 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"},
|
{"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 ---
|
# --- 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": "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": "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},
|
{"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_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": "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},
|
{"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},
|
{"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": "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},
|
{"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 ---
|
# --- 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},
|
{"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 ---
|
# --- 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": "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},
|
# TODO: Implement can_require_2fa enforcement - Allow tenant admins to require 2FA for all team
|
||||||
{"code": "sla_guarantee", "name": "SSO / SAML", "description": "Single sign-on integration", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "sso_enabled", "display_order": 30},
|
# 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": "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,
|
"advanced_reporting": True,
|
||||||
"team_permissions": True,
|
"team_permissions": True,
|
||||||
"audit_logs": True,
|
"audit_logs": True,
|
||||||
"custom_branding": True,
|
"can_white_label": True,
|
||||||
"can_use_email_templates": True,
|
"can_use_email_templates": True,
|
||||||
"can_use_automations": True,
|
"can_use_automations": True,
|
||||||
"can_use_tasks": True,
|
"can_use_tasks": True,
|
||||||
@@ -260,7 +284,6 @@ PLANS = [
|
|||||||
"can_process_refunds": True,
|
"can_process_refunds": True,
|
||||||
"can_use_calendar_sync": True,
|
"can_use_calendar_sync": True,
|
||||||
"can_export_data": True,
|
"can_export_data": True,
|
||||||
"can_add_video_conferencing": True,
|
|
||||||
"can_create_packages": True,
|
"can_create_packages": True,
|
||||||
"can_use_pos": True,
|
"can_use_pos": True,
|
||||||
"can_use_contracts": True,
|
"can_use_contracts": True,
|
||||||
@@ -308,8 +331,7 @@ PLANS = [
|
|||||||
"advanced_reporting": True,
|
"advanced_reporting": True,
|
||||||
"team_permissions": True,
|
"team_permissions": True,
|
||||||
"audit_logs": True,
|
"audit_logs": True,
|
||||||
"custom_branding": True,
|
"can_white_label": True,
|
||||||
"remove_branding": True,
|
|
||||||
"multi_location": True,
|
"multi_location": True,
|
||||||
"priority_support": True,
|
"priority_support": True,
|
||||||
"sla_guarantee": True,
|
"sla_guarantee": True,
|
||||||
@@ -320,7 +342,6 @@ PLANS = [
|
|||||||
"can_process_refunds": True,
|
"can_process_refunds": True,
|
||||||
"can_use_calendar_sync": True,
|
"can_use_calendar_sync": True,
|
||||||
"can_export_data": True,
|
"can_export_data": True,
|
||||||
"can_add_video_conferencing": True,
|
|
||||||
"can_create_packages": True,
|
"can_create_packages": True,
|
||||||
"can_use_pos": True,
|
"can_use_pos": True,
|
||||||
"can_use_contracts": True,
|
"can_use_contracts": True,
|
||||||
@@ -407,14 +428,14 @@ ADDONS = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "remove_branding_addon",
|
"code": "white_label_addon",
|
||||||
"name": "Remove Branding",
|
"name": "White Label",
|
||||||
"description": "Remove all SmoothSchedule branding from customer-facing pages",
|
"description": "Customize branding and remove SmoothSchedule branding from customer-facing pages",
|
||||||
"price_monthly_cents": 9900,
|
"price_monthly_cents": 9900,
|
||||||
"price_one_time_cents": 0, # Recurring only
|
"price_one_time_cents": 0, # Recurring only
|
||||||
"is_stackable": False,
|
"is_stackable": False,
|
||||||
"features": [
|
"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'
|
help = 'Set up periodic Celery Beat tasks for billing operations'
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
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...')
|
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(
|
schedule_1am, _ = CrontabSchedule.objects.get_or_create(
|
||||||
minute='0',
|
minute='0',
|
||||||
hour='1',
|
hour='1',
|
||||||
@@ -26,12 +27,12 @@ class Command(BaseCommand):
|
|||||||
month_of_year='*',
|
month_of_year='*',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create periodic task
|
|
||||||
task, created = PeriodicTask.objects.update_or_create(
|
task, created = PeriodicTask.objects.update_or_create(
|
||||||
name='billing-check-grace-periods',
|
name='billing-check-grace-periods',
|
||||||
defaults={
|
defaults={
|
||||||
'task': 'smoothschedule.billing.tasks.check_subscription_grace_periods',
|
'task': 'smoothschedule.billing.tasks.check_subscription_grace_periods',
|
||||||
'crontab': schedule_1am,
|
'crontab': schedule_1am,
|
||||||
|
'interval': None,
|
||||||
'description': 'Check custom tier grace periods and manage subscription lapses (runs daily at 1 AM)',
|
'description': 'Check custom tier grace periods and manage subscription lapses (runs daily at 1 AM)',
|
||||||
'enabled': True,
|
'enabled': True,
|
||||||
}
|
}
|
||||||
@@ -40,9 +41,35 @@ class Command(BaseCommand):
|
|||||||
status = 'Created' if created else 'Updated'
|
status = 'Created' if created else 'Updated'
|
||||||
self.stdout.write(self.style.SUCCESS(f" {status}: {task.name}"))
|
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(self.style.SUCCESS('\nBilling tasks set up successfully!'))
|
||||||
self.stdout.write('\nTasks configured:')
|
self.stdout.write('\nTasks configured:')
|
||||||
self.stdout.write(' - billing-check-grace-periods: Daily at 1 AM')
|
self.stdout.write(' - billing-check-grace-periods: Daily at 1 AM')
|
||||||
self.stdout.write(' - Clears grace period when subscription becomes active')
|
self.stdout.write(' - Clears grace period when subscription becomes active')
|
||||||
self.stdout.write(' - Starts grace period when subscription becomes inactive')
|
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(' - 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 datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -678,3 +679,427 @@ class TenantCustomTier(models.Model):
|
|||||||
)
|
)
|
||||||
remaining = grace_end - timezone.now()
|
remaining = grace_end - timezone.now()
|
||||||
return max(0, remaining.days)
|
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.
|
Celery tasks for billing operations.
|
||||||
|
|
||||||
These tasks run periodically to manage subscription-related operations like
|
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
|
from datetime import timedelta
|
||||||
|
|
||||||
@@ -13,6 +13,89 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
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
|
@shared_task
|
||||||
def check_subscription_grace_periods():
|
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.
|
Tests for billing Celery tasks.
|
||||||
"""
|
"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
import pytest
|
import pytest
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from smoothschedule.billing.models import TenantCustomTier
|
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:
|
class TestCheckSubscriptionGracePeriods:
|
||||||
@@ -204,3 +208,100 @@ class TestCheckSubscriptionGracePeriods:
|
|||||||
assert result['grace_periods_started'] == 1 # Only ct2 succeeded
|
assert result['grace_periods_started'] == 1 # Only ct2 succeeded
|
||||||
assert len(result['errors']) == 1
|
assert len(result['errors']) == 1
|
||||||
assert "Database error" in result['errors'][0]
|
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.
|
Provides default Puck templates for all email types.
|
||||||
These are used when a tenant hasn't customized their templates.
|
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 = {
|
DEFAULT_TEMPLATES = {
|
||||||
@@ -925,6 +933,180 @@ DEFAULT_TEMPLATES = {
|
|||||||
'root': {}
|
'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',
|
'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
|
import logging
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
@@ -293,3 +307,140 @@ def send_html_email(
|
|||||||
html_message=html_message,
|
html_message=html_message,
|
||||||
fail_silently=fail_silently,
|
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',
|
'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
|
# Tag Mapping by Email Type
|
||||||
@@ -99,6 +122,7 @@ def get_tags_for_category(category: str) -> Dict[str, str]:
|
|||||||
'contract': CONTRACT_TAGS,
|
'contract': CONTRACT_TAGS,
|
||||||
'payment': PAYMENT_TAGS,
|
'payment': PAYMENT_TAGS,
|
||||||
'ticket': TICKET_TAGS,
|
'ticket': TICKET_TAGS,
|
||||||
|
'billing': BILLING_TAGS,
|
||||||
'welcome': {}, # Only base tags
|
'welcome': {}, # Only base tags
|
||||||
}
|
}
|
||||||
return category_tags.get(category, {})
|
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'
|
category = 'Payment'
|
||||||
elif tag in TICKET_TAGS:
|
elif tag in TICKET_TAGS:
|
||||||
category = 'Support Ticket'
|
category = 'Support Ticket'
|
||||||
|
elif tag in BILLING_TAGS:
|
||||||
|
category = 'Billing & Quota'
|
||||||
else:
|
else:
|
||||||
category = 'Other'
|
category = 'Other'
|
||||||
|
|
||||||
@@ -284,6 +310,7 @@ def get_all_tag_info() -> List[Dict[str, str]]:
|
|||||||
(CONTRACT_TAGS, 'Contract'),
|
(CONTRACT_TAGS, 'Contract'),
|
||||||
(PAYMENT_TAGS, 'Payment'),
|
(PAYMENT_TAGS, 'Payment'),
|
||||||
(TICKET_TAGS, 'Support Ticket'),
|
(TICKET_TAGS, 'Support Ticket'),
|
||||||
|
(BILLING_TAGS, 'Billing & Quota'),
|
||||||
]
|
]
|
||||||
|
|
||||||
seen_tags = set()
|
seen_tags = set()
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ class EmailType(str, Enum):
|
|||||||
TICKET_RESOLVED = 'ticket_resolved'
|
TICKET_RESOLVED = 'ticket_resolved'
|
||||||
"""Sent when ticket is marked as 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
|
# Utility Methods
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
@@ -112,6 +121,9 @@ class EmailType(str, Enum):
|
|||||||
cls.TICKET_ASSIGNED: 'ticket',
|
cls.TICKET_ASSIGNED: 'ticket',
|
||||||
cls.TICKET_REPLY: 'ticket',
|
cls.TICKET_REPLY: 'ticket',
|
||||||
cls.TICKET_RESOLVED: 'ticket',
|
cls.TICKET_RESOLVED: 'ticket',
|
||||||
|
# Billing
|
||||||
|
cls.QUOTA_WARNING: 'billing',
|
||||||
|
cls.STORAGE_WARNING: 'billing',
|
||||||
}
|
}
|
||||||
return categories.get(email_type, 'other')
|
return categories.get(email_type, 'other')
|
||||||
|
|
||||||
@@ -134,6 +146,8 @@ class EmailType(str, Enum):
|
|||||||
cls.TICKET_ASSIGNED: 'Ticket Assigned',
|
cls.TICKET_ASSIGNED: 'Ticket Assigned',
|
||||||
cls.TICKET_REPLY: 'Ticket Reply',
|
cls.TICKET_REPLY: 'Ticket Reply',
|
||||||
cls.TICKET_RESOLVED: 'Ticket Resolved',
|
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())
|
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_ASSIGNED: 'Notifies staff when a support ticket is assigned',
|
||||||
cls.TICKET_REPLY: 'Sent when someone replies to a support ticket',
|
cls.TICKET_REPLY: 'Sent when someone replies to a support ticket',
|
||||||
cls.TICKET_RESOLVED: 'Sent when a support ticket is resolved',
|
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, '')
|
return descriptions.get(email_type, '')
|
||||||
|
|
||||||
|
|||||||
@@ -250,35 +250,6 @@ class User(AbstractUser):
|
|||||||
return True
|
return True
|
||||||
return False
|
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):
|
def can_self_approve_time_off(self):
|
||||||
"""
|
"""
|
||||||
Check if user can self-approve time off requests.
|
Check if user can self-approve time off requests.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Unit tests for the User model.
|
|||||||
|
|
||||||
Tests cover:
|
Tests cover:
|
||||||
1. Role-related methods (is_platform_user, is_tenant_user, can_invite_staff, etc.)
|
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)
|
3. Property methods (full_name)
|
||||||
4. The save() method validation logic (tenant requirements for different roles)
|
4. The save() method validation logic (tenant requirements for different roles)
|
||||||
|
|
||||||
@@ -198,66 +198,6 @@ class TestCanAccessTickets:
|
|||||||
assert user.can_access_tickets() is True
|
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
|
# Time Off Self-Approval Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -268,99 +268,6 @@ class TestCanAccessTickets:
|
|||||||
assert user.can_access_tickets() is True
|
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
|
# 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,
|
'username': self.email_address,
|
||||||
'password': self.password,
|
'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 rest_framework import serializers
|
||||||
from smoothschedule.identity.core.models import Tenant, Domain
|
from smoothschedule.identity.core.models import Tenant, Domain
|
||||||
from smoothschedule.identity.users.models import User
|
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):
|
class PlatformSettingsSerializer(serializers.Serializer):
|
||||||
@@ -881,3 +885,109 @@ class PlatformEmailAddressUpdateSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
return instance
|
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
|
exc_info=True
|
||||||
)
|
)
|
||||||
return {'success': False, 'error': str(e)}
|
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 response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert 'Invalid role' in response.data['detail']
|
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):
|
def test_partial_update_sets_password(self):
|
||||||
"""Test partial_update can set password"""
|
"""Test partial_update can set password"""
|
||||||
request = self.factory.patch('/api/platform/users/1/', {
|
request = self.factory.patch('/api/platform/users/1/', {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from .views import (
|
|||||||
StripeWebhookRotateSecretView,
|
StripeWebhookRotateSecretView,
|
||||||
OAuthSettingsView,
|
OAuthSettingsView,
|
||||||
PlatformEmailAddressViewSet,
|
PlatformEmailAddressViewSet,
|
||||||
|
PlatformEmailTemplateViewSet,
|
||||||
|
PlatformStaffInvitationViewSet,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = 'platform'
|
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'tenant-invitations', TenantInvitationViewSet, basename='tenant-invitation')
|
||||||
router.register(r'subscription-plans', SubscriptionPlanViewSet, basename='subscription-plan')
|
router.register(r'subscription-plans', SubscriptionPlanViewSet, basename='subscription-plan')
|
||||||
router.register(r'email-addresses', PlatformEmailAddressViewSet, basename='email-address')
|
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 = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
@@ -54,4 +58,16 @@ urlpatterns = [
|
|||||||
TenantInvitationViewSet.as_view({'post': 'accept'}),
|
TenantInvitationViewSet.as_view({'post': 'accept'}),
|
||||||
name='tenant-invitation-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.identity.users.models import User
|
||||||
from smoothschedule.billing.models import TenantCustomTier
|
from smoothschedule.billing.models import TenantCustomTier
|
||||||
from smoothschedule.billing.api.serializers import TenantCustomTierSerializer
|
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 (
|
from .serializers import (
|
||||||
TenantSerializer,
|
TenantSerializer,
|
||||||
TenantCreateSerializer,
|
TenantCreateSerializer,
|
||||||
@@ -40,6 +43,9 @@ from .serializers import (
|
|||||||
PlatformEmailAddressSerializer,
|
PlatformEmailAddressSerializer,
|
||||||
PlatformEmailAddressCreateSerializer,
|
PlatformEmailAddressCreateSerializer,
|
||||||
PlatformEmailAddressUpdateSerializer,
|
PlatformEmailAddressUpdateSerializer,
|
||||||
|
PlatformEmailTemplateListSerializer,
|
||||||
|
PlatformEmailTemplateDetailSerializer,
|
||||||
|
PlatformEmailTemplatePreviewSerializer,
|
||||||
)
|
)
|
||||||
from .permissions import IsPlatformAdmin, IsPlatformUser
|
from .permissions import IsPlatformAdmin, IsPlatformUser
|
||||||
|
|
||||||
@@ -1046,7 +1052,7 @@ class PlatformUserViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = User.objects.all().order_by('-date_joined')
|
queryset = User.objects.all().order_by('-date_joined')
|
||||||
serializer_class = PlatformUserSerializer
|
serializer_class = PlatformUserSerializer
|
||||||
permission_classes = [IsAuthenticated, IsPlatformAdmin]
|
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):
|
def get_queryset(self):
|
||||||
"""Optionally filter by business or role"""
|
"""Optionally filter by business or role"""
|
||||||
@@ -1074,7 +1080,73 @@ class PlatformUserViewSet(viewsets.ModelViewSet):
|
|||||||
user.save(update_fields=['email_verified'])
|
user.save(update_fields=['email_verified'])
|
||||||
return Response({'status': '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):
|
def partial_update(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@@ -1114,23 +1186,6 @@ class PlatformUserViewSet(viewsets.ModelViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
setattr(instance, field, role_value)
|
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:
|
else:
|
||||||
setattr(instance, field, request.data[field])
|
setattr(instance, field, request.data[field])
|
||||||
|
|
||||||
@@ -1624,3 +1679,492 @@ class PlatformEmailAddressViewSet(viewsets.ModelViewSet):
|
|||||||
'skipped_count': len(skipped),
|
'skipped_count': len(skipped),
|
||||||
'message': f'Imported {len(imported)} email addresses, skipped {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
|
- Global: 1000 requests per hour per token
|
||||||
- Burst: 100 requests per minute (allows short bursts of traffic)
|
- 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:
|
Response Headers:
|
||||||
- X-RateLimit-Limit: Total requests allowed per hour
|
- X-RateLimit-Limit: Total requests allowed per hour
|
||||||
- X-RateLimit-Remaining: Requests remaining in current hour
|
- X-RateLimit-Remaining: Requests remaining in current hour
|
||||||
- X-RateLimit-Reset: Unix timestamp when the limit resets
|
- X-RateLimit-Reset: Unix timestamp when the limit resets
|
||||||
- X-RateLimit-Burst-Limit: Requests allowed per minute
|
- X-RateLimit-Burst-Limit: Requests allowed per minute
|
||||||
- X-RateLimit-Burst-Remaining: Requests remaining in current 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
|
import time
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from rest_framework.throttling import BaseThrottle
|
from rest_framework.throttling import BaseThrottle
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class GlobalBurstRateThrottle(BaseThrottle):
|
class GlobalBurstRateThrottle(BaseThrottle):
|
||||||
"""
|
"""
|
||||||
@@ -52,7 +61,8 @@ class GlobalBurstRateThrottle(BaseThrottle):
|
|||||||
"""
|
"""
|
||||||
Check if the request should be allowed.
|
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.
|
Stores rate limit info on the request for header generation.
|
||||||
"""
|
"""
|
||||||
self.now = time.time()
|
self.now = time.time()
|
||||||
@@ -90,7 +100,7 @@ class GlobalBurstRateThrottle(BaseThrottle):
|
|||||||
'burst_remaining': minute_remaining,
|
'burst_remaining': minute_remaining,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Must pass both checks
|
# Must pass both rate limit checks
|
||||||
if not hourly_allowed or not minute_allowed:
|
if not hourly_allowed or not minute_allowed:
|
||||||
# Determine which limit was exceeded for wait time
|
# Determine which limit was exceeded for wait time
|
||||||
if not hourly_allowed:
|
if not hourly_allowed:
|
||||||
@@ -99,8 +109,73 @@ class GlobalBurstRateThrottle(BaseThrottle):
|
|||||||
self.wait_time = minute_reset - self.now
|
self.wait_time = minute_reset - self.now
|
||||||
return False
|
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
|
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):
|
def _check_rate(self, scope, limit, duration):
|
||||||
"""
|
"""
|
||||||
Check if request is within rate limit for the given scope/duration.
|
Check if request is within rate limit for the given scope/duration.
|
||||||
@@ -149,10 +224,10 @@ class GlobalBurstRateThrottle(BaseThrottle):
|
|||||||
|
|
||||||
class RateLimitHeadersMixin:
|
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
|
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:
|
Usage:
|
||||||
class MyView(RateLimitHeadersMixin, APIView):
|
class MyView(RateLimitHeadersMixin, APIView):
|
||||||
@@ -160,9 +235,10 @@ class RateLimitHeadersMixin:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def finalize_response(self, request, response, *args, **kwargs):
|
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)
|
response = super().finalize_response(request, response, *args, **kwargs)
|
||||||
|
|
||||||
|
# Rate limit headers
|
||||||
rate_limit_info = getattr(request, 'rate_limit_info', None)
|
rate_limit_info = getattr(request, 'rate_limit_info', None)
|
||||||
if rate_limit_info:
|
if rate_limit_info:
|
||||||
response['X-RateLimit-Limit'] = rate_limit_info['limit']
|
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-Limit'] = rate_limit_info['burst_limit']
|
||||||
response['X-RateLimit-Burst-Remaining'] = rate_limit_info['burst_remaining']
|
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
|
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.
|
Get data for a 429 Too Many Requests response.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The HTTP request object
|
request: The HTTP request object
|
||||||
|
quota_exceeded: If True, the daily quota was exceeded (not rate limit)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Response data with error details and retry info
|
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', {})
|
rate_limit_info = getattr(request, 'rate_limit_info', {})
|
||||||
reset_time = rate_limit_info.get('reset', int(time.time()) + 60)
|
reset_time = rate_limit_info.get('reset', int(time.time()) + 60)
|
||||||
retry_after = max(1, reset_time - int(time.time()))
|
retry_after = max(1, reset_time - int(time.time()))
|
||||||
|
|||||||
@@ -169,14 +169,12 @@ def current_business_view(request):
|
|||||||
'webhooks': tenant.has_feature('integrations_enabled'),
|
'webhooks': tenant.has_feature('integrations_enabled'),
|
||||||
'api_access': tenant.has_feature('api_access'),
|
'api_access': tenant.has_feature('api_access'),
|
||||||
'custom_domain': tenant.has_feature('custom_domain'),
|
'custom_domain': tenant.has_feature('custom_domain'),
|
||||||
'custom_branding': tenant.has_feature('custom_branding'),
|
'white_label': tenant.has_feature('can_white_label'),
|
||||||
'remove_branding': tenant.has_feature('remove_branding'),
|
|
||||||
'custom_oauth': tenant.has_feature('can_manage_oauth'),
|
'custom_oauth': tenant.has_feature('can_manage_oauth'),
|
||||||
'automations': tenant.has_feature('can_use_automations'),
|
'automations': tenant.has_feature('can_use_automations'),
|
||||||
'can_create_automations': tenant.has_feature('can_create_automations'),
|
'can_create_automations': tenant.has_feature('can_create_automations'),
|
||||||
'tasks': tenant.has_feature('can_use_tasks'),
|
'tasks': tenant.has_feature('can_use_tasks'),
|
||||||
'export_data': tenant.has_feature('can_export_data'),
|
'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'),
|
'two_factor_auth': tenant.has_feature('team_permissions'),
|
||||||
'masked_calling': tenant.has_feature('masked_calling_enabled'),
|
'masked_calling': tenant.has_feature('masked_calling_enabled'),
|
||||||
'pos_system': tenant.has_feature('can_use_pos'),
|
'pos_system': tenant.has_feature('can_use_pos'),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .serializers import (
|
|||||||
)
|
)
|
||||||
from .services import LocationService
|
from .services import LocationService
|
||||||
from .models import Service
|
from .models import Service
|
||||||
|
from smoothschedule.billing.services.quota import QuotaService
|
||||||
from smoothschedule.identity.core.permissions import HasQuota
|
from smoothschedule.identity.core.permissions import HasQuota
|
||||||
from smoothschedule.identity.core.mixins import (
|
from smoothschedule.identity.core.mixins import (
|
||||||
TenantFilteredQuerySetMixin,
|
TenantFilteredQuerySetMixin,
|
||||||
@@ -497,13 +498,20 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def perform_create(self, serializer):
|
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
|
The EventSerializer.validate() method calls AvailabilityService
|
||||||
to check if resources have capacity. If not, DRF automatically
|
to check if resources have capacity. If not, DRF automatically
|
||||||
returns 400 Bad Request with error details.
|
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):
|
def perform_update(self, serializer):
|
||||||
"""
|
"""
|
||||||
@@ -514,6 +522,21 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
serializer.save()
|
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'])
|
@action(detail=True, methods=['post'])
|
||||||
def set_status(self, request, pk=None):
|
def set_status(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
@@ -559,6 +582,8 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
|||||||
|
|
||||||
status_machine = StatusMachine(tenant, request.user)
|
status_machine = StatusMachine(tenant, request.user)
|
||||||
|
|
||||||
|
old_status = event.status
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event = status_machine.transition(
|
event = status_machine.transition(
|
||||||
event=event,
|
event=event,
|
||||||
@@ -570,6 +595,10 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
|||||||
skip_notifications=skip_notifications,
|
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)
|
serializer = self.get_serializer(event)
|
||||||
return Response({
|
return Response({
|
||||||
'success': True,
|
'success': True,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 36" width="403" height="52">
|
||||||
|
<style>
|
||||||
|
.title { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; font-size: 18px; font-weight: 600; fill: #f3f4f6; }
|
||||||
|
.subtitle { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; font-size: 12px; fill: #9ca3af; }
|
||||||
|
.brand { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; font-size: 12px; font-weight: 500; fill: #a78bfa; }
|
||||||
|
</style>
|
||||||
|
<text x="0" y="15" class="title">Automation Builder</text>
|
||||||
|
<text x="0" y="32" class="subtitle">Powered by</text>
|
||||||
|
<g transform="translate(67, 22) scale(0.65)">
|
||||||
|
<path fill="#a78bfa" d="M6.46013 5.81759C5.30809 4.10962 5.75876 1.79113 7.46672 0.639093C9.17469 -0.512944 11.4932 -0.0622757 12.6452 1.64569L20.4261 13.1813C21.5781 14.8893 21.1274 17.2077 19.4195 18.3598C17.7115 19.5118 15.393 19.0611 14.241 17.3532L10.8676 12.3519C10.4339 11.8054 9.55114 11.8905 9.02108 12.4205C8.58152 12.8601 8.43761 13.9846 8.31301 14.9582C8.29474 15.1009 8.27689 15.2405 8.25858 15.3741C8.19097 16.0114 7.97092 16.6418 7.58762 17.2101C6.33511 19.067 3.81375 19.5565 1.95682 18.304C0.0998936 17.0515 -0.390738 14.5304 0.861776 12.6734C1.51136 11.7104 2.50224 11.1151 3.56472 10.9399L3.56322 10.9384C6.63307 10.4932 7.20222 7.02864 6.64041 6.08487L6.46013 5.81759Z"/>
|
||||||
|
</g>
|
||||||
|
<text x="82" y="32" class="brand">activepieces</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 36" width="403" height="52">
|
||||||
|
<style>
|
||||||
|
.title { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; font-size: 18px; font-weight: 600; fill: #1f2937; }
|
||||||
|
.subtitle { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; font-size: 12px; fill: #6b7280; }
|
||||||
|
.brand { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; font-size: 12px; font-weight: 500; fill: #6e41e2; }
|
||||||
|
</style>
|
||||||
|
<text x="0" y="15" class="title">Automation Builder</text>
|
||||||
|
<text x="0" y="32" class="subtitle">Powered by</text>
|
||||||
|
<g transform="translate(67, 22) scale(0.65)">
|
||||||
|
<path fill="#6e41e2" d="M6.46013 5.81759C5.30809 4.10962 5.75876 1.79113 7.46672 0.639093C9.17469 -0.512944 11.4932 -0.0622757 12.6452 1.64569L20.4261 13.1813C21.5781 14.8893 21.1274 17.2077 19.4195 18.3598C17.7115 19.5118 15.393 19.0611 14.241 17.3532L10.8676 12.3519C10.4339 11.8054 9.55114 11.8905 9.02108 12.4205C8.58152 12.8601 8.43761 13.9846 8.31301 14.9582C8.29474 15.1009 8.27689 15.2405 8.25858 15.3741C8.19097 16.0114 7.97092 16.6418 7.58762 17.2101C6.33511 19.067 3.81375 19.5565 1.95682 18.304C0.0998936 17.0515 -0.390738 14.5304 0.861776 12.6734C1.51136 11.7104 2.50224 11.1151 3.56472 10.9399L3.56322 10.9384C6.63307 10.4932 7.20222 7.02864 6.64041 6.08487L6.46013 5.81759Z"/>
|
||||||
|
</g>
|
||||||
|
<text x="82" y="32" class="brand">activepieces</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
Reference in New Issue
Block a user