143 Commits

Author SHA1 Message Date
poduck
18c9a69d75 fix: Store service prices in cents and fix contracts permission
- Update Service model to use price_cents/deposit_amount_cents as IntegerField
- Add @property methods for backward compatibility (price, deposit_amount return dollars)
- Update ServiceSerializer to convert dollars <-> cents on read/write
- Add migration to convert column types from numeric to integer
- Fix BusinessEditModal to properly use typed PlatformBusiness interface
- Add missing feature permission fields to PlatformBusiness TypeScript interface

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 03:37:13 -05:00
poduck
30ec150d90 feat: Add subscription/billing/entitlement system
Implements a complete billing system with:

Backend (Django):
- New billing app with models: Feature, Plan, PlanVersion, PlanFeature,
  Subscription, AddOnProduct, AddOnFeature, SubscriptionAddOn,
  EntitlementOverride, Invoice, InvoiceLine
- EntitlementService with resolution order: overrides > add-ons > plan
- Invoice generation service with immutable snapshots
- DRF API endpoints for entitlements, subscription, plans, invoices
- Data migrations to seed initial plans and convert existing tenants
- Bridge to legacy Tenant.has_feature() with fallback support
- 75 tests covering models, services, and API endpoints

Frontend (React):
- Billing API client (getEntitlements, getPlans, getInvoices, etc.)
- useEntitlements hook with hasFeature() and getLimit() helpers
- FeatureGate and LimitGate components for conditional rendering
- 29 tests for API, hook, and components

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 03:10:30 -05:00
poduck
ba2c656243 perf: Optimize slow tests with shared tenant fixtures
- Add session-scoped shared_tenant and second_shared_tenant fixtures to conftest.py
- Refactor test_models.py and test_user_model.py to use shared fixtures
- Avoid ~40s migration overhead per tenant by reusing fixtures across tests
- Add pytest-xdist to dev dependencies for future parallel test execution

Previously 4 tests each created their own tenant (~40s each = ~160s total).
Now they share session-scoped tenants, reducing overhead significantly.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 02:22:43 -05:00
poduck
485f86086b feat: Unified FeaturesPermissionsEditor component for plan and business permissions
- Create reusable FeaturesPermissionsEditor component with support for both
  subscription plan editing and individual business permission overrides
- Add can_use_contracts field to Tenant model for per-business contracts toggle
- Update PlatformSettings.tsx to use unified component for plan permissions
- Update BusinessEditModal.tsx to use unified component for business permissions
- Update PlatformBusinessUpdate API interface with all permission fields
- Add contracts permission mapping to tenant sync task

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 01:37:04 -05:00
poduck
2f6ea82114 fix: Update djstripe signal imports and fix test mocking
- Use correct WEBHOOK_SIGNALS dict access for payment intent signals
- Simplify webhook tests by removing complex djstripe module mocking
- Fix TimezoneSerializerMixin tests to expect dynamic field addition
- Update TenantViewSet tests to mock exclude() chain for public schema

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 00:24:37 -05:00
poduck
507222316c fix: Add django.setup() to deploy script plugin seeding
The python -c one-liner wasn't initializing Django's app registry,
causing AppRegistryNotReady error when calling get_tenant_model().
2025-12-09 14:29:08 -05:00
poduck
c5c108c76f fix: Exclude public schema from platform businesses listing 2025-12-09 14:21:15 -05:00
poduck
90fa628cb5 feat: Add customer appointment details modal and ATM-style currency input
- Add appointment detail modal to CustomerDashboard with payment info display
  - Shows service, date/time, duration, status, and notes
  - Displays payment summary: service price, deposit paid, payment made, amount due
  - Print receipt functionality with secure DOM manipulation
  - Cancel appointment button for upcoming appointments

- Add CurrencyInput component for ATM-style price entry
  - Digits entered as cents, shift left as more digits added (e.g., "1234" → $12.34)
  - Robust input validation: handles keyboard, mobile, paste, drop, IME
  - Only allows integer digits (0-9)

- Update useAppointments hook to map payment fields from backend
  - Converts amounts from cents to dollars for display

- Update Services page to use CurrencyInput for price and deposit fields

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 12:46:10 -05:00
poduck
7f389830f8 docs: Update README with comprehensive setup and deployment guide
- Updated project structure to reflect current domain-based organization
- Added detailed local development setup with lvh.me explanation
- Added production deployment instructions (quick deploy and fresh server)
- Documented environment variables configuration
- Added architecture diagrams for multi-tenancy and request flow
- Included troubleshooting section for common issues
- Updated role hierarchy documentation
- Added configuration files reference table

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:53:02 -05:00
poduck
30909f3268 fix: Add WebSocket proxy configuration to nginx
The nginx.conf was missing a location block for /ws/ paths, causing
WebSocket connections to fall through to the SPA catch-all and return
index.html instead of proxying to Django/Daphne.

Added proper WebSocket proxy configuration with:
- HTTP/1.1 upgrade headers for WebSocket protocol
- 24-hour read timeout for long-lived connections
- Standard proxy headers for Django

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:46:37 -05:00
poduck
df45a6f5d7 fix: Use request.tenant for staff filtering in multi-tenant context
- UserTenantFilteredMixin now uses request.tenant (from django-tenants
  middleware) instead of request.user.tenant for filtering
- ResourceSerializer._get_valid_user uses request.tenant for validation
- Frontend useResources sends user_id instead of user field

This fixes 400 errors when creating staff resources because the tenant
context is now correctly derived from the subdomain being accessed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:31:18 -05:00
poduck
156ad09232 fix: Use request.tenant instead of request.user.tenant for user validation
Platform-level users (owners) may have tenant=None on their user record
but still access tenant subdomains. The _get_valid_user method now uses
request.tenant (from django-tenants middleware) which is set based on
the subdomain being accessed, not the user's tenant FK.

This fixes 400 Bad Request errors when platform users try to create
resources with staff assignments.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:11:38 -05:00
poduck
8dc2248f1f feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities
- Add backend tests for webhooks, notifications, middleware, and edge cases
- Add ForgotPassword, NotFound, and ResetPassword pages
- Add migration for orphaned staff resources conversion
- Add coverage directory to gitignore (generated reports)
- Various bug fixes and improvements from previous work

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:36:46 -05:00
poduck
c220612214 Revert "chore: Add WIP test files to gitignore for clean deploy"
This reverts commit 33137289ef.
2025-12-08 02:35:50 -05:00
poduck
33137289ef chore: Add WIP test files to gitignore for clean deploy 2025-12-08 02:34:56 -05:00
poduck
b2be35bdfa chore: Add coverage to gitignore 2025-12-08 02:34:21 -05:00
poduck
a4b23e44b6 feat(messaging): Add broadcast messaging system for owners and managers
- Add BroadcastMessage and MessageRecipient models for sending messages to groups or individuals
- Add Messages page with compose form and sent messages list
- Support targeting by role (owners, managers, staff, customers) or individual users
- Add can_send_messages permission (owners always, managers by default with revocable permission)
- Add autofill search dropdown with infinite scroll for selecting individual recipients
- Add staff permission toggle for managers' messaging access
- Integrate Messages link in sidebar for users with permission

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:33:27 -05:00
poduck
67ce2c433c Merge remote-tracking branch 'origin/main' into refactor/organize-django-apps
# Conflicts:
#	smoothschedule/smoothschedule/scheduling/schedule/serializers.py
2025-12-07 21:12:09 -05:00
poduck
1391374d45 test: Add comprehensive unit test coverage for all domains
This commit adds extensive unit tests across all Django app domains,
increasing test coverage significantly. All tests use mocks to avoid
database dependencies and follow the testing pyramid approach.

Domains covered:
- identity/core: mixins, models, permissions, OAuth, quota service
- identity/users: models, API views, MFA, services
- commerce/tickets: signals, serializers, views, email notifications
- commerce/payments: services, views
- communication/credits: models, tasks, views
- communication/mobile: serializers, views
- communication/notifications: models, serializers, views
- platform/admin: serializers, views
- platform/api: models, views, token security
- scheduling/schedule: models, serializers, services, signals, views
- scheduling/contracts: serializers, views
- scheduling/analytics: views

Key improvements:
- Fixed 54 previously failing tests in signals and serializers
- All tests use proper mocking patterns (no @pytest.mark.django_db)
- Added test factories for creating mock objects
- Updated conftest.py with shared fixtures

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 21:10:26 -05:00
poduck
8440ac945a feat(time-off): Reset approval when staff edits approved request
- Add pre_save signal to track changes to approved time blocks
- Reset to PENDING status when staff modifies approved time-off
- Send re-approval notifications to managers with changed fields
- Update email templates for modified requests
- Allow managers to have self-approval permission revoked (default: allowed)

A changed request is treated as a new request requiring re-approval.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 20:35:47 -05:00
poduck
f4332153f4 feat: Add timezone architecture for consistent date/time handling
- Create dateUtils.ts with helpers for UTC conversion and timezone display
- Add TimezoneSerializerMixin to include business_timezone in API responses
- Update GeneralSettings timezone dropdown with IANA identifiers
- Apply timezone mixin to Event, TimeBlock, and field mobile serializers
- Document timezone architecture in CLAUDE.md

All times stored in UTC, converted for display based on business timezone.
If business_timezone is null, uses user's local timezone.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 19:39:36 -05:00
poduck
b9e90e6f46 docs: Add comprehensive testing guidelines to CLAUDE.md
Add testing documentation emphasizing mocked unit tests over slow
database-hitting integration tests due to django-tenants overhead.

Guidelines include:
- Testing pyramid philosophy (prefer unit tests)
- Unit test examples with mocks
- Serializer and ViewSet testing patterns
- When to use integration tests (sparingly)
- Repository pattern for testable code
- Dependency injection examples
- Test file structure conventions
- Commands for running tests with coverage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 19:12:01 -05:00
poduck
1af79cc019 refactor: Reorganize tests into tests/ directories
Follow cookiecutter-django convention by placing tests in dedicated
tests/ directories within each app instead of single tests.py files.

Changes:
- Created tests/ directories with __init__.py for all 13 apps
- Moved analytics/tests.py → analytics/tests/test_views.py
- Moved schedule/test_export.py → schedule/tests/test_export.py
- Moved platform/api/tests_token_security.py → platform/api/tests/test_token_security.py
- Deleted empty placeholder tests.py files

All apps now have a tests/ directory ready for proper test organization.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 18:31:00 -05:00
poduck
156cc2676d refactor: Reorganize Django apps into domain-based structure
Restructured 13 Django apps from flat/mixed organization into 5 logical
domain packages following cookiecutter-django conventions:

- identity/: core (tenant/domain models, middleware, mixins), users
- scheduling/: schedule, contracts, analytics
- communication/: notifications, credits, mobile, messaging
- commerce/: payments, tickets
- platform/: admin, api

Key changes:
- Moved all apps to smoothschedule/smoothschedule/{domain}/{app}/
- Updated all import paths across the codebase
- Updated settings (base.py, multitenancy.py, test.py)
- Updated URL configuration in config/urls.py
- Updated middleware and permission paths
- Preserved app_label in AppConfig for migration compatibility
- Updated CLAUDE.md documentation with new structure

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 18:24:50 -05:00
poduck
897a336d0b feat: Add click navigation for time-off request notifications
Clicking a time-off request notification now navigates to the
time blocks page where pending requests can be reviewed.

- Added Clock icon for time-off request notifications
- Handle notification.data.type === 'time_off_request' to navigate to /time-blocks

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:54:20 -05:00
poduck
410b46a896 feat: Add time block approval workflow and staff permission system
- Add TimeBlock approval status with manager approval workflow
- Create core mixins for staff permission restrictions (DenyStaffWritePermission, etc.)
- Add StaffDashboard page for staff-specific views
- Refactor MyAvailability page for time block management
- Update field mobile status machine and views
- Add per-user permission overrides via JSONField
- Document core mixins and permission system in CLAUDE.md

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:49:37 -05:00
poduck
01020861c7 feat(staff): Restrict staff permissions and add schedule view
- Backend: Restrict staff from accessing resources, customers, services, and tasks APIs
- Frontend: Hide management sidebar links from staff members
- Add StaffSchedule page with vertical timeline view of appointments
- Add StaffHelp page with staff-specific documentation
- Return linked_resource_id and can_edit_schedule in user profile for staff

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 02:23:00 -05:00
poduck
61882b300f feat(mobile): Add field app with date range navigation
- Add React Native Expo field app for mobile staff
- Use main /appointments/ endpoint with date range support
- Add X-Business-Subdomain header for tenant context
- Support day/week view navigation
- Remove WebSocket console logging from frontend
- Update AppointmentStatus type to include all backend statuses
- Add responsive status legend to scheduler header

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 01:23:24 -05:00
poduck
46b154e957 feat: Add favicon.ico and apple-touch-icon
- Create multi-resolution favicon.ico (48x48, 32x32, 16x16) from logo
- Add apple-touch-icon.png for iOS devices
- Update index.html to use new favicon

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 14:32:35 -05:00
poduck
023ea7f020 feat(contracts): Add contracts permission to subscription tiers
- Add contracts_enabled field to SubscriptionPlan model
- Add contracts toggle to plan create/edit modal in platform settings
- Hide contracts menu item for tenants without contracts permission
- Protect /contracts routes with canUse('contracts') check
- Add HasContractsPermission to contracts API ViewSets
- Add contracts to PlanPermissions interface and feature definitions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 23:28:51 -05:00
poduck
35f4301fe1 feat(contracts): Add legal export package and ESIGN compliance improvements
- Add export_legal endpoint for signed contracts that generates a ZIP with:
  - Signed contract PDF
  - Audit certificate PDF with signature details and hash verification
  - Machine-readable signature_record.json
  - Integrity verification report
  - README documentation

- Add audit certificate template with:
  - Contract and signature information
  - Consent records with exact legal text
  - Document integrity verification (SHA-256 hash comparison)
  - ESIGN Act and UETA compliance statement

- Update ContractSigning page for ESIGN/UETA compliance:
  - Consent checkbox text now matches backend-stored legal text
  - Added proper legal notice with ESIGN Act references

- Add signed_at field to ContractListSerializer
- Add view/print buttons for signed contracts in Contracts page
- Allow viewing signed contracts via public signing URL

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:29:35 -05:00
poduck
6feaa8dda5 fix(i18n): Update French win-back translation
Changed "Reconquête Client" to "Réactivation des clients" for more
natural French phrasing.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:47:51 -05:00
poduck
f084e33621 fix(i18n): Complete German helpComprehensive translations
The German helpComprehensive section had a different structure with 250
missing keys. Replaced with complete translations matching the English
structure used by HelpComprehensive.tsx.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:44:26 -05:00
poduck
db0165dc5e fix(i18n): Add missing 'welcome' translation key to en/es/fr.json
The HelpComprehensive.tsx uses introduction.welcome but the translation
files only had introduction.title. Added the welcome key to match
the German translation structure.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:27:20 -05:00
poduck
af891d7e8f fix(i18n): Convert HelpComprehensive.tsx to use translation keys
Replaced all hardcoded English text with i18n translation function calls
to enable proper internationalization. All sections now use
helpComprehensive.* translation keys that are already present in
en.json, es.json, fr.json, and de.json.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:15:20 -05:00
poduck
7ef255a5f1 feat(help): Add Time Blocks section to comprehensive help docs
- Add Time Blocks section to HelpComprehensive.tsx with block levels,
  types, recurrence patterns, and key features documentation
- Add complete helpComprehensive translations for en, es, fr, de
- Update HelpContracts.tsx styling
- Enhance FeaturesPage.tsx and HomePage.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:58:41 -05:00
poduck
29e99631c9 feat(i18n): Add time blocks translations and fix deployment
- Add comprehensive timeBlocks translations (ES, FR, DE, EN)
- Add myAvailability translations (ES, FR, DE, EN)
- Add full helpTimeBlocks guide content (ES, FR, DE, EN)
- Add contracts guide translations (ES)
- Fix DATABASE_URL env var in deploy.sh for seed_platform_plugins
- Update Contracts page and HelpContracts guide

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:43:03 -05:00
poduck
2d7c1dcd27 feat(time-blocks): Add seed_holidays management command
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:21:02 -05:00
poduck
8d0cc1e90a feat(time-blocks): Add comprehensive time blocking system with contracts
- Add TimeBlock and Holiday models with recurrence support (one-time, weekly, monthly, yearly, holiday)
- Implement business-level and resource-level blocking with hard/soft block types
- Add multi-select holiday picker for bulk holiday blocking
- Add calendar overlay visualization with distinct colors:
  - Business blocks: Red (hard) / Yellow (soft)
  - Resource blocks: Purple (hard) / Cyan (soft)
- Add month view resource indicators showing 1/n width per resource
- Add yearly calendar view for block overview
- Add My Availability page for staff self-service
- Add contracts module with templates, signing flow, and PDF generation
- Update scheduler with click-to-day navigation in week view

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:19:12 -05:00
poduck
cf91bae24f feat(services): Add deposit percentage option for fixed-price services
- Add deposit_percent field back to Service model for percentage-based deposits
- Reorganize service form: variable pricing toggle at top, deposit toggle with
  amount/percent options (percent only available for fixed pricing)
- Disable price field when variable pricing is enabled
- Add backend validation: variable pricing cannot use percentage deposits
- Update frontend types and hooks to handle deposit_percent field

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:52:51 -05:00
poduck
c7308ad167 refactor(services): Simplify deposit to single amount field
- Remove deposit_percent field (doesn't work for variable pricing)
- Make deposit_amount default to 0 (no deposit)
- Deposit now applies to both variable and fixed pricing services
- Add requires_deposit and requires_saved_payment_method as computed properties
- Simplify frontend form with single deposit amount input
- Show deposit badge in service list when deposit > 0

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:42:46 -05:00
poduck
7da5d55831 fix(services): Update hooks to handle variable pricing fields
- Add ServiceInput interface for create/update operations
- Transform variable pricing fields in useServices query
- Handle deposit_amount and deposit_percent in mutations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:35:35 -05:00
poduck
3bc8167649 feat(payments): Add variable pricing with deposit collection
Services can now have variable pricing where:
- Final price is determined after service completion
- A deposit (fixed amount or percentage) is collected at booking
- Customer's saved payment method is charged for remaining balance

Changes:
- Add variable_pricing, deposit_amount, deposit_percent fields to Service model
- Add service FK and final_price fields to Event model
- Add AWAITING_PAYMENT status to Event
- Add SetFinalPriceView endpoint to charge customer's saved card
- Add EventPricingInfoView endpoint for pricing details
- Update Services page with variable pricing toggle and deposit config
- Show "From $X" and deposit info in customer preview

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:33:03 -05:00
poduck
b0512a660c feat(billing): Add customer billing page with payment method management
- Add CustomerBilling page for customers to view payment history and manage cards
- Create AddPaymentMethodModal with Stripe Elements for secure card saving
- Support both Stripe Connect and direct API payment modes
- Auto-set first payment method as default when no default exists
- Add dark mode support for Stripe card input styling
- Add customer billing API endpoints for payment history and saved cards
- Add stripe_customer_id field to User model for Stripe customer tracking

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:06:30 -05:00
poduck
65faaae864 fix(security): Multi-tenancy isolation and customer appointment filtering
- Add request tenant validation to all ViewSets (EventViewSet, ResourceViewSet,
  ParticipantViewSet, CustomerViewSet, StaffViewSet) to prevent cross-tenant
  data access via subdomain/header manipulation
- Change permission_classes from AllowAny to IsAuthenticated for EventViewSet
  and ResourceViewSet
- Filter events for customers to only show appointments where they are a
  participant
- Add customer field to EventSerializer to create Customer participants when
  appointments are created
- Update CustomerDashboard to fetch appointments from API instead of mock data
- Fix TenantViewSet.destroy() to properly handle cross-schema cascade when
  deleting tenants

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 11:05:01 -05:00
poduck
dbe91ec2ff feat(auth): Convert login system to use email as username
- Backend login now accepts 'email' field (with backward compatibility)
- User creation (signup, invitation, customer) uses email as username
- Frontend login form updated with email input and validation
- Updated test users to use email addresses as usernames
- Updated all translation files (en, es, fr, de)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:38:53 -05:00
poduck
a2f74ee769 fix(customers): Auto-generate username when creating customers
The CustomerSerializer was missing a create method to generate a unique
username, causing IntegrityError when trying to create customers.

- Add first_name and last_name as write-only fields
- Remove email from read_only_fields so it can be set on creation
- Generate username from email prefix (with counter for uniqueness)
- Fall back to UUID-based username if no email provided

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:29:45 -05:00
poduck
9073970189 fix(i18n): Add language selector to platform UI
Restore the LanguageSelector component to the platform layout header,
allowing platform users to switch languages.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:12:38 -05:00
poduck
6554e62d30 fix(seo): Add noindex for platform and business subdomains
Dynamically set robots meta tag to noindex/nofollow when on any
subdomain (platform.*, demo.*, etc.). Only the root domain
marketing pages should be indexed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:08:47 -05:00
poduck
bd6d9144ce fix(seo): Block crawlers and add sitemap
- Set robots meta tag to noindex, nofollow (site not live)
- Update robots.txt with instructions for going live
- Add sitemap.xml with all marketing pages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:01:44 -05:00
poduck
ad04e5f6ff fix(seo): Remove technical jargon and add SEO meta tags
- Replace "multi-tenant" wording with user-friendly alternatives
  - Hero subheadline: "Secure" instead of "Multi-tenant"
  - Feature title: "Enterprise Security" instead of "Multi-Tenant Architecture"
  - Updated testimonials and FAQ to remove technical references
- Add comprehensive SEO meta tags to index.html:
  - Meta description for search engines
  - Open Graph tags for social sharing
  - Twitter card meta tags
  - Canonical URL and robots directives
- Update all language files (en, es, fr, de) with consistent changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 23:01:32 -05:00
poduck
460bf200d0 fix(i18n): Sync marketing translations across all languages
Update es.json, fr.json, and de.json to match en.json structure:
- Add missing benefits, plugins, and home sections
- Add new hero keys (badge, title, titleHighlight, visualContent)
- Add features automation and multi-tenancy sections
- Add pricing FAQ, starter/pro tiers
- Add signup address fields and payment setup
- Restructure footer with proper nesting
- Add contact page new keys (formHeading, scheduleCall)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 22:49:44 -05:00
poduck
3e8634b370 fix(i18n): Add missing About page timeline translations
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 22:27:30 -05:00
poduck
bc094f2f80 feat(i18n): Internationalize marketing pages and components
- HomePage.tsx: Add translation keys for features, testimonials, section titles
- FeaturesPage.tsx: Add translation keys for automation engine, multi-tenancy sections
- Hero.tsx: Add translation keys for headline, CTAs, trust signals, visual content
- ContactPage.tsx: Add translation keys for form headings, success messages
- PricingPage.tsx: Add translation keys for FAQ section
- PrivacyPolicyPage.tsx: Full internationalization of 15-section privacy policy
- TermsOfServicePage.tsx: Full internationalization of 16-section terms of service
- Footer.tsx & Navbar.tsx: Add translation keys for brand name, aria-labels

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 22:25:11 -05:00
poduck
c7f241b30a feat(i18n): Comprehensive internationalization of frontend components and pages
Translate all hardcoded English strings to use i18n translation keys:

Components:
- TransactionDetailModal: payment details, refunds, technical info
- ConnectOnboarding/ConnectOnboardingEmbed: Stripe Connect setup
- StripeApiKeysForm: API key management
- DomainPurchase: domain registration flow
- Sidebar: navigation labels
- Schedule/Sidebar, PendingSidebar: scheduler UI
- MasqueradeBanner: masquerade status
- Dashboard widgets: metrics, capacity, customers, tickets
- Marketing: PricingTable, PluginShowcase, BenefitsSection
- ConfirmationModal, ServiceList: common UI

Pages:
- Staff: invitation flow, role management
- Customers: form placeholders
- Payments: transactions, payouts, billing
- BookingSettings: URL and redirect configuration
- TrialExpired: upgrade prompts and features
- PlatformSettings, PlatformBusinesses: admin UI
- HelpApiDocs: API documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 21:40:54 -05:00
poduck
902582f4ba feat(platform): Redesign tenant invite modal with tier-based permissions
- Simplified UI with Email, Business Name, and Subscription Tier fields
- Added collapsible "Override Tier Limits" section with sliding animation
- Permission options match platform settings structure (Payments, Communication, Customization, Plugins, Advanced, Enterprise)
- Permissions are loaded from subscription plans or fallback to static defaults
- Custom limits/permissions only sent to backend when override is checked

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 20:45:29 -05:00
poduck
7b18637b1e feat(tenant): Add public-facing landing page for business subdomains
- New TenantLandingPage component with 'Coming Soon' message
- Shows business name derived from subdomain
- Has 'Sign In' button that goes to /login
- 'Powered by SmoothSchedule' footer
- Will be customizable later for each tenant

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:54:46 -05:00
poduck
3a1b2f2dd8 fix(onboarding): Change 'Go to Dashboard' to 'Go to Login'
The button after tenant creation was misleading - users need to log in first.
Changed button text and URL to explicitly point to /login.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:47:59 -05:00
poduck
88b54ef9e4 chore(traefik): Remove debug logging, set production log level
Wildcard subdomain routing is now working. Removed access logging
that was added for debugging.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:39:39 -05:00
poduck
5cdbc19517 fix(traefik): Fix HostRegexp YAML escaping for subdomain routing
In YAML single-quoted strings, backslashes are literal characters.
'\\.' was being interpreted as two backslashes + dot, not as an
escaped dot in the regex.

Changed from '\\.smoothschedule\\.com' to '\.smoothschedule\.com'

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:38:57 -05:00
poduck
f3a0f1f07a debug: Add access logging to Traefik
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:37:47 -05:00
poduck
f3951295ac fix(traefik): Remove conflicting TCP router for subdomain handling
The TCP router was intercepting wildcard subdomain traffic at the TCP layer
and sending it directly to nginx:80, bypassing HTTP routing entirely.
This caused 404 errors because nginx wasn't receiving proper HTTP routing.

Now relying on:
- TLS store's defaultGeneratedCert for wildcard certificate
- HTTP HostRegexp router for subdomain routing to nginx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:34:41 -05:00
poduck
9cbf19ed1b fix(traefik): Simplify HTTP HostRegexp pattern
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:32:53 -05:00
poduck
88c74398e4 fix(traefik): Simplify HostSNIRegexp pattern for wildcard subdomains
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:29:17 -05:00
poduck
86947ab206 feat(deploy): Add selective service rebuild and --no-migrate option
- Add support for specifying services to rebuild (e.g., ./deploy.sh traefik)
- Add --no-migrate flag to skip migrations for config-only changes
- Reduce wait time from 10s to 5s

Usage examples:
  ./deploy.sh                               # Build all, run migrations
  ./deploy.sh traefik --no-migrate          # Only rebuild traefik, skip migrations
  ./deploy.sh django nginx                  # Build django and nginx, run migrations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:27:17 -05:00
poduck
7cc013eaf2 fix(traefik): Add TCP router with HostSNIRegexp for wildcard subdomain TLS
Add a TCP-level router using HostSNIRegexp to match unknown subdomains
at the TLS layer and terminate TLS with wildcard certificate.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:19:21 -05:00
poduck
a723d784cd fix(traefik): Add TLS store for wildcard subdomain routing
- Add default TLS store with wildcard certificate for unknown SNIs
- Add priority=1 to subdomain-router for catch-all behavior
- Use proper Traefik v3 HostRegexp syntax with anchors

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:14:36 -05:00
poduck
13441d88fc fix(traefik): Use separate storage files for certificate resolvers
Separate acme.json storage for HTTP and DNS certificate resolvers
to prevent conflicts when requesting wildcard certificates.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 17:08:34 -05:00
poduck
b20fa5cfd8 fix(traefik): Update HostRegexp syntax for Traefik v3
Traefik v3 changed HostRegexp syntax from named capture groups to
standard regex. Added low priority to avoid matching specific routes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:59:45 -05:00
poduck
093f6d9a62 fix(traefik): Add env_file to read Cloudflare token
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:53:33 -05:00
poduck
5bf2fc5319 fix(traefik): Use Cloudflare DNS provider instead of DigitalOcean
DNS is hosted on Cloudflare, not DigitalOcean.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:50:21 -05:00
poduck
33e4b6b9b5 feat(traefik): Add DNS challenge for wildcard SSL certificates
HostRegexp patterns don't work with HTTP challenge because Traefik
can't request certificates for dynamic subdomains. Switched to DNS
challenge using DigitalOcean provider for *.smoothschedule.com wildcard.

Requires DO_AUTH_TOKEN environment variable to be set.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:48:50 -05:00
poduck
434f874963 fix(traefik): Route tenant subdomains to nginx instead of django
The subdomain-router was incorrectly sending tenant subdomain requests
directly to Django (API server), causing 404 errors. Now routes to nginx
which serves the React SPA and proxies /api/ requests to Django.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:40:18 -05:00
poduck
0d3c97ea5f fix(onboarding): Improve loading indicator with elapsed time and better pacing
- Add elapsed time counter (MM:SS)
- Spread animation steps over ~30 seconds before final step
- Final step stays spinning (doesn't complete early)
- Progress bar caps at 90% until actually done, pulses on final step
- Show "Finalizing..." and helpful message during long final step
- Clear "45-90 seconds" time estimate upfront

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:37:34 -05:00
poduck
567fe0604a feat(onboarding): Add animated loading indicator and fix completion
- Add multi-step animated loading indicator during tenant creation
- Fix blank completion screen (was checking wrong step number)
- Auto-verify email for users accepting tenant invitations
- Show progress bar and step-by-step status during database setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:26:11 -05:00
poduck
5244e16279 fix(tenant): Defer plugin seeding until after transaction commits
The post_save signal was trying to seed plugins before the tenant's
schema migrations had completed, causing a 500 error when accepting
tenant invitations. Using transaction.on_commit() ensures the schema
and tables exist before seeding.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:11:51 -05:00
poduck
55cb97ca0d fix(deploy): Check if backup directory has content before restoring
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:05:04 -05:00
poduck
a170d6134b fix(invitations): Use tenant-onboard page for platform invitations
Platform/tenant invitations should redirect to /tenant-onboard which has
the full onboarding wizard, not /accept-invite which is for staff invitations.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 16:02:02 -05:00
poduck
d2c4cbe183 fix(deploy): Fix .envs and .ssh restore to copy contents not directory
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:57:34 -05:00
poduck
47f1a4d7b4 fix(deploy): Backup and restore .ssh keys during git-based deployments
SSH keys for mail server management are not in git, need to preserve
them like .envs secrets.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:51:08 -05:00
poduck
b455be0ac6 fix(docker): Update nginx context path for git-based deployments
Changed from ../smoothschedule-frontend to ../frontend to match
the git repository structure.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:50:31 -05:00
poduck
abf67a36ed fix(invitations): Support both platform and staff invitation types
- Update useInvitationDetails to try platform tenant invitation first,
  then fall back to staff invitation
- Update useAcceptInvitation to handle both invitation types
- Update useDeclineInvitation to handle both invitation types
- Pass invitation type from AcceptInvitePage to mutations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:49:59 -05:00
poduck
4f515c3710 feat: Quota enforcement UI and various improvements
- Add quota limit warnings to Resources, Services, and OwnerScheduler pages
- Add quotaUtils.ts for checking quota limits
- Update BusinessLayout with quota context
- Improve email receiver logging
- Update serializers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:47:48 -05:00
poduck
fd751f02f8 refactor(deploy): Use git pull instead of rsync for deployments
- Requires changes to be committed and pushed before deploying
- Clones repo on first deploy, then uses git fetch/reset
- Preserves .envs secrets which are not in git
- Better for multi-developer workflows

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:46:01 -05:00
poduck
04bb9e3c14 fix(auth): Allow accept-invite on subdomains without redirect to login
Don't redirect unauthenticated users to login when accessing public paths
like /accept-invite, /verify-email, or /tenant-onboard on subdomains.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:28:17 -05:00
poduck
39a376b39b fix(email): Add SMTP configuration and fix invitation link routing
- Add SMTP email backend support to production settings (EMAIL_HOST, EMAIL_PORT, etc.)
- Falls back to console backend if SMTP not configured
- Fix AcceptInvitePage to support both path parameter (/accept-invite/:token) and
  query parameter (?token=...) formats for invitation tokens
- Add route for /accept-invite/:token in App.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:19:46 -05:00
poduck
85c4b835fd fix(mail): Copy SSH keys into Docker image instead of volume mount
Remove volume mount for SSH keys and rely on Dockerfile COPY instruction
to bake SSH keys into the image during build. This ensures proper
permissions and ownership for the django user.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 13:40:57 -05:00
poduck
bed0ba9304 feat(mail): Add mail server SSH configuration
- Mount SSH keys in Django container for mail server access
- Add mail server configuration environment variables
- Import mail server settings in production.py with env defaults
- Configure mail.talova.net SSH connection parameters

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 13:33:19 -05:00
poduck
dcb14503a2 feat: Dashboard redesign, plan permissions, and help docs improvements
Major updates including:
- Customizable dashboard with drag-and-drop widget grid layout
- Plan-based feature locking for plugins and tasks
- Comprehensive help documentation updates across all pages
- Plugin seeding in deployment process for all tenants
- Permission synchronization system for subscription plans
- QuotaOverageModal component and enhanced UX flows

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 13:02:44 -05:00
poduck
9444e26924 docs(help): Comprehensive rewrites for Resources, Services, Customers, Staff guides
HelpResources.tsx:
- Added resource types section with Staff/Room/Equipment
- Documented table columns and their meanings
- Added step-by-step resource creation guide
- Added staff autocomplete with keyboard navigation
- Detailed multilane mode for concurrent bookings
- Documented View Calendar and Edit features

HelpServices.tsx:
- Documented two-column layout with customer preview
- Added drag-and-drop reordering instructions
- Detailed service properties (name, duration, price, description)
- Added photo gallery section with upload, reorder, delete
- Documented customer preview mockup feature

HelpCustomers.tsx:
- Documented customer table columns
- Added search and sorting capabilities
- Step-by-step customer creation guide
- Documented customer statuses (Active, Inactive, Blocked)
- Added tags section for customer organization
- Documented masquerading feature for customer support

HelpStaff.tsx:
- Detailed staff roles (Owner, Manager, Staff) with badges
- Staff table columns documentation
- Step-by-step staff invitation workflow
- Pending invitations management (resend, cancel)
- Edit staff modal with permissions
- Make Bookable feature for linking to resources
- Inactive staff section with reactivation
- Masquerading as staff for training/troubleshooting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 02:44:02 -05:00
poduck
445b2bb3fc fix(help): Correct pending appointments sidebar position to left
Fixed documentation that incorrectly stated the pending appointments
sidebar appears on the right side of the scheduler when it actually
appears on the left side.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 02:35:06 -05:00
poduck
baffe7e577 docs(help): Comprehensive Scheduler documentation with all features
Rewrote HelpScheduler.tsx to document actual scheduler features including:
- Drag-and-drop to reschedule, change resource, or delete appointments
- Resize appointments by dragging edges (start or end)
- Pending appointments sidebar with archive zone
- Undo/Redo with Ctrl+Z/Ctrl+Y (up to 50 actions)
- Zoom controls for timeline detail
- Status colors (blue/yellow/red/green/gray)
- Filtering by status, resource, and service
- Overlapping appointment lanes
- Real-time WebSocket updates
- Month view click-to-day and drag overlay features

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 02:27:39 -05:00
poduck
5aa49399d0 feat(help): Add floating help button to all pages
Replaced inline HelpButton components with a global FloatingHelpButton
that appears fixed in the top-right corner of all pages. The button:
- Automatically detects the current route and links to the appropriate help page
- Uses a consistent position across all pages (fixed, top-right)
- Is hidden on help pages themselves
- Works on both business and platform layouts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 02:23:28 -05:00
poduck
11bb83a85d feat: Add comprehensive help documentation system and plugin creation page
- Add CreatePlugin.tsx page for custom plugin creation with code editor
- Add HelpButton component for contextual help links
- Create 21 new help pages covering all dashboard features:
  - Core: Dashboard, Scheduler, Tasks
  - Manage: Customers, Services, Resources, Staff
  - Communicate: Messages (Ticketing already existed)
  - Money: Payments
  - Extend: Plugins overview and creation guide
  - Settings: General, Resource Types, Booking, Appearance, Email, Domains, API, Auth, Billing, Quota
- Update HelpGuide.tsx as main documentation hub with quick start guide
- Add routes for all help pages in App.tsx
- Add HelpButton to Dashboard, Customers, Services, and Tasks pages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 02:18:05 -05:00
poduck
5cef01ad0d feat: Reorganize settings sidebar and add plan-based feature locking
- Add locked state to Plugins sidebar item with plan feature check
- Create Branding section in settings with Appearance, Email Templates, Custom Domains
- Split Domains page into Booking (URLs, redirects) and Custom Domains (BYOD, purchase)
- Add booking_return_url field to Tenant model for customer redirects
- Update SidebarItem component to support locked prop with lock icon
- Move Email Templates from main sidebar to Settings > Branding
- Add communication credits hooks and payment form updates
- Add timezone fields migration and various UI improvements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 01:35:59 -05:00
poduck
ef58e9fc94 feat: Stripe subscriptions, tier-based permissions, dark mode, and UX improvements
- Fix Stripe SDK v14 compatibility (bracket notation for subscription items)
- Fix subscription period dates from subscription items instead of subscription object
- Add tier-based permissions (can_accept_payments, etc.) on tenant signup
- Add stripe_customer_id field to Tenant model
- Add clickable logo on login page (navigates to /)
- Add database setup message during signup wizard
- Add dark mode support for payment settings and Connect onboarding
- Add subscription management endpoints (cancel, reactivate)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 20:50:18 -05:00
poduck
08b51d1a5f feat: Quota overage system, updated tier pricing, and communication addons
Quota Overage System:
- Add QuotaOverage model for tracking resource/user quota overages
- Implement 30-day grace period with email notifications (immediate, 7-day, 1-day)
- Add QuotaWarningBanner component in BusinessLayout
- Add QuotaSettings page for managing overages and archiving resources
- Add Celery tasks for automated quota checks and expiration handling
- Add quota management API endpoints

Updated Tier Pricing (Stripe: 2.9% + $0.30):
- Free: No payments (requires addon)
- Starter: 4% + $0.40
- Professional: 3.5% + $0.35
- Business: 3.25% + $0.32
- Enterprise: 3% + $0.30

New Subscription Addons:
- Online Payments ($5/mo + 5% + $0.50) - for Free tier
- SMS Notifications ($10/mo) - enables SMS reminders
- Masked Calling ($15/mo) - enables anonymous calling

BusinessEditModal Improvements:
- Increased width to match PlanModal (max-w-3xl)
- Added all tier options with auto-update on tier change
- Added limits configuration and permissions sections

Backend Fixes:
- Fixed SubscriptionPlan serializer to include all communication fields
- Allow blank business_tier for addon plans
- Added migration for business_tier field changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 13:05:02 -05:00
poduck
dc3210927a feat(platform): Add confirmation modal for email verification
- Create reusable ConfirmationModal component with variants (info, warning, danger, success)
- Replace browser confirm() dialogs with styled modal for email verification
- Update PlatformBusinesses and PlatformUsers to use the new modal
- Add translation keys for verification messages
- Unverify test@example.com for testing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:26:47 -05:00
poduck
42988c0f88 fix(platform): Allow POST method for verify_email action
The PlatformUserViewSet restricted HTTP methods to GET and PATCH,
but the verify_email action requires POST. Added POST to allowed
methods.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:22:13 -05:00
poduck
e4ad7fca87 feat: Plan-based feature permissions and quota enforcement
Backend:
- Add HasQuota() permission factory for quota limits (resources, users, services, appointments, email templates, automated tasks)
- Add HasFeaturePermission() factory for feature-based permissions (SMS, masked calling, custom domains, white label, plugins, webhooks, calendar sync, analytics)
- Add has_feature() method to Tenant model for flexible permission checking
- Add new tenant permission fields: can_create_plugins, can_use_webhooks, can_use_calendar_sync, can_export_data
- Create Data Export API with CSV/JSON support for appointments, customers, resources, services
- Create Analytics API with dashboard, appointments, revenue endpoints
- Add calendar sync views and URL configuration

Frontend:
- Add usePlanFeatures hook for checking feature availability
- Add UpgradePrompt components (inline, banner, overlay variants)
- Add LockedSection wrapper and LockedButton for feature gating
- Update settings pages with permission checks

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:21:11 -05:00
poduck
05ebd0f2bb feat: Email templates, bulk delete, communication credits, plan features
- Add email template presets for Browse Templates tab (12 templates)
- Add bulk selection and deletion for My Templates tab
- Add communication credits system with Twilio integration
- Add payment views for credit purchases and auto-reload
- Add SMS reminder and masked calling plan permissions
- Fix appointment status mapping (frontend/backend mismatch)
- Clear masquerade stack on login/logout for session hygiene
- Update platform settings with credit configuration
- Add new migrations for Twilio and Stripe payment fields

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 01:42:38 -05:00
poduck
8038f67183 fix(frontend): Add missing RefreshCw import to PlatformSettings
The Tiers & Pricing tab was crashing with "RefreshCw is not defined"
because the icon was used but not imported from lucide-react.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 20:58:44 -05:00
poduck
ee6cf2b802 fix(tickets): Remove invalid source_email_address_id in PlatformEmailReceiver
PlatformEmailReceiver was setting source_email_address_id to a
PlatformEmailAddress ID, but the Ticket model expects a TicketEmailAddress
foreign key. This caused an integrity error that was rolling back the
entire transaction (due to ATOMIC_REQUESTS=True), preventing tickets
from being created.
2025-12-01 19:32:45 -05:00
poduck
c82c60a562 fix(traefik): Add host rewriting for email autoconfig endpoints
Django's multi-tenant middleware doesn't recognize autoconfig/autodiscover
subdomains. This middleware rewrites the Host header to api.smoothschedule.com
so Django routes the request to the public schema.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 19:14:25 -05:00
poduck
06e0ec3d01 fix: Add SSH client and autoconfig routes for production
- Install openssh-client in production Django container for mail server management
- Copy .ssh keys into container with proper permissions
- Add explicit Traefik routes for autoconfig/autodiscover subdomains

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 18:03:40 -05:00
poduck
ae74b4c2ed feat: Multi-email ticketing system with platform email addresses
- Add PlatformEmailAddress model for managing platform-level email addresses
- Add TicketEmailAddress model for tenant-level email addresses
- Create MailServerService for IMAP integration with mail.talova.net
- Implement PlatformEmailReceiver for processing incoming platform emails
- Add email autoconfiguration for Mozilla, Microsoft, and Apple clients
- Add configurable email polling interval in platform settings
- Add "Check Emails" button on support page for manual refresh
- Add ticket counts to status tabs on support page
- Add platform email addresses management page
- Add Privacy Policy and Terms of Service pages
- Add robots.txt for SEO
- Restrict email addresses to smoothschedule.com domain only

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 17:49:09 -05:00
poduck
65da1c73d0 Checkpoint 2025-12-01 10:56:51 -05:00
poduck
5147101c7c remove: Django Debug Toolbar from development setup
Removed django-debug-toolbar as it's unnecessary for API-only setup:
- Removed from INSTALLED_APPS and MIDDLEWARE in local.py
- Removed from dev dependencies in pyproject.toml
- Updated uv.lock after package removal

The debug toolbar was interfering with API documentation pages
and provides minimal value for a primarily API-based application.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 04:01:51 -05:00
poduck
10afe61bb8 fix(debug-toolbar): Hide debug toolbar on API documentation pages
Added SHOW_TOOLBAR_CALLBACK to exclude the debug toolbar from
displaying on /v1/* paths (Swagger UI and ReDoc documentation).

This prevents the large debug toolbar logo from interfering with
the API documentation interface.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 03:59:40 -05:00
poduck
f16ccf76a8 fix(csp): Add cdn.jsdelivr.net to local CSP policy for Swagger UI
Updated local.py CSP directives to match multitenancy.py changes.
This allows Swagger UI assets to load in local development.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 03:58:09 -05:00
poduck
86cde135a9 fix(csp): Allow cdn.jsdelivr.net for Swagger UI assets
Added cdn.jsdelivr.net to Content Security Policy directives to allow
Swagger UI assets (JavaScript, CSS, and images) to load properly.

Updated CSP directives:
- CSP_SCRIPT_SRC: Added cdn.jsdelivr.net for swagger-ui-bundle.js
- CSP_STYLE_SRC: Added cdn.jsdelivr.net for swagger-ui.css
- CSP_IMG_SRC: Added cdn.jsdelivr.net for favicon

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 03:56:05 -05:00
poduck
7e151a23cc fix(api-docs): Use absolute API URL for Interactive Explorer link
The Interactive Explorer link was using a relative URL (/v1/docs/), which caused it to open on the current subdomain instead of the API subdomain. This resulted in users being redirected to the dashboard.

Changed to use API_BASE_URL to construct the absolute URL, which will correctly point to:
- Local: http://lvh.me:8000/v1/docs/
- Production: https://api.smoothschedule.com/v1/docs/

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 03:50:55 -05:00
poduck
63723906d0 fix(production): Configure frontend build for api.smoothschedule.com subdomain
- Update nginx build context to ../smoothschedule-frontend (matches deployment structure)
- Add VITE_API_URL build arg to pass API URL during frontend build
- Fixes login issues caused by incorrect API endpoint configuration

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 03:03:48 -05:00
poduck
99adeda83c feat(production): Configure WebSocket auth and multi-tenant cookies for production
- Add TokenAuthMiddleware to WebSocket connections for authenticated access
- Configure SESSION_COOKIE_DOMAIN and CSRF_COOKIE_DOMAIN for subdomain sharing (.smoothschedule.com)
- Remove '/api' prefix from URL routes to align frontend/backend conventions
- Fix imports in asgi.py (tickets instead of smoothschedule.tickets)
- Update dependencies (pyproject.toml, uv.lock)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 02:54:03 -05:00
poduck
2b4104a819 chore(deploy): Update production configuration for ASGI and new URL structure
- Switched production start script from Gunicorn to Daphne to support WebSockets.
- Updated VITE_API_URL in frontend production env to 'https://api.smoothschedule.com', removing the '/api' prefix to align with the backend URL refactor.
2025-12-01 02:44:51 -05:00
poduck
fa3195b3b3 feat(multitenancy): Add TenantHeaderMiddleware to support centralized API with tenant switching
- Implemented TenantHeaderMiddleware to switch the database tenant schema based on the 'X-Business-Subdomain' request header.
- This allows API requests directed to the centralized 'api' subdomain (e.g., api.lvh.me) to correctly access tenant-specific data when the header is present.
- Registered the middleware in multitenancy settings after TenantMainMiddleware.
2025-12-01 02:41:25 -05:00
poduck
980b5d36aa fix(scheduler): Update Timeline to use apiClient for authenticated resource fetching
The Timeline component was using a raw axios instance with hardcoded URLs, causing it to bypass authentication and tenant context headers. This resulted in empty or failed data fetches. Updated it to use the configured 'apiClient', ensuring that the authentication token and 'X-Business-Subdomain' headers are correctly sent, allowing the backend to return the appropriate tenant-specific resources and appointments.
2025-12-01 02:32:48 -05:00
poduck
5cd689af0a fix(auth): Remove hash from email verification URL to support BrowserRouter
The email verification link was incorrectly including a hash fragment (/#/), which caused the React Router (using BrowserRouter) to misinterpret the path as the root path, leading to a redirect to the default 'email-verification-required' page instead of the 'verify-email' page. This commit removes the hash, ensuring the link correctly points to /verify-email.
2025-12-01 02:20:03 -05:00
poduck
b3e2c1f324 refactor(frontend): Remove '/api' prefix from all API calls to align with backend URL convention
- Updated all API endpoint strings in 'frontend/src' (via sed and manual fixes) to remove the '/api/' prefix.
- Manually fixed 'Timeline.tsx' absolute URLs to use the 'api' subdomain and correct path.
- Manually fixed 'useAuth.ts' logout fetch URLs.
- Updated 'HelpApiDocs.tsx' sandbox URL.
- This change, combined with the backend URL update, fully transitions the application to use subdomain-based routing (e.g., 'http://api.lvh.me:8000/resource/') instead of path-prefix routing (e.g., 'http://api.lvh.me:8000/api/resource/').
2025-12-01 02:14:17 -05:00
poduck
92724d03b6 refactor(api): Remove '/api' prefix from frontend API calls and config
- Removed '/api/' prefix from endpoint paths in auth.ts, notifications.ts, oauth.ts, and platform.ts to align with the backend URL reconfiguration.
- Updated 'API_BASE_URL' in config.ts to remove the '/api' suffix, ensuring that API requests are correctly routed to the root of the 'api' subdomain (e.g., http://api.lvh.me:8000/).
- Included improvements to login redirect logic in client.ts.
2025-12-01 01:48:22 -05:00
poduck
2ec78a5237 fix(urls): Remove 'api/' prefix from tickets endpoint to match frontend expectation 2025-12-01 01:45:22 -05:00
poduck
a274d70cec feat(websocket): Resolve ticket WebSocket disconnection/reconnection issue
This commit addresses the persistent WebSocket disconnection and reconnection
problem experienced with ticket updates. The root cause was identified as the
Django backend not running as an ASGI server, which is essential for WebSocket
functionality, and incorrect WebSocket routing.

The following changes were made:

- **Frontend ():**
  - Updated to append the  from cookies to the WebSocket URL's
    query parameter for authentication, ensuring the token is sent with the
    WebSocket connection request.

- **Backend Configuration:**
  - **:** Modified to explicitly
    start the Daphne ASGI server using  instead
    of . This ensures the backend runs in ASGI
    mode, capable of handling WebSocket connections.
  - **:** Removed 'daphne' from
    . Daphne is an ASGI server, not a traditional Django
    application, and its presence in  was causing application
    startup failures.
  - **:**
    - Removed  from  as it
      conflicts with Channels' ASGI server takeover.
    - Explicitly set  to ensure
      the ASGI entry point is correctly referenced.
  - **:** Added 'channels'
    to , ensuring the Channels application is correctly loaded
    within the multi-tenant setup, enabling ASGI functionality.

- **Backend Middleware & Routing:**
  - **:** Implemented a custom
     to authenticate WebSocket connections using an
     from either a query parameter or cookies. This middleware
    ensures proper user authentication for WebSocket sessions. Debugging
    prints with  were added for better visibility.
  - **:** Adjusted WebSocket URL regexes
    to  for robustness, ensuring correct matching
    regardless of leading/trailing slashes in the path.

These changes collectively ensure that WebSocket connections are properly
initiated by the frontend, authenticated by the backend, and served by
an ASGI-compliant server, resolving the frequent disconnection/reconnection
issue.
2025-12-01 01:40:45 -05:00
poduck
be3b5b2d08 Fix: Resolve production CORS issues by moving CorsMiddleware before TenantMainMiddleware
Root cause: CorsMiddleware was positioned after TenantMainMiddleware, which
prevented CORS headers from being set. The tenant middleware processes requests
before CORS middleware could add the necessary headers.

Changes:
- Moved CorsMiddleware to first position in MIDDLEWARE stack
- Added CORS_ALLOW_ALL_ORIGINS configuration (for testing only)
- Updated production CORS regex to match both base and subdomains
- Created public tenant and registered production domains
- Re-enabled CORS_URLS_REGEX for API security

This fix ensures proper CORS headers are sent for cross-origin requests from
smoothschedule.com domains to api.smoothschedule.com.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 21:29:00 -05:00
poduck
89f2b570b3 Fix: Move notifications to SHARED_APPS for platform-to-tenant notifications
Platform users need to send notifications to business tenants, so the
notifications app must be in SHARED_APPS (public schema) rather than
TENANT_APPS (tenant-specific schemas).

Changes:
- Moved notifications from TENANT_APPS to SHARED_APPS in multitenancy.py
- Run migrations on public schema to create notifications tables

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 21:15:03 -05:00
poduck
885d8bbba2 Fix: Add lvh.me to CORS allowed origins for development
- Added http://lvh.me:5173 and http://lvh.me:5174 to CORS_ALLOWED_ORIGINS
- Added regex pattern to allow all *.lvh.me subdomains in CORS_ALLOWED_ORIGIN_REGEXES
- This allows frontend at lvh.me:5173 to make requests to API at api.lvh.me:8000
- CORS preflight requests now return proper Access-Control-Allow-Origin headers
- Quick login and all API calls from frontend now work without CORS errors

Testing confirmed:
✓ OPTIONS request to /api/auth-token/ returns 200 OK with CORS headers
✓ Access-Control-Allow-Origin: http://lvh.me:5173
✓ Access-Control-Allow-Methods: DELETE, GET, OPTIONS, PATCH, POST, PUT

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 21:06:46 -05:00
poduck
c0c037e3b9 Fix: Use api.lvh.me:8000 consistently for development API access
- Changed VITE_API_URL from localhost:8000 to api.lvh.me:8000
- Registered api.lvh.me domain in database pointing to public schema
- This maintains consistency between development and production where
  api subdomain is used for API access
- All test users can now authenticate via quick login

The development setup now mirrors production:
- Production: api.smoothschedule.com → Django API
- Development: api.lvh.me:8000 → Django API (via docker container)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 21:05:40 -05:00
poduck
52dde7c95b Fix: Resolve Django settings import error and fix quick login API endpoint
Settings Refactoring Fixes:
- Add minimal LOGGING structure to base.py for multitenancy.py to extend
- Restore LOGGING import in multitenancy.py
- Add development LOGGING configuration to local.py
- This allows multitenancy.py to extend LOGGING configuration properly

Quick Login Fix:
- Update frontend .env.development to use VITE_API_URL=http://localhost:8000
- Previous configuration tried to access api.lvh.me which failed due to
  django-tenants not recognizing that hostname
- Using localhost:8000 directly bypasses subdomain routing and accesses
  the public schema where auth endpoints are available

Both fixes restore full functionality:
- Django now starts without import errors in local development
- Quick login API calls now succeed and return authentication tokens
- Frontend can authenticate users for development/testing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 21:02:24 -05:00
poduck
af92a5ebf4 Refactor: Clean up settings files to eliminate duplication
- Remove duplicate FRONTEND_URL from base.py (was defined twice)
- Move LOGGING configuration from base.py to production.py (production-specific)
- Keep base.py minimal with only universal settings
- Verify EMAIL and AWS/STORAGES are production-only
- Update multitenancy.py import to not reference LOGGING from base.py

This ensures proper separation of concerns:
- base.py: Universal settings (DATABASES, CORS, CSRF, SECURITY basics)
- local.py: Development-specific (CSP, debug tools, console email, eager celery)
- production.py: Production-specific (LOGGING, EMAIL, AWS/S3, SECURITY headers, Sentry)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 20:47:48 -05:00
poduck
3ea71408db Refactor: Move CORS and CSRF to base.py with environment variable configuration
- Move CORS_ALLOWED_ORIGINS to base.py, configurable via DJANGO_CORS_ALLOWED_ORIGINS env var
- Move CORS_ALLOWED_ORIGIN_REGEXES to base.py, configurable via DJANGO_CORS_ALLOWED_ORIGIN_REGEXES
- Move CSRF_TRUSTED_ORIGINS to base.py, configurable via DJANGO_CSRF_TRUSTED_ORIGINS
- Remove duplicate CORS/CSRF config from local.py (now inherits from base)
- Remove production-specific CORS config (now uses env vars from base)
- Allows development and production to use same settings with different .env variables
2025-11-30 20:38:00 -05:00
poduck
60708a6417 Add CORS and CSRF configuration to production settings
- Add CORS_ALLOWED_ORIGINS configurable via DJANGO_CORS_ALLOWED_ORIGINS env var
- Add CORS_ALLOWED_ORIGIN_REGEXES for wildcard subdomains
- Add CSRF_TRUSTED_ORIGINS for production domain
- Support custom domains via DJANGO_DOMAIN_NAME env var
- Use corsheaders.defaults for standard CORS headers
- Add custom headers: x-business-subdomain, x-sandbox-mode
2025-11-30 20:37:11 -05:00
poduck
349a54e264 Fix: Remove duplicate middlewares key in Traefik configuration 2025-11-30 20:28:27 -05:00
poduck
c8c0669801 Fix: Correct frontend build context path in production docker-compose 2025-11-30 20:14:32 -05:00
poduck
fa68b4a869 Update README with production deployment guide reference 2025-11-30 20:13:33 -05:00
poduck
b958f9368b Add comprehensive production deployment manual guide 2025-11-30 20:13:19 -05:00
poduck
2b321aef57 Add missing frontend platform components and update production deployment
This commit adds all previously untracked files and modifications needed for production deployment:
- New marketing components (BenefitsSection, CodeBlock, PluginShowcase, PricingTable)
- Platform admin components (EditPlatformEntityModal, PlatformListRow, PlatformListing, PlatformTable)
- Updated deployment configuration and scripts
- Various frontend API and component improvements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 19:49:06 -05:00
poduck
0d1a3045fb Feat: Add marketing site and switch to git-based deployment 2025-11-30 16:18:11 -05:00
poduck
2b28fc49c9 fix: Remove /api/ prefix from all API endpoints
- Fixed double /api/api/ issue in production
- Updated all API files to remove /api/ prefix since baseURL already includes it
- Files fixed: platform.ts, oauth.ts, customDomains.ts, domains.ts, business.ts, sandbox.ts
- Production build will need to be rebuilt after pulling these changes
2025-11-30 16:04:20 -05:00
poduck
4cd6610f2a Fix double /api/ prefix in API endpoint calls
When VITE_API_URL=/api, axios baseURL is already set to /api. However, all endpoint calls included the /api/ prefix, creating double paths like /api/api/auth/login/.

Removed /api/ prefix from 81 API endpoint calls across 22 files:
- src/api/auth.ts - Fixed login, logout, me, refresh, hijack endpoints
- src/api/client.ts - Fixed token refresh endpoint
- src/api/profile.ts - Fixed all profile, email, password, MFA, sessions endpoints
- src/hooks/*.ts - Fixed all remaining API calls (users, appointments, resources, etc)
- src/pages/*.tsx - Fixed signup and email verification endpoints

This ensures API requests use the correct path: /api/auth/login/ instead of /api/api/auth/login/

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 15:27:57 -05:00
poduck
f1d4dac9d2 Fix deployment: Inject DATABASE_URL in deploy script 2025-11-30 02:08:36 -05:00
poduck
25db8dd35a Fix API URL duplication: Remove /api suffix from VITE_API_URL 2025-11-30 02:04:43 -05:00
poduck
9eb07a87e6 Fix hardcoded domain redirect: Set FRONTEND_URL in production 2025-11-30 01:59:29 -05:00
poduck
613acf17c1 Fix production 404 errors: Add missing OAuth endpoints and domain script 2025-11-30 01:37:19 -05:00
poduck
3ddd762d74 fix: Add missing Django core apps and DigitalOcean Spaces support
- Add django.contrib.auth, contenttypes, sessions, sites, messages,
  staticfiles, and admin to INSTALLED_APPS
- Add DigitalOcean Spaces (S3-compatible) storage configuration
- Add AWS_S3_ENDPOINT_URL setting for custom S3 endpoints

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 21:32:29 -05:00
900 changed files with 227849 additions and 9110 deletions

View File

@@ -0,0 +1,125 @@
from schedule.models import EmailTemplate
import json
html_content = """
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 20px;">
<tbody><tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 6px; box-shadow: 0 4px 6px rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.08);">
<!-- Header -->
<tbody><tr>
<td style="background-color: #4f46e5; padding: 30px; text-align: center; border-radius: 6px 6px 0 0;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">Appointment Confirmed</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 40px 30px;">
<p style="margin: 0 0 20px 0; color: #374151; font-size: 16px; line-height: 1.6;">
Hello <strong>{{CUSTOMER_NAME}}</strong>,
</p>
<p style="margin: 0 0 30px 0; color: #374151; font-size: 16px; line-height: 1.6;">
Your appointment has been confirmed. We look forward to seeing you!
</p>
<!-- Appointment Details Card -->
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; border-radius: 6px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<tbody><tr>
<td style="padding: 20px;">
<table width="100%" cellpadding="8" cellspacing="0">
<tbody><tr>
<td style="color: #6b7280; font-size: 14px; font-weight: 600;">Service:</td>
<td style="color: #111827; font-size: 14px; text-align: right;">{{SERVICE_NAME}}</td>
</tr>
<tr>
<td style="color: #6b7280; font-size: 14px; font-weight: 600;">Date &amp; Time:</td>
<td style="color: #111827; font-size: 14px; text-align: right;">{{EVENT_START_DATETIME}}</td>
</tr>
<tr>
<td style="color: #6b7280; font-size: 14px; font-weight: 600;">Duration:</td>
<td style="color: #111827; font-size: 14px; text-align: right;">{{SERVICE_DURATION}} minutes</td>
</tr>
<tr>
<td style="color: #6b7280; font-size: 14px; font-weight: 600;">With:</td>
<td style="color: #111827; font-size: 14px; text-align: right;">{{STAFF_NAME}}</td>
</tr>
</tbody></table>
</td>
</tr>
</tbody></table>
<!-- Call to Action Button (example - not in original but good to show professional button style) -->
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom: 30px;">
<tr>
<td align="center" style="padding-top: 10px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td align="center" bgcolor="#4f46e5" style="border-radius: 5px; background-color: #4f46e5; padding: 12px 25px;">
<a href="{{VIEW_APPOINTMENT_LINK}}" target="_blank" style="font-size: 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; color: #ffffff; text-decoration: none; font-weight: 600; display: inline-block;">View My Appointment</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; color: #6b7280; font-size: 14px; line-height: 1.6;">
If you need to reschedule or cancel, please contact us at least 24 hours in advance.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f9fafb; padding: 20px 30px; text-align: center; border-radius: 0 0 6px 6px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; color: #6b7280; font-size: 14px;">
<strong>{{BUSINESS_NAME}}</strong><br>
{{BUSINESS_EMAIL}} | {{BUSINESS_PHONE}}
</p>
</td>
</tr>
</tbody></table>
</td>
</tr>
</tbody></table>
</body>
"""
text_content = """
Hello {{CUSTOMER_NAME}},
Your appointment has been confirmed. We look forward to seeing you!
---
Appointment Details:
Service: {{SERVICE_NAME}}
Date & Time: {{EVENT_START_DATETIME}}
Duration: {{SERVICE_DURATION}} minutes
With: {{STAFF_NAME}}
---
If you need to reschedule or cancel, please contact us at least 24 hours in advance.
View your appointment: {{VIEW_APPOINTMENT_LINK}}
---
{{BUSINESS_NAME}}
{{BUSINESS_EMAIL}} | {{BUSINESS_PHONE}}
"""
template_name = "Appointment Confirmed" # Assuming this is the name of the template to update
try:
template = EmailTemplate.objects.get(name=template_name)
template.html_content = html_content
template.text_content = text_content
template.save()
print(f"Successfully updated template '{template_name}'.")
except EmailTemplate.DoesNotExist:
print(f"Error: Template '{template_name}' not found.")
except Exception as e:
print(f"An error occurred: {e}")

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Test coverage reports (generated)
frontend/coverage/

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,17 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ourVersions">
<value>
<list size="3">
<item index="0" class="java.lang.String" itemvalue="3.14" />
<item index="1" class="java.lang.String" itemvalue="3.7" />
<item index="2" class="java.lang.String" itemvalue="3.8" />
</list>
</value>
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

18
.idea/smoothschedule2.iml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/smoothschedule/schedule/templates" />
</list>
</option>
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

352
ANALYTICS_CHANGES.md Normal file
View File

@@ -0,0 +1,352 @@
# Advanced Analytics Implementation - Change Summary
## Overview
Successfully implemented the Advanced Analytics feature with permission-based access control in the Django backend. All analytics endpoints are gated behind the `advanced_analytics` permission from the subscription plan.
## Files Created
### Analytics App (`/smoothschedule/analytics/`)
1. **`__init__.py`** - Package initialization
2. **`apps.py`** - Django app configuration
3. **`admin.py`** - Admin interface (read-only app, no models)
4. **`views.py`** - AnalyticsViewSet with 3 endpoints:
- `dashboard()` - Summary statistics
- `appointments()` - Detailed appointment analytics
- `revenue()` - Revenue analytics (dual-permission gated)
5. **`serializers.py`** - Response serializers for data validation
6. **`urls.py`** - URL routing
7. **`tests.py`** - Comprehensive pytest test suite
8. **`migrations/`** - Empty migrations directory
9. **`README.md`** - Full API documentation
10. **`IMPLEMENTATION_GUIDE.md`** - Developer implementation guide
## Files Modified
### 1. `/smoothschedule/core/permissions.py`
**Changes:**
- Added `advanced_analytics` and `advanced_reporting` to the `FEATURE_NAMES` dictionary in `HasFeaturePermission`
**Before:**
```python
FEATURE_NAMES = {
'can_use_sms_reminders': 'SMS Reminders',
...
'can_use_calendar_sync': 'Calendar Sync',
}
```
**After:**
```python
FEATURE_NAMES = {
'can_use_sms_reminders': 'SMS Reminders',
...
'can_use_calendar_sync': 'Calendar Sync',
'advanced_analytics': 'Advanced Analytics',
'advanced_reporting': 'Advanced Reporting',
}
```
### 2. `/smoothschedule/config/urls.py`
**Changes:**
- Added analytics URL include in the API URL patterns
**Before:**
```python
# Schedule API (internal)
path("", include("schedule.urls")),
# Payments API
path("payments/", include("payments.urls")),
```
**After:**
```python
# Schedule API (internal)
path("", include("schedule.urls")),
# Analytics API
path("", include("analytics.urls")),
# Payments API
path("payments/", include("payments.urls")),
```
### 3. `/smoothschedule/config/settings/base.py`
**Changes:**
- Added `analytics` app to `LOCAL_APPS`
**Before:**
```python
LOCAL_APPS = [
"smoothschedule.users",
"core",
"schedule",
"payments",
...
]
```
**After:**
```python
LOCAL_APPS = [
"smoothschedule.users",
"core",
"schedule",
"analytics",
"payments",
...
]
```
## API Endpoints
All endpoints are located at `/api/analytics/` and require:
- Authentication via token or session
- `advanced_analytics` permission in tenant's subscription plan
### 1. Dashboard Summary
```
GET /api/analytics/analytics/dashboard/
```
Returns:
- Total appointments (this month and all-time)
- Active resources and services count
- Upcoming appointments
- Average appointment duration
- Peak booking day and hour
### 2. Appointment Analytics
```
GET /api/analytics/analytics/appointments/
```
Query Parameters:
- `days` (default: 30)
- `status` (optional: confirmed, cancelled, no_show)
- `service_id` (optional)
- `resource_id` (optional)
Returns:
- Total appointments
- Breakdown by status
- Breakdown by service and resource
- Daily breakdown
- Booking trends and rates
### 3. Revenue Analytics
```
GET /api/analytics/analytics/revenue/
```
Query Parameters:
- `days` (default: 30)
- `service_id` (optional)
Returns:
- Total revenue in cents
- Transaction count
- Average transaction value
- Revenue by service
- Daily breakdown
**Note:** Requires both `advanced_analytics` AND `can_accept_payments` permissions
## Permission Gating Implementation
### How It Works
1. **Request arrives at endpoint**
2. **IsAuthenticated check** - Verifies user is logged in
3. **HasFeaturePermission('advanced_analytics') check**:
- Gets tenant from request
- Calls `tenant.has_feature('advanced_analytics')`
- Checks both direct field and subscription plan JSON
4. **If permission exists** - View logic executes
5. **If permission missing** - 403 Forbidden returned with message
### Permission Check Logic
```python
# In core/models.py - Tenant.has_feature()
def has_feature(self, permission_key):
# Check direct field on Tenant model
if hasattr(self, permission_key):
return bool(getattr(self, permission_key))
# Check subscription plan permissions JSON
if self.subscription_plan:
plan_perms = self.subscription_plan.permissions or {}
return bool(plan_perms.get(permission_key, False))
return False
```
## Enabling Analytics for a Plan
### Via Django Admin
1. Go to `/admin/platform_admin/subscriptionplan/`
2. Edit a plan
3. Add to "Permissions" JSON field:
```json
{
"advanced_analytics": true
}
```
### Via Django Shell
```bash
docker compose -f docker-compose.local.yml exec django python manage.py shell
from platform_admin.models import SubscriptionPlan
plan = SubscriptionPlan.objects.get(name='Professional')
perms = plan.permissions or {}
perms['advanced_analytics'] = True
plan.permissions = perms
plan.save()
```
## Testing
### Permission Tests Included
The `analytics/tests.py` file includes comprehensive tests:
1. **TestAnalyticsPermissions**
- `test_analytics_requires_authentication` - 401 without auth
- `test_analytics_denied_without_permission` - 403 without permission
- `test_analytics_allowed_with_permission` - 200 with permission
- `test_dashboard_endpoint_structure` - Verify response structure
- `test_appointments_endpoint_with_filters` - Query parameters work
- `test_revenue_requires_payments_permission` - Dual permission check
- `test_multiple_permission_check` - Both checks enforced
2. **TestAnalyticsData**
- `test_dashboard_counts_appointments_correctly` - Correct counts
- `test_appointments_counts_by_status` - Status breakdown
- `test_cancellation_rate_calculation` - Rate calculation
### Running Tests
```bash
# Run all analytics tests
docker compose -f docker-compose.local.yml exec django pytest analytics/tests.py -v
# Run specific test
docker compose -f docker-compose.local.yml exec django pytest analytics/tests.py::TestAnalyticsPermissions::test_analytics_denied_without_permission -v
# Run with coverage
docker compose -f docker-compose.local.yml exec django pytest analytics/tests.py --cov=analytics
```
## Error Responses
### 401 Unauthorized (No Authentication)
```json
{
"detail": "Authentication credentials were not provided."
}
```
### 403 Forbidden (No Permission)
```json
{
"detail": "Your current plan does not include Advanced Analytics. Please upgrade your subscription to access this feature."
}
```
### 403 Forbidden (Revenue Endpoint - Missing Payments Permission)
```json
{
"error": "Payment analytics not available",
"detail": "Your plan does not include payment processing."
}
```
## Example Usage
### Get Dashboard Stats (with cURL)
```bash
TOKEN="your_auth_token_here"
curl -H "Authorization: Token $TOKEN" \
http://lvh.me:8000/api/analytics/analytics/dashboard/ | jq
```
### Get Appointment Analytics (with filters)
```bash
curl -H "Authorization: Token $TOKEN" \
"http://lvh.me:8000/api/analytics/analytics/appointments/?days=7&status=confirmed" | jq
```
### Get Revenue Analytics
```bash
curl -H "Authorization: Token $TOKEN" \
http://lvh.me:8000/api/analytics/analytics/revenue/ | jq
```
## Key Design Decisions
1. **ViewSet without models** - Analytics is calculated on-the-fly, no database models
2. **Read-only endpoints** - No POST/PUT/DELETE, only GET for querying
3. **Comprehensive permission naming** - Both `advanced_analytics` and `advanced_reporting` supported for flexibility
4. **Dual permission check** - Revenue endpoint requires both analytics and payments permissions
5. **Query parameter filtering** - Flexible filtering for reports
6. **Detailed error messages** - User-friendly upgrade prompts
## Documentation Provided
1. **README.md** - Complete API documentation with examples
2. **IMPLEMENTATION_GUIDE.md** - Developer guide for enabling and debugging
3. **Code comments** - Detailed docstrings in views and serializers
4. **Test file** - Comprehensive test suite with examples
## Next Steps
1. **Migrate** - No migrations needed (no database models)
2. **Configure Plans** - Add `advanced_analytics` permission to desired subscription plans
3. **Test** - Run the test suite to verify functionality
4. **Deploy** - Push to production
5. **Monitor** - Check logs for any issues
## Implementation Checklist
- [x] Create analytics app with ViewSet
- [x] Implement dashboard endpoint with summary statistics
- [x] Implement appointments endpoint with filtering
- [x] Implement revenue endpoint with dual permission check
- [x] Add permission to FEATURE_NAMES in core/permissions.py
- [x] Register app in INSTALLED_APPS
- [x] Add URL routing
- [x] Create serializers for response validation
- [x] Write comprehensive test suite
- [x] Document API endpoints
- [x] Document implementation details
- [x] Provide developer guide
## Files Summary
**Total Files Created:** 11
- 10 Python files (app code + tests)
- 2 Documentation files
**Total Files Modified:** 3
- core/permissions.py
- config/urls.py
- config/settings/base.py
**Lines of Code:**
- views.py: ~350 lines
- tests.py: ~260 lines
- serializers.py: ~80 lines
- Documentation: ~1000 lines
## Questions or Issues?
Refer to:
1. `analytics/README.md` - API usage and endpoints
2. `analytics/IMPLEMENTATION_GUIDE.md` - Setup and debugging
3. `analytics/tests.py` - Examples of correct usage
4. `core/permissions.py` - Permission checking logic

View File

@@ -0,0 +1,476 @@
# Calendar Sync Permission Implementation
## Summary
Successfully added permission checking for the calendar sync feature in the Django backend. The implementation follows the existing `HasFeaturePermission` pattern and gates access to calendar OAuth and sync operations.
## Files Modified and Created
### Core Changes
#### 1. **core/models.py** - Tenant Model
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/core/models.py`
Added new permission field to the Tenant model:
```python
can_use_calendar_sync = models.BooleanField(
default=False,
help_text="Whether this business can sync Google Calendar and other calendar providers"
)
```
**Impact:**
- New tenants will have `can_use_calendar_sync=False` by default
- Platform admins can enable this per-tenant via the Django admin or API
- Works with existing subscription plan system
#### 2. **core/migrations/0016_tenant_can_use_calendar_sync.py** - Database Migration
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/core/migrations/0016_tenant_can_use_calendar_sync.py`
Database migration that adds the `can_use_calendar_sync` boolean field to the Tenant table.
**How to apply:**
```bash
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
docker compose -f docker-compose.local.yml exec django python manage.py migrate
```
#### 3. **core/permissions.py** - Permission Check
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/core/permissions.py`
Updated `HasFeaturePermission` factory function:
- Added `'can_use_calendar_sync': 'Calendar Sync'` to `FEATURE_NAMES` mapping
- This displays user-friendly error messages when the feature is not available
- Follows the existing pattern used by other features (SMS reminders, webhooks, etc.)
**Usage Pattern:**
```python
from core.permissions import HasFeaturePermission
from rest_framework.permissions import IsAuthenticated
class MyViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, HasFeaturePermission('can_use_calendar_sync')]
```
#### 4. **core/oauth_views.py** - OAuth Permission Checks
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/core/oauth_views.py`
Updated OAuth views to check calendar sync permission when initiating calendar-specific OAuth flows:
**GoogleOAuthInitiateView:**
- Imported `HasFeaturePermission` from core.permissions
- Added check: If `purpose == 'calendar'`, verify tenant has `can_use_calendar_sync` permission
- Returns 403 Forbidden with upgrade message if permission denied
- Email OAuth (`purpose == 'email'`) is NOT affected by this check
**MicrosoftOAuthInitiateView:**
- Same pattern as Google OAuth
- Supports both email and calendar purposes with respective permission checks
**Docstring updates:**
Both views now document the permission requirements:
```
Permission Requirements:
- For "email" purpose: IsPlatformAdmin only
- For "calendar" purpose: Requires can_use_calendar_sync feature permission
```
### New Calendar Sync Implementation
#### 5. **schedule/calendar_sync_views.py** - Calendar Sync Endpoints
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/schedule/calendar_sync_views.py`
Created comprehensive calendar sync views with permission checking:
**CalendarSyncPermission Custom Permission:**
- Combines authentication check with feature permission check
- Used by all calendar sync endpoints
- Ensures both user is authenticated AND tenant has permission
**CalendarListView (GET /api/calendar/list/)**
- Lists connected calendars for the current tenant
- Returns OAuth credentials with masked tokens
- Protected by CalendarSyncPermission
**CalendarSyncView (POST /api/calendar/sync/)**
- Initiates calendar event synchronization
- Accepts credential_id, calendar_id, start_date, end_date
- Verifies credential belongs to tenant
- Checks credential validity before sync
- TODO: Implement actual calendar API integration
**CalendarDeleteView (DELETE /api/calendar/disconnect/)**
- Disconnects/revokes a calendar integration
- Removes the OAuth credential
- Logs the action for audit trail
**CalendarStatusView (GET /api/calendar/status/)**
- Informational endpoint (authentication only, not feature-gated)
- Returns whether calendar sync is enabled for tenant
- Shows number of connected calendars
- User-friendly message if feature not available
#### 6. **schedule/calendar_sync_urls.py** - URL Configuration
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/schedule/calendar_sync_urls.py`
URL routes for calendar sync endpoints:
```
/api/calendar/status/ - Check calendar sync status
/api/calendar/list/ - List connected calendars
/api/calendar/sync/ - Sync calendar events
/api/calendar/disconnect/ - Disconnect a calendar
```
To integrate with main URL config, add to config/urls.py:
```python
path("calendar/", include("schedule.calendar_sync_urls", namespace="calendar")),
```
#### 7. **schedule/tests/test_calendar_sync_permissions.py** - Test Suite
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/schedule/tests/test_calendar_sync_permissions.py`
Comprehensive test suite with 20+ tests covering:
**CalendarSyncPermissionTests:**
- `test_calendar_list_without_permission` - Verify 403 when disabled
- `test_calendar_sync_without_permission` - Verify 403 when disabled
- `test_oauth_calendar_initiate_without_permission` - Verify OAuth rejects calendar
- `test_calendar_list_with_permission` - Verify 200 when enabled
- `test_calendar_with_connected_credential` - Verify credential appears in list
- `test_unauthenticated_calendar_access` - Verify 401 for anonymous users
**CalendarSyncIntegrationTests:**
- `test_full_calendar_workflow` - Complete workflow (list → connect → sync → disconnect)
**TenantPermissionModelTests:**
- `test_tenant_can_use_calendar_sync_default` - Verify default False
- `test_has_feature_with_other_permissions` - Verify method works correctly
**Run tests:**
```bash
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
docker compose -f docker-compose.local.yml exec django pytest schedule/tests/test_calendar_sync_permissions.py -v
```
#### 8. **CALENDAR_SYNC_INTEGRATION.md** - Integration Guide
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/CALENDAR_SYNC_INTEGRATION.md`
Comprehensive developer guide including:
- Architecture overview
- Permission flow diagram
- API endpoint examples with curl commands
- Integration patterns with ViewSets
- Testing examples
- Security considerations
- Related files reference
## Permission Flow
```
User Request to Calendar Endpoint
1. [Is User Authenticated?]
├─ NO → 401 Unauthorized
└─ YES ↓
2. [Request Has Tenant Context?]
├─ NO → 400 Bad Request
└─ YES ↓
3. [Does Tenant have can_use_calendar_sync?]
├─ NO → 403 Forbidden (upgrade message)
└─ YES ↓
4. [Process Request]
├─ Success → 200 OK
└─ Error → 500 Server Error
```
## Implementation Details
### Permission Field Design
The `can_use_calendar_sync` field:
- Is a BooleanField on the Tenant model
- Defaults to False (disabled by default)
- Can be set per-tenant by platform admins
- Works alongside subscription_plan.permissions for more granular control
- Integrates with existing `has_feature()` method on Tenant
### How Permission Checking Works
#### In OAuth Views
```python
# Check calendar sync permission if purpose is calendar
if purpose == 'calendar':
calendar_permission = HasFeaturePermission('can_use_calendar_sync')
if not calendar_permission().has_permission(request, self):
return Response({
'success': False,
'error': 'Your current plan does not include Calendar Sync...',
}, status=status.HTTP_403_FORBIDDEN)
```
#### In Calendar Sync Views
```python
class CalendarSyncPermission(IsAuthenticated):
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
tenant = getattr(request, 'tenant', None)
if not tenant:
return False
return tenant.has_feature('can_use_calendar_sync')
class CalendarListView(APIView):
permission_classes = [CalendarSyncPermission]
```
### Separation of Concerns
- **Email OAuth**: Not affected by calendar sync permission (separate feature)
- **Calendar OAuth**: Requires calendar sync permission only when `purpose='calendar'`
- **Calendar Sync**: Requires calendar sync permission for all operations
- **Calendar Status**: Authentication only (informational endpoint)
## Security Considerations
1. **Multi-Tenancy Isolation**
- All OAuthCredential queries filter by tenant
- Users can only access their own tenant's calendars
- Credentials are not shared between tenants
2. **Token Security**
- OAuth tokens stored encrypted at rest (via Django settings)
- Tokens masked in API responses
- Token validity checked before use
3. **CSRF Protection**
- OAuth state parameter validated
- Standard Django session handling
4. **Audit Trail**
- All calendar operations logged with tenant/user info
- Sync operations logged with timestamps
- Disconnect operations logged
5. **Feature Gating**
- Permission checked at view level
- No way to bypass by direct API access
- Consistent error messages for upgrade prompts
## API Examples
### Check if Feature is Available
```bash
GET /api/calendar/status/
# Response (if enabled):
{
"success": true,
"can_use_calendar_sync": true,
"total_connected": 2
}
# Response (if disabled):
{
"success": true,
"can_use_calendar_sync": false,
"message": "Calendar Sync feature is not available for your plan"
}
```
### Initiate Calendar OAuth
```bash
POST /api/oauth/google/initiate/
Content-Type: application/json
{
"purpose": "calendar"
}
# Response (if permission granted):
{
"success": true,
"authorization_url": "https://accounts.google.com/o/oauth2/auth?..."
}
# Response (if permission denied):
{
"success": false,
"error": "Your current plan does not include Calendar Sync. Please upgrade..."
}
```
### List Connected Calendars
```bash
GET /api/calendar/list/
# Response:
{
"success": true,
"calendars": [
{
"id": 1,
"provider": "Google",
"email": "user@gmail.com",
"is_valid": true,
"is_expired": false,
"created_at": "2025-12-01T08:15:00Z"
}
]
}
```
## Testing the Implementation
### Manual Testing via API
1. **Test without permission:**
```bash
# Create a user in a tenant without calendar sync
curl -X GET http://lvh.me:8000/api/calendar/list/ \
-H "Authorization: Bearer <token>"
# Expected: 403 Forbidden
```
2. **Test with permission:**
```bash
# Enable calendar sync on tenant
# Then try again:
curl -X GET http://lvh.me:8000/api/calendar/list/ \
-H "Authorization: Bearer <token>"
# Expected: 200 OK with calendar list
```
### Run Test Suite
```bash
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
# Run all calendar permission tests
docker compose -f docker-compose.local.yml exec django pytest \
schedule/tests/test_calendar_sync_permissions.py -v
# Run specific test
docker compose -f docker-compose.local.yml exec django pytest \
schedule/tests/test_calendar_sync_permissions.py::CalendarSyncPermissionTests::test_calendar_list_without_permission -v
```
### Django Shell Testing
```bash
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
docker compose -f docker-compose.local.yml exec django python manage.py shell
# In Django shell:
from core.models import Tenant
from smoothschedule.users.models import User
tenant = Tenant.objects.get(schema_name='demo')
print(tenant.has_feature('can_use_calendar_sync')) # False initially
# Enable it
tenant.can_use_calendar_sync = True
tenant.save()
print(tenant.has_feature('can_use_calendar_sync')) # True now
```
## Integration with Existing Systems
### Works with Subscription Plans
```python
# Tenant can get permission from subscription_plan.permissions
subscription_plan.permissions = {
'can_use_calendar_sync': True,
'can_use_webhooks': True,
...
}
```
### Works with Platform Admin Invitations
```python
# TenantInvitation can grant this permission
invitation = TenantInvitation(
can_use_calendar_sync=True,
...
)
```
### Works with User Role-Based Access
- Permission is at tenant level, not user level
- All users in a tenant with enabled feature can use it
- Can be further restricted by user roles if needed
## Next Steps for Full Implementation
While the permission framework is complete, the following features need implementation:
1. **Google Calendar API Integration**
- Fetch events from Google Calendar API using OAuth token
- Map Google Calendar events to Event model
- Handle recurring events
- Sync deleted events
2. **Microsoft Calendar API Integration**
- Fetch events from Microsoft Graph API
- Handle Outlook calendar format
3. **Conflict Resolution**
- Handle overlapping events from multiple calendars
- Update vs. create decision logic
4. **Bi-directional Sync**
- Push events back to calendar after scheduling
- Handle edit/delete synchronization
5. **UI/Frontend Integration**
- Calendar selection dialog
- Sync status display
- Calendar disconnect confirmation
## Rollback Plan
If needed to rollback:
1. **Revert database migration:**
```bash
docker compose -f docker-compose.local.yml exec django python manage.py migrate core 0015_tenant_can_create_plugins_tenant_can_use_webhooks
```
2. **Revert code changes:**
- Remove lines from core/models.py (can_use_calendar_sync field)
- Remove calendar check from oauth_views.py
- Remove calendar_sync_views.py
- Remove calendar_sync_urls.py
3. **Revert permissions.py:**
- Remove 'can_use_calendar_sync' from FEATURE_NAMES
## Summary of Changes
| File | Type | Change |
|------|------|--------|
| core/models.py | Modified | Added can_use_calendar_sync field to Tenant |
| core/migrations/0016_tenant_can_use_calendar_sync.py | New | Database migration |
| core/permissions.py | Modified | Added can_use_calendar_sync to FEATURE_NAMES |
| core/oauth_views.py | Modified | Added permission check for calendar OAuth |
| schedule/calendar_sync_views.py | New | Calendar sync API views |
| schedule/calendar_sync_urls.py | New | Calendar sync URL configuration |
| schedule/tests/test_calendar_sync_permissions.py | New | Test suite (20+ tests) |
| CALENDAR_SYNC_INTEGRATION.md | New | Integration guide |
## File Locations
All files are located in: `/home/poduck/Desktop/smoothschedule2/smoothschedule/`
**Key files:**
- Models: `core/models.py` (line 194-197)
- Migration: `core/migrations/0016_tenant_can_use_calendar_sync.py`
- Permissions: `core/permissions.py` (line 354)
- OAuth Views: `core/oauth_views.py` (lines 27, 92-98, 241-247)
- Calendar Views: `schedule/calendar_sync_views.py` (entire file)
- Calendar URLs: `schedule/calendar_sync_urls.py` (entire file)
- Tests: `schedule/tests/test_calendar_sync_permissions.py` (entire file)
- Documentation: `CALENDAR_SYNC_INTEGRATION.md`

350
CLAUDE.md
View File

@@ -61,14 +61,293 @@ docker compose -f docker-compose.local.yml exec django python manage.py <command
| `frontend/src/api/client.ts` | Axios API client |
| `frontend/src/types.ts` | TypeScript interfaces |
| `frontend/src/i18n/locales/en.json` | Translations |
| `frontend/src/utils/dateUtils.ts` | Date formatting utilities |
## Key Django Apps
## Timezone Architecture (CRITICAL)
All date/time handling follows this architecture to ensure consistency across timezones.
### Core Principles
1. **Database**: All times stored in UTC
2. **API Communication**: Always use UTC (both directions)
3. **API Responses**: Include `business_timezone` field
4. **Frontend Display**: Convert UTC based on `business_timezone`
- If `business_timezone` is set → display in that timezone
- If `business_timezone` is null/blank → display in user's local timezone
### Data Flow
```
FRONTEND (User in Eastern Time selects "Dec 8, 2:00 PM")
Convert to UTC: "2024-12-08T19:00:00Z"
Send to API (always UTC)
DATABASE (stores UTC)
API RESPONSE:
{
"start_time": "2024-12-08T19:00:00Z", // Always UTC
"business_timezone": "America/Denver" // IANA timezone (or null for local)
}
FRONTEND CONVERTS:
- If business_timezone set: UTC → Mountain Time → "Dec 8, 12:00 PM MST"
- If business_timezone null: UTC → User local → "Dec 8, 2:00 PM EST"
```
### Frontend Helper Functions
Located in `frontend/src/utils/dateUtils.ts`:
```typescript
import {
toUTC,
fromUTC,
formatForDisplay,
formatDateForDisplay,
getDisplayTimezone,
} from '../utils/dateUtils';
// SENDING TO API - Always convert to UTC
const apiPayload = {
start_time: toUTC(selectedDateTime), // "2024-12-08T19:00:00Z"
};
// RECEIVING FROM API - Convert for display
const displayTime = formatForDisplay(
response.start_time, // UTC from API
response.business_timezone // "America/Denver" or null
);
// Result: "Dec 8, 2024 12:00 PM" (in business or local timezone)
// DATE-ONLY fields (time blocks)
const displayDate = formatDateForDisplay(
response.start_date,
response.business_timezone
);
```
### API Response Requirements
All endpoints returning date/time data MUST include:
```python
# In serializers or views
{
"start_time": "2024-12-08T19:00:00Z",
"business_timezone": business.timezone, # "America/Denver" or None
}
```
### Backend Serializer Mixin
Use `TimezoneSerializerMixin` from `core/mixins.py` to automatically add the timezone field:
```python
from core.mixins import TimezoneSerializerMixin
class EventSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Event
fields = [
'id', 'start_time', 'end_time',
# ... other fields ...
'business_timezone', # Provided by mixin
]
read_only_fields = ['business_timezone']
```
The mixin automatically retrieves the timezone from the tenant context.
- Returns the IANA timezone string if set (e.g., "America/Denver")
- Returns `null` if not set (frontend uses user's local timezone)
### Common Mistakes to Avoid
```typescript
// BAD - Uses browser local time, not UTC
date.toISOString().split('T')[0]
// BAD - Doesn't respect business timezone setting
new Date(utcString).toLocaleString()
// GOOD - Use helper functions
toUTC(date) // For API requests
formatForDisplay(utcString, businessTimezone) // For displaying
```
## Django App Organization (Domain-Based)
Apps are organized into domain packages under `smoothschedule/smoothschedule/`:
### Identity Domain
| App | Location | Purpose |
|-----|----------|---------|
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
| `core` | `identity/core/` | Tenant, Domain, PermissionGrant, middleware, mixins |
| `users` | `identity/users/` | User model, authentication, MFA |
### Scheduling Domain
| App | Location | Purpose |
|-----|----------|---------|
| `schedule` | `scheduling/schedule/` | Resources, Events, Services, Participants |
| `contracts` | `scheduling/contracts/` | Contract/e-signature system |
| `analytics` | `scheduling/analytics/` | Business analytics and reporting |
### Communication Domain
| App | Location | Purpose |
|-----|----------|---------|
| `notifications` | `communication/notifications/` | Notification system |
| `credits` | `communication/credits/` | SMS/calling credits |
| `mobile` | `communication/mobile/` | Field employee mobile app |
| `messaging` | `communication/messaging/` | Email templates and messaging |
### Commerce Domain
| App | Location | Purpose |
|-----|----------|---------|
| `payments` | `commerce/payments/` | Stripe Connect payments bridge |
| `tickets` | `commerce/tickets/` | Support ticket system |
### Platform Domain
| App | Location | Purpose |
|-----|----------|---------|
| `admin` | `platform/admin/` | Platform administration, subscriptions |
| `api` | `platform/api/` | Public API v1 for third-party integrations |
## Core Mixins & Base Classes
Located in `smoothschedule/smoothschedule/identity/core/mixins.py`. Use these to avoid code duplication.
### Permission Classes
```python
from smoothschedule.identity.core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
class MyViewSet(ModelViewSet):
# Block write operations for staff (GET allowed)
permission_classes = [IsAuthenticated, DenyStaffWritePermission]
# Block ALL operations for staff
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
# Block list/create/update/delete but allow retrieve
permission_classes = [IsAuthenticated, DenyStaffListPermission]
```
#### Per-User Permission Overrides
Staff permissions can be overridden on a per-user basis using the `user.permissions` JSONField.
Permission keys are auto-derived from the view's basename or model name:
| Permission Class | Auto-derived Key | Example |
|-----------------|------------------|---------|
| `DenyStaffWritePermission` | `can_write_{basename}` | `can_write_resources` |
| `DenyStaffAllAccessPermission` | `can_access_{basename}` | `can_access_services` |
| `DenyStaffListPermission` | `can_list_{basename}` or `can_access_{basename}` | `can_list_customers` |
**Current ViewSet permission keys:**
| ViewSet | Permission Class | Override Key |
|---------|-----------------|--------------|
| `ResourceViewSet` | `DenyStaffAllAccessPermission` | `can_access_resources` |
| `ServiceViewSet` | `DenyStaffAllAccessPermission` | `can_access_services` |
| `CustomerViewSet` | `DenyStaffListPermission` | `can_list_customers` or `can_access_customers` |
| `ScheduledTaskViewSet` | `DenyStaffAllAccessPermission` | `can_access_scheduled-tasks` |
**Granting a specific staff member access:**
```bash
# Open Django shell
docker compose -f docker-compose.local.yml exec django python manage.py shell
```
```python
from smoothschedule.identity.users.models import User
# Find the staff member
staff = User.objects.get(email='john@example.com')
# Grant read access to resources
staff.permissions['can_access_resources'] = True
staff.save()
# Or grant list access to customers (but not full CRUD)
staff.permissions['can_list_customers'] = True
staff.save()
```
**Custom permission keys (optional):**
```python
class ResourceViewSet(ModelViewSet):
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
# Override the auto-derived key
staff_access_permission_key = 'can_manage_equipment'
```
Then grant via: `staff.permissions['can_manage_equipment'] = True`
### QuerySet Mixins
```python
from smoothschedule.identity.core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
# For tenant-scoped models (automatic django-tenants filtering)
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
queryset = Resource.objects.all()
deny_staff_queryset = True # Optional: also filter staff at queryset level
def filter_queryset_for_tenant(self, queryset):
# Override for custom filtering
return queryset.filter(is_active=True)
# For User model (shared schema, needs explicit tenant filter)
class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
queryset = User.objects.filter(role=User.Role.CUSTOMER)
```
### Feature Permission Mixins
```python
from smoothschedule.identity.core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
# Checks can_use_plugins feature on list/retrieve/create
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
pass
# Checks both can_use_plugins AND can_use_tasks
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, ModelViewSet):
pass
```
### Base API Views (for non-ViewSet views)
```python
from rest_framework.views import APIView
from smoothschedule.identity.core.mixins import TenantAPIView, TenantRequiredAPIView
# Optional tenant - use self.get_tenant()
class MyView(TenantAPIView, APIView):
def get(self, request):
tenant = self.get_tenant() # May be None
return self.success_response({'data': 'value'})
# or: return self.error_response('Something went wrong', status_code=400)
# Required tenant - self.tenant always available
class MyTenantView(TenantRequiredAPIView, APIView):
def get(self, request):
# self.tenant is guaranteed to exist (returns 400 if missing)
return Response({'name': self.tenant.name})
```
### Helper Methods Available
| Method | Description |
|--------|-------------|
| `self.get_tenant()` | Get tenant from request (may be None) |
| `self.get_tenant_or_error()` | Returns (tenant, error_response) tuple |
| `self.error_response(msg, status_code)` | Standard error response |
| `self.success_response(data, status_code)` | Standard success response |
| `self.check_feature(key, name)` | Check feature permission, returns error or None |
## Common Tasks
@@ -100,3 +379,66 @@ curl -s "http://lvh.me:8000/api/resources/" | jq
## Git Branch
Currently on: `feature/platform-superuser-ui`
Main branch: `main`
## Production Deployment
### Quick Deploy
```bash
# From your local machine
cd /home/poduck/Desktop/smoothschedule2
./deploy.sh poduck@smoothschedule.com
```
### Initial Server Setup (one-time)
```bash
# Setup server dependencies
ssh poduck@smoothschedule.com 'bash -s' < server-setup.sh
# Setup DigitalOcean Spaces
ssh poduck@smoothschedule.com
./setup-spaces.sh
```
### Production URLs
- **Main site:** `https://smoothschedule.com`
- **Platform dashboard:** `https://platform.smoothschedule.com`
- **Tenant subdomains:** `https://*.smoothschedule.com`
- **Flower (Celery):** `https://smoothschedule.com:5555`
### Production Management
```bash
# SSH into server
ssh poduck@smoothschedule.com
# Navigate to project
cd ~/smoothschedule
# View logs
docker compose -f docker-compose.production.yml logs -f
# Run migrations
docker compose -f docker-compose.production.yml exec django python manage.py migrate
# Create superuser
docker compose -f docker-compose.production.yml exec django python manage.py createsuperuser
# Restart services
docker compose -f docker-compose.production.yml restart
# View status
docker compose -f docker-compose.production.yml ps
```
### Environment Variables
Production environment configured in:
- **Backend:** `smoothschedule/.envs/.production/.django`
- **Database:** `smoothschedule/.envs/.production/.postgres`
- **Frontend:** `frontend/.env.production`
### DigitalOcean Spaces
- **Bucket:** `smoothschedule`
- **Region:** `nyc3`
- **Endpoint:** `https://nyc3.digitaloceanspaces.com`
- **Public URL:** `https://smoothschedule.nyc3.digitaloceanspaces.com`
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed deployment guide.

View File

@@ -0,0 +1,155 @@
# Data Export API Implementation Summary
## Overview
Implemented a comprehensive data export feature for the SmoothSchedule Django backend that allows businesses to export their data in CSV and JSON formats. The feature is properly gated by subscription plan permissions.
## Implementation Date
December 2, 2025
## Files Created/Modified
### New Files Created
1. **`/home/poduck/Desktop/smoothschedule2/smoothschedule/schedule/export_views.py`**
- Main export API implementation
- Contains `ExportViewSet` with 4 export endpoints
- Implements permission checking via `HasExportDataPermission`
- Supports both CSV and JSON formats
- ~450 lines of code
2. **`/home/poduck/Desktop/smoothschedule2/smoothschedule/schedule/test_export.py`**
- Comprehensive test suite for export API
- Tests all endpoints, formats, filters
- Tests permission gating
- ~200 lines of test code
3. **`/home/poduck/Desktop/smoothschedule2/smoothschedule/DATA_EXPORT_API.md`**
- Complete API documentation
- Request/response examples
- Query parameter documentation
- Error handling documentation
- ~300 lines of documentation
4. **`/home/poduck/Desktop/smoothschedule2/test_export_api.py`**
- Standalone test script for manual API testing
- Can be run outside of Django test framework
### Modified Files
1. **`/home/poduck/Desktop/smoothschedule2/smoothschedule/schedule/urls.py`**
- Added import for `ExportViewSet`
- Registered export viewset with router
2. **`/home/poduck/Desktop/smoothschedule2/smoothschedule/core/models.py`**
- Added `can_export_data` BooleanField to Tenant model
- Field defaults to `False` (permission must be explicitly granted)
- Field already had migration applied (0014_tenant_can_export_data_tenant_subscription_plan.py)
## API Endpoints
All endpoints are accessible at the base path `/export/` (not `/api/export/` since schedule URLs are at root level).
### 1. Export Appointments
- **URL**: `GET /export/appointments/`
- **Query Params**: `format`, `start_date`, `end_date`, `status`
- **Formats**: CSV, JSON
- **Data**: Event/appointment information with customer and resource details
### 2. Export Customers
- **URL**: `GET /export/customers/`
- **Query Params**: `format`, `status`
- **Formats**: CSV, JSON
- **Data**: Customer list with contact information
### 3. Export Resources
- **URL**: `GET /export/resources/`
- **Query Params**: `format`, `is_active`
- **Formats**: CSV, JSON
- **Data**: Resource list (staff, rooms, equipment)
### 4. Export Services
- **URL**: `GET /export/services/`
- **Query Params**: `format`, `is_active`
- **Formats**: CSV, JSON
- **Data**: Service catalog with pricing and duration
## Security Features
### Permission Gating
- All endpoints check `tenant.can_export_data` permission
- Returns 403 Forbidden if permission not granted
- Clear error messages guide users to upgrade their subscription
### Authentication
- All endpoints require authentication (IsAuthenticated permission)
- Returns 401 Unauthorized for unauthenticated requests
### Data Isolation
- Leverages django-tenants automatic schema isolation
- Users can only export data from their own tenant
- No risk of cross-tenant data leakage
## Features
### Format Support
- **JSON**: Includes metadata (count, filters, export timestamp)
- **CSV**: Clean, spreadsheet-ready format with proper headers
- Both formats include Content-Disposition header for automatic downloads
### Filtering
- **Date Range**: Filter appointments by start_date and end_date
- **Status**: Filter by active/inactive status for various entities
- **Query Parameters**: Flexible, URL-based filtering
### File Naming
- Timestamped filenames for uniqueness
- Format: `{data_type}_{YYYYMMDD}_{HHMMSS}.{format}`
- Example: `appointments_20241202_103000.csv`
## Testing
Run unit tests with:
```bash
docker compose -f docker-compose.local.yml exec django python manage.py test schedule.test_export
```
## Integration
### Enable Export for a Tenant
```python
# In Django shell or admin
from core.models import Tenant
tenant = Tenant.objects.get(schema_name='your_tenant')
tenant.can_export_data = True
tenant.save()
```
### Example API Calls
```bash
# JSON export
curl -H "Authorization: Bearer YOUR_TOKEN" \
"http://lvh.me:8000/export/appointments/?format=json"
# CSV export with date range
curl -H "Authorization: Bearer YOUR_TOKEN" \
"http://lvh.me:8000/export/appointments/?format=csv&start_date=2024-01-01T00:00:00Z&end_date=2024-12-31T23:59:59Z"
```
## Production Checklist
- [x] Permission gating implemented
- [x] Authentication required
- [x] Unit tests written
- [x] Documentation created
- [x] Database migration applied
- [ ] Rate limiting configured
- [ ] Frontend integration completed
- [ ] Load testing performed
---
**Implementation completed successfully!**

449
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,449 @@
# SmoothSchedule Production Deployment Guide
## Prerequisites
### Server Requirements
- Ubuntu/Debian Linux server
- Minimum 2GB RAM, 20GB disk space
- Docker and Docker Compose installed
- Domain name pointed to server IP: `smoothschedule.com`
- DNS configured with wildcard subdomain: `*.smoothschedule.com`
### Required Accounts/Services
- [x] DigitalOcean Spaces (already configured)
- Access Key: DO801P4R8QXYMY4CE8WZ
- Bucket: smoothschedule
- Region: nyc3
- [ ] Email service (optional - Mailgun or SMTP)
- [ ] Sentry (optional - error tracking)
## Pre-Deployment Checklist
### 1. DigitalOcean Spaces Setup
```bash
# Create the bucket (if not already created)
aws --profile do-tor1 s3 mb s3://smoothschedule
# Set bucket to public-read for static/media files
aws --profile do-tor1 s3api put-bucket-acl \
--bucket smoothschedule \
--acl public-read
# Configure CORS (for frontend uploads)
cat > cors.json <<EOF
{
"CORSRules": [
{
"AllowedOrigins": ["https://smoothschedule.com", "https://*.smoothschedule.com"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}
]
}
EOF
aws --profile do-tor1 s3api put-bucket-cors \
--bucket smoothschedule \
--cors-configuration file://cors.json
```
### 2. DNS Configuration
Configure these DNS records at your domain registrar:
```
Type Name Value TTL
A smoothschedule.com YOUR_SERVER_IP 300
A *.smoothschedule.com YOUR_SERVER_IP 300
CNAME www smoothschedule.com 300
```
### 3. Environment Variables Review
**Backend** (`.envs/.production/.django`):
- [x] DJANGO_SECRET_KEY - Set
- [x] DJANGO_ALLOWED_HOSTS - Set to `.smoothschedule.com`
- [x] DJANGO_AWS_ACCESS_KEY_ID - Set
- [x] DJANGO_AWS_SECRET_ACCESS_KEY - Set
- [x] DJANGO_AWS_STORAGE_BUCKET_NAME - Set to `smoothschedule`
- [x] DJANGO_AWS_S3_ENDPOINT_URL - Set to `https://nyc3.digitaloceanspaces.com`
- [x] DJANGO_AWS_S3_REGION_NAME - Set to `nyc3`
- [ ] MAILGUN_API_KEY - Optional (for email)
- [ ] MAILGUN_DOMAIN - Optional (for email)
- [ ] SENTRY_DSN - Optional (for error tracking)
**Frontend** (`.env.production`):
- [x] VITE_API_URL - Set to `https://smoothschedule.com/api`
## Deployment Steps
### Step 1: Server Preparation
```bash
# SSH into production server
ssh poduck@smoothschedule.com
# Install Docker (if not already installed)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
# Install Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
# Logout and login again for group changes to take effect
exit
ssh poduck@smoothschedule.com
```
### Step 2: Deploy Backend (Django)
```bash
# Create deployment directory
mkdir -p ~/smoothschedule
cd ~/smoothschedule
# Clone the repository (or upload files via rsync/git)
# Option A: Clone from Git
git clone <your-repo-url> .
git checkout main
# Option B: Copy from local machine
# From your local machine:
# rsync -avz --exclude 'node_modules' --exclude '.venv' --exclude '__pycache__' \
# /home/poduck/Desktop/smoothschedule2/ poduck@smoothschedule.com:~/smoothschedule/
# Navigate to backend
cd smoothschedule
# Build and start containers
docker compose -f docker-compose.production.yml build
docker compose -f docker-compose.production.yml up -d
# Wait for containers to start
sleep 10
# Check logs
docker compose -f docker-compose.production.yml logs -f
```
### Step 3: Database Initialization
```bash
# Run migrations
docker compose -f docker-compose.production.yml exec django python manage.py migrate
# Create public schema (for multi-tenancy)
docker compose -f docker-compose.production.yml exec django python manage.py migrate_schemas --shared
# Create superuser
docker compose -f docker-compose.production.yml exec django python manage.py createsuperuser
# Collect static files (uploads to DigitalOcean Spaces)
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
```
### Step 4: Create Initial Tenant
```bash
# Access Django shell
docker compose -f docker-compose.production.yml exec django python manage.py shell
# In the shell, create your first business tenant:
```
```python
from core.models import Business
from django.contrib.auth import get_user_model
User = get_user_model()
# Create a business
business = Business.objects.create(
name="Demo Business",
subdomain="demo",
schema_name="demo",
)
# Verify it was created
print(f"Created business: {business.name} at {business.subdomain}.smoothschedule.com")
# Create a business owner
owner = User.objects.create_user(
username="demo_owner",
email="owner@demo.com",
password="your_password_here",
role="owner",
business_subdomain="demo"
)
print(f"Created owner: {owner.username}")
exit()
```
### Step 5: Deploy Frontend
```bash
# On your local machine
cd /home/poduck/Desktop/smoothschedule2/frontend
# Install dependencies
npm install
# Build for production
npm run build
# Upload build files to server
rsync -avz dist/ poduck@smoothschedule.com:~/smoothschedule-frontend/
# On the server, set up nginx or serve via backend
```
**Option A: Serve via Django (simpler)**
The Django `collectstatic` command already handles static files. For serving the frontend:
1. Copy frontend build to Django static folder
2. Django will serve it via Traefik
**Option B: Separate Nginx (recommended for production)**
```bash
# Install nginx
sudo apt-get update
sudo apt-get install -y nginx
# Create nginx config
sudo nano /etc/nginx/sites-available/smoothschedule
```
```nginx
server {
listen 80;
server_name smoothschedule.com *.smoothschedule.com;
# Frontend (React)
location / {
root /home/poduck/smoothschedule-frontend;
try_files $uri $uri/ /index.html;
}
# Backend API (proxy to Traefik)
location /api {
proxy_pass http://localhost:80;
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;
}
location /admin {
proxy_pass http://localhost:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
```bash
# Enable site
sudo ln -s /etc/nginx/sites-available/smoothschedule /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### Step 6: SSL/HTTPS Setup
Traefik is configured to automatically obtain Let's Encrypt SSL certificates. Ensure:
1. DNS is pointed to your server
2. Ports 80 and 443 are accessible
3. Wait for Traefik to obtain certificates (check logs)
```bash
# Monitor Traefik logs
docker compose -f docker-compose.production.yml logs -f traefik
# You should see:
# "Certificate obtained for domain smoothschedule.com"
```
### Step 7: Verify Deployment
```bash
# Check all containers are running
docker compose -f docker-compose.production.yml ps
# Should show:
# - django (running)
# - postgres (running)
# - redis (running)
# - traefik (running)
# - celeryworker (running)
# - celerybeat (running)
# - flower (running)
# Test API endpoint
curl https://smoothschedule.com/api/
# Test admin
curl https://smoothschedule.com/admin/
# Access in browser:
# https://smoothschedule.com - Main site
# https://platform.smoothschedule.com - Platform dashboard
# https://demo.smoothschedule.com - Demo business
# https://smoothschedule.com:5555 - Flower (Celery monitoring)
```
## Post-Deployment
### 1. Monitoring
```bash
# View logs
docker compose -f docker-compose.production.yml logs -f
# View specific service logs
docker compose -f docker-compose.production.yml logs -f django
docker compose -f docker-compose.production.yml logs -f postgres
# Monitor Celery tasks via Flower
# Access: https://smoothschedule.com:5555
# Login with credentials from .envs/.production/.django
```
### 2. Backups
```bash
# Database backup
docker compose -f docker-compose.production.yml exec postgres backup
# List backups
docker compose -f docker-compose.production.yml exec postgres backups
# Restore from backup
docker compose -f docker-compose.production.yml exec postgres restore backup_filename.sql.gz
```
### 3. Updates
```bash
# Pull latest code
cd ~/smoothschedule/smoothschedule
git pull origin main
# Rebuild and restart
docker compose -f docker-compose.production.yml build
docker compose -f docker-compose.production.yml up -d
# Run migrations
docker compose -f docker-compose.production.yml exec django python manage.py migrate
# Collect static files
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
```
## Troubleshooting
### SSL Certificate Issues
```bash
# Check Traefik logs
docker compose -f docker-compose.production.yml logs traefik
# Verify DNS is pointing to server
dig smoothschedule.com +short
# Ensure ports are open
sudo ufw allow 80
sudo ufw allow 443
```
### Database Connection Issues
```bash
# Check PostgreSQL is running
docker compose -f docker-compose.production.yml ps postgres
# Check database logs
docker compose -f docker-compose.production.yml logs postgres
# Verify connection
docker compose -f docker-compose.production.yml exec django python manage.py dbshell
```
### Static Files Not Loading
```bash
# Verify DigitalOcean Spaces credentials
docker compose -f docker-compose.production.yml exec django python manage.py shell
>>> from django.conf import settings
>>> print(settings.AWS_ACCESS_KEY_ID)
>>> print(settings.AWS_STORAGE_BUCKET_NAME)
# Re-collect static files
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
# Check Spaces bucket
aws --profile do-tor1 s3 ls s3://smoothschedule/static/
aws --profile do-tor1 s3 ls s3://smoothschedule/media/
```
### Celery Not Running Tasks
```bash
# Check Celery worker logs
docker compose -f docker-compose.production.yml logs celeryworker
# Access Flower dashboard
# https://smoothschedule.com:5555
# Restart Celery
docker compose -f docker-compose.production.yml restart celeryworker celerybeat
```
## Security Checklist
- [x] SSL/HTTPS enabled via Let's Encrypt
- [x] DJANGO_SECRET_KEY set to random value
- [x] Database password set to random value
- [x] Flower dashboard password protected
- [ ] Firewall configured (UFW or iptables)
- [ ] SSH key-based authentication enabled
- [ ] Fail2ban installed for brute-force protection
- [ ] Regular backups configured
- [ ] Sentry error monitoring (optional)
## Performance Optimization
1. **Enable CDN for DigitalOcean Spaces**
- In Spaces settings, enable CDN
- Update `DJANGO_AWS_S3_CUSTOM_DOMAIN=smoothschedule.nyc3.cdn.digitaloceanspaces.com`
2. **Scale Gunicorn Workers**
- Adjust `WEB_CONCURRENCY` in `.envs/.production/.django`
- Formula: (2 x CPU cores) + 1
3. **Add Redis Persistence**
- Update docker-compose.production.yml redis config
- Enable AOF persistence
4. **Database Connection Pooling**
- Already configured via `CONN_MAX_AGE=60`
## Maintenance
### Weekly
- Review error logs
- Check disk space: `df -h`
- Monitor Flower dashboard for failed tasks
### Monthly
- Update Docker images: `docker compose pull`
- Update dependencies: `uv sync`
- Review backups
### As Needed
- Scale resources (CPU/RAM)
- Add more Celery workers
- Optimize database queries

286
IMPLEMENTATION_COMPLETE.md Normal file
View File

@@ -0,0 +1,286 @@
# Advanced Analytics Implementation - Complete
## Status: ✅ COMPLETE
All files have been created and configured successfully. The advanced analytics feature is fully implemented with permission-based access control.
## What Was Implemented
### New Analytics App
- **Location:** `/smoothschedule/analytics/`
- **Endpoints:** 3 analytics endpoints with permission gating
- **Permissions:** All endpoints gated by `advanced_analytics` permission
- **Tests:** 10 comprehensive test cases
### 3 Analytics Endpoints
1. **Dashboard** (`GET /api/analytics/analytics/dashboard/`)
- Summary statistics
- Total appointments, resources, services
- Peak times and trends
2. **Appointments** (`GET /api/analytics/analytics/appointments/`)
- Detailed appointment analytics
- Filtering by status, service, resource, date range
- Status breakdown and trend analysis
3. **Revenue** (`GET /api/analytics/analytics/revenue/`)
- Payment analytics
- Requires both `advanced_analytics` AND `can_accept_payments`
- Revenue by service and daily breakdown
## Permission Gating
All endpoints use:
- **IsAuthenticated** - Requires login
- **HasFeaturePermission('advanced_analytics')** - Requires subscription plan permission
Permission chain:
```
Request → IsAuthenticated (401) → HasFeaturePermission (403) → View
```
## Files Created (11 total)
### Core App Files
```
analytics/
├── __init__.py
├── admin.py
├── apps.py
├── migrations/__init__.py
├── views.py (350+ lines, 3 endpoints)
├── serializers.py (80+ lines)
├── urls.py
└── tests.py (260+ lines, 10 test cases)
```
### Documentation
```
analytics/
├── README.md (Full API documentation)
└── IMPLEMENTATION_GUIDE.md (Developer guide)
Project Root:
├── ANALYTICS_CHANGES.md (Change summary)
└── analytics/ANALYTICS_IMPLEMENTATION_SUMMARY.md (Complete overview)
```
## Files Modified (3 total)
### 1. `/smoothschedule/core/permissions.py`
- Added to FEATURE_NAMES dictionary:
- 'advanced_analytics': 'Advanced Analytics'
- 'advanced_reporting': 'Advanced Reporting'
### 2. `/smoothschedule/config/urls.py`
- Added: `path("", include("analytics.urls"))`
### 3. `/smoothschedule/config/settings/base.py`
- Added "analytics" to LOCAL_APPS
## How to Use
### Enable Analytics for a Plan
**Option 1: Django Admin**
```
1. Go to /admin/platform_admin/subscriptionplan/
2. Edit a plan
3. Add to Permissions JSON: "advanced_analytics": true
4. Save
```
**Option 2: Django Shell**
```bash
docker compose -f docker-compose.local.yml exec django python manage.py shell
from platform_admin.models import SubscriptionPlan
plan = SubscriptionPlan.objects.get(name='Professional')
perms = plan.permissions or {}
perms['advanced_analytics'] = True
plan.permissions = perms
plan.save()
```
### Test the Endpoints
```bash
# Get auth token
TOKEN=$(curl -X POST http://lvh.me:8000/auth-token/ \
-H "Content-Type: application/json" \
-d '{"username":"test@example.com","password":"password"}' | jq -r '.token')
# Get dashboard analytics
curl -H "Authorization: Token $TOKEN" \
http://lvh.me:8000/api/analytics/analytics/dashboard/ | jq
# Get appointment analytics
curl -H "Authorization: Token $TOKEN" \
"http://lvh.me:8000/api/analytics/analytics/appointments/?days=7" | jq
```
### Run Tests
```bash
# All tests
docker compose -f docker-compose.local.yml exec django pytest analytics/tests.py -v
# Specific test
docker compose -f docker-compose.local.yml exec django pytest analytics/tests.py::TestAnalyticsPermissions::test_analytics_denied_without_permission -v
```
## Verification Checklist
- [x] Analytics app created with proper structure
- [x] Three endpoints implemented (dashboard, appointments, revenue)
- [x] Permission gating with HasFeaturePermission
- [x] Advanced analytics permission added to FEATURE_NAMES
- [x] URL routing configured
- [x] App registered in INSTALLED_APPS
- [x] Serializers created for response validation
- [x] Comprehensive test suite (10 tests)
- [x] Full API documentation
- [x] Implementation guide for developers
- [x] All files in place and verified
## Key Features
**Permission-Based Access Control**
- Uses standard HasFeaturePermission pattern
- Supports both direct fields and plan JSON
- User-friendly error messages
**Three Functional Endpoints**
- Dashboard: Summary statistics
- Appointments: Detailed analytics with filters
- Revenue: Payment analytics (dual-permission)
**Comprehensive Testing**
- 10 test cases covering all scenarios
- Permission checks verified
- Data calculations validated
**Complete Documentation**
- API documentation with examples
- Implementation guide
- Code comments and docstrings
- Test examples
**No Database Migrations**
- Analytics app has no models
- Uses existing models (Event, Service, Resource)
- Calculated on-demand
## Next Steps
1. **Code Review** - Review the implementation
2. **Testing** - Run test suite: `pytest analytics/tests.py -v`
3. **Enable Plans** - Add permission to subscription plans
4. **Deploy** - Push to production
5. **Monitor** - Watch for usage and issues
## Documentation Files
- **README.md** - Complete API documentation with usage examples
- **IMPLEMENTATION_GUIDE.md** - Developer guide with setup instructions
- **ANALYTICS_CHANGES.md** - Summary of all changes made
- **ANALYTICS_IMPLEMENTATION_SUMMARY.md** - Detailed implementation overview
## Project Structure
```
/home/poduck/Desktop/smoothschedule2/
├── smoothschedule/
│ ├── analytics/ ← NEW APP
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── views.py ← 350+ lines
│ │ ├── serializers.py
│ │ ├── urls.py
│ │ ├── tests.py ← 10 test cases
│ │ ├── migrations/
│ │ ├── README.md ← Full API docs
│ │ └── IMPLEMENTATION_GUIDE.md ← Developer guide
│ ├── core/
│ │ └── permissions.py ← MODIFIED
│ ├── config/
│ │ ├── urls.py ← MODIFIED
│ │ └── settings/base.py ← MODIFIED
│ └── [other apps...]
├── ANALYTICS_CHANGES.md ← Change summary
└── IMPLEMENTATION_COMPLETE.md ← This file
```
## Statistics
| Metric | Value |
|--------|-------|
| New Files Created | 11 |
| Files Modified | 3 |
| New Lines of Code | 900+ |
| API Endpoints | 3 |
| Test Cases | 10 |
| Documentation Pages | 4 |
| Query Parameters Supported | 6 |
## Response Examples
### Dashboard (200 OK)
```json
{
"total_appointments_this_month": 42,
"total_appointments_all_time": 1250,
"active_resources_count": 5,
"active_services_count": 3,
"upcoming_appointments_count": 8,
"average_appointment_duration_minutes": 45.5,
"peak_booking_day": "Friday",
"peak_booking_hour": 14,
"period": {...}
}
```
### Permission Denied (403 Forbidden)
```json
{
"detail": "Your current plan does not include Advanced Analytics. Please upgrade your subscription to access this feature."
}
```
### Unauthorized (401 Unauthorized)
```json
{
"detail": "Authentication credentials were not provided."
}
```
## Implementation Quality
- ✓ Follows DRF best practices
- ✓ Uses existing permission patterns (HasFeaturePermission)
- ✓ Comprehensive error handling
- ✓ Full test coverage
- ✓ Clear documentation
- ✓ Code comments
- ✓ Consistent with project style
## Support
For questions or issues:
1. **API Usage** → See `analytics/README.md`
2. **Setup & Debugging** → See `analytics/IMPLEMENTATION_GUIDE.md`
3. **Permission Logic** → See `core/permissions.py`
4. **Test Examples** → See `analytics/tests.py`
---
**Status: Ready for Production**
All implementation, testing, and documentation are complete.
The advanced analytics feature is fully functional with permission-based access control.
Last Updated: December 2, 2025

383
PLAN_APP_REORGANIZATION.md Normal file
View File

@@ -0,0 +1,383 @@
# Django App Reorganization Plan - Option C (Domain-Based)
## Overview
Reorganize Django apps from their current scattered locations into a clean domain-based structure within `smoothschedule/smoothschedule/`.
**Branch:** `refactor/organize-django-apps`
**Risk Level:** Medium-High (migration history must be preserved)
**Estimated Parallel Agents:** 6-8
---
## Current State Analysis
### Current App Locations (Inconsistent)
| App | Current Location | Registered As |
|-----|-----------------|---------------|
| core | `smoothschedule/core/` | `"core"` |
| schedule | `smoothschedule/schedule/` | `"schedule"` |
| payments | `smoothschedule/payments/` | `"payments"` |
| platform_admin | `smoothschedule/platform_admin/` | `"platform_admin.apps.PlatformAdminConfig"` |
| analytics | `smoothschedule/analytics/` | `"analytics"` |
| notifications | `smoothschedule/notifications/` | `"notifications"` |
| tickets | `smoothschedule/tickets/` | `"tickets"` |
| contracts | `smoothschedule/contracts/` | **NOT REGISTERED** |
| communication | `smoothschedule/communication/` | **NOT REGISTERED** |
| users | `smoothschedule/smoothschedule/users/` | `"smoothschedule.users"` |
| comms_credits | `smoothschedule/smoothschedule/comms_credits/` | `"smoothschedule.comms_credits"` |
| field_mobile | `smoothschedule/smoothschedule/field_mobile/` | `"smoothschedule.field_mobile"` |
| public_api | `smoothschedule/smoothschedule/public_api/` | `"smoothschedule.public_api"` |
### Migration Counts by App
| App | Migrations | Complexity |
|-----|------------|------------|
| core | 22 | High (Tenant model) |
| schedule | 30 | High (main business logic) |
| payments | 1 | Low |
| platform_admin | 12 | Medium |
| users | 10 | Medium |
| tickets | 13 | Medium |
| contracts | 1 | Low |
| notifications | 1 | Low |
| comms_credits | 2 | Low |
| field_mobile | 1 | Low |
| public_api | 3 | Low |
| analytics | 0 | None |
| communication | 1 | Low |
---
## Target Structure (Option C - Domain-Based)
```
smoothschedule/smoothschedule/
├── __init__.py
├── identity/ # User & Tenant Management
│ ├── __init__.py
│ ├── core/ # Multi-tenancy, permissions, OAuth
│ │ └── (moved from smoothschedule/core/)
│ └── users/ # User model, auth, invitations
│ └── (keep at current location, just move parent)
├── scheduling/ # Core Business Logic
│ ├── __init__.py
│ ├── schedule/ # Resources, Events, Services, Plugins
│ │ └── (moved from smoothschedule/schedule/)
│ ├── contracts/ # E-signatures, legal documents
│ │ └── (moved from smoothschedule/contracts/)
│ └── analytics/ # Reporting, dashboards
│ └── (moved from smoothschedule/analytics/)
├── communication/ # Messaging & Notifications
│ ├── __init__.py
│ ├── notifications/ # In-app notifications
│ │ └── (moved from smoothschedule/notifications/)
│ ├── credits/ # SMS/voice credits (renamed from comms_credits)
│ │ └── (moved from smoothschedule/smoothschedule/comms_credits/)
│ ├── mobile/ # Field employee app (renamed from field_mobile)
│ │ └── (moved from smoothschedule/smoothschedule/field_mobile/)
│ └── messaging/ # Twilio conversations (renamed from communication)
│ └── (moved from smoothschedule/communication/)
├── commerce/ # Payments & Support
│ ├── __init__.py
│ ├── payments/ # Stripe Connect, transactions
│ │ └── (moved from smoothschedule/payments/)
│ └── tickets/ # Support tickets, email integration
│ └── (moved from smoothschedule/tickets/)
└── platform/ # Platform Administration
├── __init__.py
├── admin/ # Platform settings, subscriptions (renamed)
│ └── (moved from smoothschedule/platform_admin/)
└── api/ # Public API v1 (renamed from public_api)
└── (moved from smoothschedule/smoothschedule/public_api/)
```
---
## Critical Constraints
### 1. Migration History Preservation
Django migrations contain the app label in their `dependencies` and `app_label` references. We MUST:
- **Keep `app_label` unchanged** in each app's `Meta` class
- Update `AppConfig.name` to the new dotted path
- Django will use the `app_label` (not the path) for migration tracking
### 2. Foreign Key String References
Models use string references like `'users.User'` and `'core.Tenant'`. These reference `app_label`, not the module path, so they remain valid.
### 3. Import Path Updates
All imports across the codebase must be updated:
- `from core.models import Tenant``from smoothschedule.identity.core.models import Tenant`
- `from schedule.models import Event``from smoothschedule.scheduling.schedule.models import Event`
### 4. URL Configuration
`config/urls.py` imports views directly - all import paths must be updated.
### 5. Settings Files
- `config/settings/base.py` - `LOCAL_APPS`
- `config/settings/multitenancy.py` - `SHARED_APPS`, `TENANT_APPS`
---
## Implementation Phases
### Phase 1: Preparation (Serial)
**Agent 1: Setup & Verification**
1. Create all domain package directories with `__init__.py` files
2. Verify Docker is running and database is accessible
3. Run existing tests to establish baseline
4. Create backup of current migration state
```bash
# Create domain packages
mkdir -p smoothschedule/smoothschedule/identity
mkdir -p smoothschedule/smoothschedule/scheduling
mkdir -p smoothschedule/smoothschedule/communication
mkdir -p smoothschedule/smoothschedule/commerce
mkdir -p smoothschedule/smoothschedule/platform
# Create __init__.py files
touch smoothschedule/smoothschedule/identity/__init__.py
touch smoothschedule/smoothschedule/scheduling/__init__.py
touch smoothschedule/smoothschedule/communication/__init__.py
touch smoothschedule/smoothschedule/commerce/__init__.py
touch smoothschedule/smoothschedule/platform/__init__.py
```
---
### Phase 2: Move Apps (Parallel - 5 Agents)
Each agent handles one domain. For each app move:
1. **Move directory** to new location
2. **Update `apps.py`** - change `name` to new dotted path, keep `label` same
3. **Update internal imports** within the app
4. **Add explicit `app_label`** to all model Meta classes (if not present)
#### Agent 2: Identity Domain
Move and update:
- `smoothschedule/core/``smoothschedule/smoothschedule/identity/core/`
- `smoothschedule/smoothschedule/users/``smoothschedule/smoothschedule/identity/users/`
**apps.py changes:**
```python
# identity/core/apps.py
class CoreConfig(AppConfig):
name = "smoothschedule.identity.core" # NEW
label = "core" # KEEP SAME
verbose_name = "Core"
# identity/users/apps.py
class UsersConfig(AppConfig):
name = "smoothschedule.identity.users" # NEW
label = "users" # KEEP SAME
```
#### Agent 3: Scheduling Domain
Move and update:
- `smoothschedule/schedule/``smoothschedule/smoothschedule/scheduling/schedule/`
- `smoothschedule/contracts/``smoothschedule/smoothschedule/scheduling/contracts/`
- `smoothschedule/analytics/``smoothschedule/smoothschedule/scheduling/analytics/`
#### Agent 4: Communication Domain
Move and update:
- `smoothschedule/notifications/``smoothschedule/smoothschedule/communication/notifications/`
- `smoothschedule/smoothschedule/comms_credits/``smoothschedule/smoothschedule/communication/credits/`
- `smoothschedule/smoothschedule/field_mobile/``smoothschedule/smoothschedule/communication/mobile/`
- `smoothschedule/communication/``smoothschedule/smoothschedule/communication/messaging/`
**Note:** Rename apps for clarity:
- `comms_credits` label stays same, path changes
- `field_mobile` label stays same, path changes
- `communication` label stays same, path changes
#### Agent 5: Commerce Domain
Move and update:
- `smoothschedule/payments/``smoothschedule/smoothschedule/commerce/payments/`
- `smoothschedule/tickets/``smoothschedule/smoothschedule/commerce/tickets/`
#### Agent 6: Platform Domain
Move and update:
- `smoothschedule/platform_admin/``smoothschedule/smoothschedule/platform/admin/`
- `smoothschedule/smoothschedule/public_api/``smoothschedule/smoothschedule/platform/api/`
---
### Phase 3: Update Settings (Serial)
**Agent 7: Settings Configuration**
Update `config/settings/base.py`:
```python
LOCAL_APPS = [
# Identity
"smoothschedule.identity.users",
"smoothschedule.identity.core",
# Scheduling
"smoothschedule.scheduling.schedule",
"smoothschedule.scheduling.contracts",
"smoothschedule.scheduling.analytics",
# Communication
"smoothschedule.communication.notifications",
"smoothschedule.communication.credits",
"smoothschedule.communication.mobile",
"smoothschedule.communication.messaging",
# Commerce
"smoothschedule.commerce.payments",
"smoothschedule.commerce.tickets",
# Platform
"smoothschedule.platform.admin",
"smoothschedule.platform.api",
]
```
Update `config/settings/multitenancy.py`:
```python
SHARED_APPS = [
'django_tenants',
'smoothschedule.identity.core',
'smoothschedule.platform.admin',
# ... rest of shared apps with new paths
]
TENANT_APPS = [
'django.contrib.contenttypes',
'smoothschedule.scheduling.schedule',
'smoothschedule.commerce.payments',
'smoothschedule.scheduling.contracts',
]
```
---
### Phase 4: Update All Import Paths (Parallel - Multiple Agents)
**This is the largest task.** Each agent handles specific import patterns:
#### Agent 8: Core Imports
Find and replace across entire codebase:
- `from core.models import``from smoothschedule.identity.core.models import`
- `from core.``from smoothschedule.identity.core.`
- `import core``import smoothschedule.identity.core as core`
#### Agent 9: Schedule Imports
- `from schedule.models import``from smoothschedule.scheduling.schedule.models import`
- `from schedule.``from smoothschedule.scheduling.schedule.`
#### Agent 10: Users/Auth Imports
- `from smoothschedule.users.``from smoothschedule.identity.users.`
- `from users.``from smoothschedule.identity.users.`
#### Agent 11: Other App Imports
Handle remaining apps:
- payments, tickets, notifications, contracts, analytics
- platform_admin, public_api, comms_credits, field_mobile, communication
---
### Phase 5: URL Configuration Updates (Serial)
**Agent 12: URL Updates**
Update `config/urls.py` with new import paths:
```python
# Old
from schedule.views import ResourceViewSet, EventViewSet
from core.api_views import business_current
# New
from smoothschedule.scheduling.schedule.views import ResourceViewSet, EventViewSet
from smoothschedule.identity.core.api_views import business_current
```
---
### Phase 6: Cleanup & Verification (Serial)
**Agent 13: Cleanup**
1. Remove old empty directories at top level
2. Remove deprecated `smoothschedule/smoothschedule/schedule/` directory
3. Update `CLAUDE.md` documentation
4. Update any remaining references
**Agent 14: Verification**
1. Run `docker compose exec django python manage.py check`
2. Run `docker compose exec django python manage.py makemigrations --check`
3. Run `docker compose exec django python manage.py migrate --check`
4. Run test suite
5. Manual smoke test of key endpoints
---
## App Label Mapping Reference
| Old Import Path | New Import Path | app_label (unchanged) |
|----------------|-----------------|----------------------|
| `core` | `smoothschedule.identity.core` | `core` |
| `smoothschedule.users` | `smoothschedule.identity.users` | `users` |
| `schedule` | `smoothschedule.scheduling.schedule` | `schedule` |
| `contracts` | `smoothschedule.scheduling.contracts` | `contracts` |
| `analytics` | `smoothschedule.scheduling.analytics` | `analytics` |
| `notifications` | `smoothschedule.communication.notifications` | `notifications` |
| `smoothschedule.comms_credits` | `smoothschedule.communication.credits` | `comms_credits` |
| `smoothschedule.field_mobile` | `smoothschedule.communication.mobile` | `field_mobile` |
| `communication` | `smoothschedule.communication.messaging` | `communication` |
| `payments` | `smoothschedule.commerce.payments` | `payments` |
| `tickets` | `smoothschedule.commerce.tickets` | `tickets` |
| `platform_admin` | `smoothschedule.platform.admin` | `platform_admin` |
| `smoothschedule.public_api` | `smoothschedule.platform.api` | `public_api` |
---
## Rollback Plan
If issues are encountered:
1. **Git Reset:** `git checkout main` and delete branch
2. **Database:** No migration changes, database remains intact
3. **Docker:** Rebuild containers if needed
---
## Success Criteria
- [ ] All apps moved to domain-based structure
- [ ] `python manage.py check` passes
- [ ] `python manage.py makemigrations --check` shows no changes
- [ ] All existing tests pass
- [ ] Frontend can communicate with API
- [ ] Mobile app can communicate with API
- [ ] CLAUDE.md updated with new structure
---
## Execution Order
```
Phase 1 (Serial): Agent 1 - Setup
Phase 2 (Parallel): Agents 2-6 - Move apps by domain
Phase 3 (Serial): Agent 7 - Update settings
Phase 4 (Parallel): Agents 8-11 - Update imports
Phase 5 (Serial): Agent 12 - URL updates
Phase 6 (Serial): Agents 13-14 - Cleanup & verify
```
**Total Agents:** 14 (8 can run in parallel at peak)

179
PLAN_HELP_DOCS.md Normal file
View File

@@ -0,0 +1,179 @@
# Help Documentation Implementation Plan
## Overview
This plan covers creating comprehensive help documentation for the SmoothSchedule business dashboard, adding contextual help buttons to each page, and creating a monolithic help document.
## Phase 1: Create Plugin Page First (User Request)
### Task 1.1: Create CreatePlugin.tsx Page
- Create `/frontend/src/pages/CreatePlugin.tsx`
- Features:
- Name, description, short description fields
- Category dropdown (EMAIL, REPORTS, CUSTOMER, BOOKING, INTEGRATION, AUTOMATION, OTHER)
- Plugin code editor with syntax highlighting (using same Prism setup as HelpPluginDocs)
- Template variables preview (auto-extracted from code)
- Version field (default 1.0.0)
- Logo URL field (optional)
- Save as Private / Submit to Marketplace options
- Visibility selector (PRIVATE, PUBLIC)
- Uses API endpoint: `POST /api/plugin-templates/`
- Plan feature gate: `can_create_plugins`
### Task 1.2: Add Route for CreatePlugin
- Add lazy import: `const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin'));`
- Add route: `/plugins/create` pointing to CreatePlugin component
## Phase 2: Create Reusable HelpButton Component
### Task 2.1: Create HelpButton Component
- Create `/frontend/src/components/HelpButton.tsx`
- Props: `helpPath: string` (route to help page)
- Renders: HelpCircle icon button at fixed position (top-right of page)
- Styling: Circular button with question mark icon, tooltip on hover
- Uses Link from react-router-dom to navigate to help page
## Phase 3: Create Individual Help Pages
### 3.1 Core Pages Help
| Page | Help File | Route |
|------|-----------|-------|
| Dashboard | HelpDashboard.tsx | /help/dashboard |
| Scheduler | HelpScheduler.tsx | /help/scheduler |
| Tasks | HelpTasks.tsx | /help/tasks |
### 3.2 Manage Section Help
| Page | Help File | Route |
|------|-----------|-------|
| Customers | HelpCustomers.tsx | /help/customers |
| Services | HelpServices.tsx | /help/services |
| Resources | HelpResources.tsx | /help/resources |
| Staff | HelpStaff.tsx | /help/staff |
### 3.3 Communicate Section Help
| Page | Help File | Route |
|------|-----------|-------|
| Messages | HelpMessages.tsx | /help/messages |
| Tickets | HelpTicketing.tsx (exists) | /help/ticketing |
### 3.4 Money Section Help
| Page | Help File | Route |
|------|-----------|-------|
| Payments | HelpPayments.tsx | /help/payments |
### 3.5 Extend Section Help
| Page | Help File | Route |
|------|-----------|-------|
| Plugins | HelpPluginsOverview.tsx | /help/plugins-overview |
| Plugin Marketplace | (link to existing HelpPluginDocs) | /help/plugins |
| My Plugins | HelpMyPlugins.tsx | /help/my-plugins |
| Create Plugin | HelpCreatePlugin.tsx | /help/create-plugin |
### 3.6 Settings Section Help
| Page | Help File | Route |
|------|-----------|-------|
| General | HelpSettingsGeneral.tsx | /help/settings/general |
| Resource Types | HelpSettingsResourceTypes.tsx | /help/settings/resource-types |
| Booking | HelpSettingsBooking.tsx | /help/settings/booking |
| Appearance | HelpSettingsAppearance.tsx | /help/settings/appearance |
| Email Templates | HelpSettingsEmailTemplates.tsx | /help/settings/email-templates |
| Custom Domains | HelpSettingsCustomDomains.tsx | /help/settings/custom-domains |
| API & Webhooks | HelpSettingsApi.tsx | /help/settings/api |
| Authentication | HelpSettingsAuth.tsx | /help/settings/authentication |
| Email Setup | HelpEmailSettings.tsx (exists) | /help/email-settings |
| SMS & Calling | HelpSettingsSmsCalling.tsx | /help/settings/sms-calling |
| Plan & Billing | HelpSettingsBilling.tsx | /help/settings/billing |
| Quota Management | HelpSettingsQuota.tsx | /help/settings/quota |
## Phase 4: Add HelpButton to Each Page
Add the HelpButton component to the top-right of each dashboard page, linking to its corresponding help page.
## Phase 5: Update HelpPluginDocs
### Task 5.1: Review and Update Plugin Documentation
- Verify plugin documentation matches current codebase
- Add section for "Creating Custom Plugins"
- Add links to API documentation
- Ensure examples work with current API
## Phase 6: Create Monolithic Help Document
### Task 6.1: Create HelpGuideComplete.tsx
- Compile all help content into single comprehensive page
- Table of contents with anchor links
- Searchable content
- Organized by sections (Core, Manage, Communicate, Money, Extend, Settings)
### Task 6.2: Update HelpGuide.tsx
- Replace "Coming Soon" with actual compiled documentation
- Or redirect to HelpGuideComplete
## Phase 7: Register All Routes
Add all new help page routes to App.tsx in the business dashboard section.
## Help Page Template Structure
Each help page should follow this structure:
```tsx
- Header with icon and title
- Overview/Introduction
- Key Features section
- How to Use section (step-by-step)
- Benefits section
- Tips & Best Practices
- Related Features (links to other help pages)
- Need More Help? (link to support/tickets)
```
## Implementation Order
1. Create CreatePlugin.tsx page and route
2. Create HelpButton component
3. Create help pages for core pages (Dashboard, Scheduler, Tasks)
4. Create help pages for Manage section
5. Create help pages for Communicate section
6. Create help pages for Money section
7. Create help pages for Extend section (including plugin docs update)
8. Create help pages for Settings section
9. Add HelpButton to all pages
10. Create monolithic help document
11. Test all help pages and navigation
## Files to Create
### New Components
- `/frontend/src/components/HelpButton.tsx`
### New Pages
- `/frontend/src/pages/CreatePlugin.tsx`
- `/frontend/src/pages/help/HelpDashboard.tsx`
- `/frontend/src/pages/help/HelpScheduler.tsx`
- `/frontend/src/pages/help/HelpTasks.tsx`
- `/frontend/src/pages/help/HelpCustomers.tsx`
- `/frontend/src/pages/help/HelpServices.tsx`
- `/frontend/src/pages/help/HelpResources.tsx`
- `/frontend/src/pages/help/HelpStaff.tsx`
- `/frontend/src/pages/help/HelpMessages.tsx`
- `/frontend/src/pages/help/HelpPayments.tsx`
- `/frontend/src/pages/help/HelpPluginsOverview.tsx`
- `/frontend/src/pages/help/HelpMyPlugins.tsx`
- `/frontend/src/pages/help/HelpCreatePlugin.tsx`
- `/frontend/src/pages/help/HelpSettingsGeneral.tsx`
- `/frontend/src/pages/help/HelpSettingsResourceTypes.tsx`
- `/frontend/src/pages/help/HelpSettingsBooking.tsx`
- `/frontend/src/pages/help/HelpSettingsAppearance.tsx`
- `/frontend/src/pages/help/HelpSettingsEmailTemplates.tsx`
- `/frontend/src/pages/help/HelpSettingsCustomDomains.tsx`
- `/frontend/src/pages/help/HelpSettingsApi.tsx`
- `/frontend/src/pages/help/HelpSettingsAuth.tsx`
- `/frontend/src/pages/help/HelpSettingsSmsCalling.tsx`
- `/frontend/src/pages/help/HelpSettingsBilling.tsx`
- `/frontend/src/pages/help/HelpSettingsQuota.tsx`
- `/frontend/src/pages/help/HelpGuideComplete.tsx`
### Files to Modify
- `/frontend/src/App.tsx` - Add routes
- `/frontend/src/pages/HelpPluginDocs.tsx` - Update with current codebase info
- `/frontend/src/pages/HelpGuide.tsx` - Replace Coming Soon
- All dashboard pages - Add HelpButton component

View File

@@ -0,0 +1,653 @@
# Implementation Plan: Multi-Email Ticketing System
## Executive Summary
Add support for multiple email addresses per business in the ticketing system, with color-coded visual indicators and per-email IMAP/SMTP configuration.
## Current System Analysis
### Existing Components
1. **Django Backend (`tickets` app)**
- `Ticket` model: Core ticket entity
- `TicketComment` model: Ticket responses
- `TicketEmailSettings` model: **Singleton** platform-wide email config
- `IncomingTicketEmail` model: Email audit log
- `TicketEmailReceiver` class: IMAP email fetching
- `TicketEmailService` class: SMTP email sending
2. **Frontend**
- `Tickets.tsx`: Main ticket listing page
- `TicketModal.tsx`: Ticket detail modal
- `useTickets` hook: Fetch tickets
- `useTicketEmailSettings` hook: Manage email settings (singleton)
- `Settings.tsx`: Business settings page
3. **Current Email Flow**
- Single email account configured platform-wide
- Emails matched to tickets by ID in subject/address
- Comments created from email replies
- New tickets created from unmatched emails
## Requirements (from user clarification)
1. **Per-Business Email Addresses**
- Each business provides their own email account(s) and credentials
- Multiple email addresses per business
- Each email has independent IMAP/SMTP settings
2. **Email Address Properties**
- Display name (e.g., "Support", "Billing")
- Email address
- IMAP settings (host, port, username, password, SSL)
- SMTP settings (host, port, username, password, TLS/SSL)
- Color for visual identification (hex color code)
- Active/inactive status
3. **Ticket Routing**
- Incoming emails matched to business by email address configuration
- Reply emails matched to existing tickets
- New emails create tickets for that business
- System attempts to match sender email to customer/staff in business
4. **UI Requirements**
- Colored left border on ticket rows indicating source email
- Business settings page to manage email addresses
- Test connection buttons for IMAP/SMTP
- Email address selector when creating tickets manually
## Implementation Plan
### Phase 1: Django Backend Models
#### 1.1 Create `TicketEmailAddress` Model
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/models.py`
```python
class TicketEmailAddress(models.Model):
"""
Per-business email address configuration for ticket management.
Each business can have multiple email addresses with their own settings.
"""
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
related_name='ticket_email_addresses',
help_text="Business this email address belongs to"
)
# Display information
display_name = models.CharField(
max_length=100,
help_text="Display name (e.g., 'Support', 'Billing', 'Sales')"
)
email_address = models.EmailField(
help_text="Email address for sending/receiving tickets"
)
color = models.CharField(
max_length=7,
default='#3b82f6',
help_text="Hex color code for visual identification (e.g., #3b82f6)"
)
# IMAP settings (inbound)
imap_host = models.CharField(max_length=255)
imap_port = models.IntegerField(default=993)
imap_use_ssl = models.BooleanField(default=True)
imap_username = models.CharField(max_length=255)
imap_password = models.CharField(max_length=255) # Encrypted in production
imap_folder = models.CharField(max_length=100, default='INBOX')
# SMTP settings (outbound)
smtp_host = models.CharField(max_length=255)
smtp_port = models.IntegerField(default=587)
smtp_use_tls = models.BooleanField(default=True)
smtp_use_ssl = models.BooleanField(default=False)
smtp_username = models.CharField(max_length=255)
smtp_password = models.CharField(max_length=255) # Encrypted in production
# Status and tracking
is_active = models.BooleanField(
default=True,
help_text="Whether this email address is actively checked"
)
is_default = models.BooleanField(
default=False,
help_text="Default email for new tickets in this business"
)
last_check_at = models.DateTimeField(null=True, blank=True)
last_error = models.TextField(blank=True, default='')
emails_processed_count = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-is_default', 'display_name']
unique_together = [['tenant', 'email_address']]
indexes = [
models.Index(fields=['tenant', 'is_active']),
models.Index(fields=['email_address']),
]
def __str__(self):
return f"{self.display_name} <{self.email_address}> ({self.tenant.name})"
def save(self, *args, **kwargs):
# Ensure only one default per tenant
if self.is_default:
TicketEmailAddress.objects.filter(
tenant=self.tenant,
is_default=True
).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
```
#### 1.2 Update `Ticket` Model
Add field to track which email address received/sent the ticket:
```python
class Ticket(models.Model):
# ... existing fields ...
source_email_address = models.ForeignKey(
'TicketEmailAddress',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='tickets',
help_text="Email address this ticket was received from or sent to"
)
```
#### 1.3 Update `IncomingTicketEmail` Model
Add field to track which email address received the email:
```python
class IncomingTicketEmail(models.Model):
# ... existing fields ...
email_address = models.ForeignKey(
'TicketEmailAddress',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='incoming_emails',
help_text="Email address configuration that received this email"
)
```
### Phase 2: Django Backend Logic
#### 2.1 Update Email Receiver
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/email_receiver.py`
- Modify `TicketEmailReceiver` to iterate through all active `TicketEmailAddress` objects
- Connect to each email address's IMAP server
- Process emails for each address
- Associate processed tickets with the source email address
#### 2.2 Update Email Sender
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/email_notifications.py`
- Modify `TicketEmailService` to use the ticket's `source_email_address` for sending
- Fall back to business's default email address if none specified
### Phase 3: Django Backend API
#### 3.1 Create Serializers
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/serializers.py`
```python
class TicketEmailAddressSerializer(serializers.ModelSerializer):
class Meta:
model = TicketEmailAddress
fields = [
'id', 'tenant', 'display_name', 'email_address', 'color',
'imap_host', 'imap_port', 'imap_use_ssl', 'imap_username',
'imap_password', 'imap_folder',
'smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl',
'smtp_username', 'smtp_password',
'is_active', 'is_default', 'last_check_at', 'last_error',
'emails_processed_count', 'created_at', 'updated_at'
]
read_only_fields = ['tenant', 'last_check_at', 'last_error',
'emails_processed_count', 'created_at', 'updated_at']
extra_kwargs = {
'imap_password': {'write_only': True},
'smtp_password': {'write_only': True},
}
class TicketEmailAddressListSerializer(serializers.ModelSerializer):
"""Lightweight serializer without passwords"""
class Meta:
model = TicketEmailAddress
fields = [
'id', 'display_name', 'email_address', 'color',
'is_active', 'is_default', 'last_check_at',
'emails_processed_count'
]
```
Update `TicketSerializer` to include email address:
```python
class TicketSerializer(serializers.ModelSerializer):
# ... existing fields ...
source_email_address = TicketEmailAddressListSerializer(read_only=True)
```
#### 3.2 Create ViewSet
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/views.py`
```python
class TicketEmailAddressViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing ticket email addresses.
Only business owners and managers can manage email addresses.
"""
serializer_class = TicketEmailAddressSerializer
permission_classes = [IsTenantUser]
def get_queryset(self):
user = self.request.user
# Business users see their own email addresses
if user.role in ['owner', 'manager', 'staff']:
return TicketEmailAddress.objects.filter(
tenant=user.tenant
)
# Platform users see all
elif user.role in ['superuser', 'platform_manager']:
return TicketEmailAddress.objects.all()
return TicketEmailAddress.objects.none()
def get_serializer_class(self):
if self.action == 'list':
return TicketEmailAddressListSerializer
return TicketEmailAddressSerializer
def perform_create(self, serializer):
# Automatically set tenant from current user
serializer.save(tenant=self.request.user.tenant)
@action(detail=True, methods=['post'])
def test_imap(self, request, pk=None):
"""Test IMAP connection for this email address"""
email_address = self.get_object()
# Test IMAP connection logic
return Response({'status': 'success'})
@action(detail=True, methods=['post'])
def test_smtp(self, request, pk=None):
"""Test SMTP connection for this email address"""
email_address = self.get_object()
# Test SMTP connection logic
return Response({'status': 'success'})
@action(detail=True, methods=['post'])
def fetch_now(self, request, pk=None):
"""Manually trigger email fetch for this address"""
email_address = self.get_object()
# Trigger email fetch
return Response({'status': 'fetching'})
```
#### 3.3 Add URL Routes
**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/urls.py`
```python
router.register(r'email-addresses', views.TicketEmailAddressViewSet, basename='ticketemailaddress')
```
### Phase 4: Frontend - React Hooks
#### 4.1 Create API Client Functions
**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/api/ticketEmailAddresses.ts` (new file)
```typescript
export interface TicketEmailAddress {
id: number;
display_name: string;
email_address: string;
color: string;
imap_host: string;
imap_port: number;
imap_use_ssl: boolean;
imap_username: string;
imap_password?: string;
imap_folder: string;
smtp_host: string;
smtp_port: number;
smtp_use_tls: boolean;
smtp_use_ssl: boolean;
smtp_username: string;
smtp_password?: string;
is_active: boolean;
is_default: boolean;
last_check_at?: string;
last_error?: string;
emails_processed_count: number;
created_at: string;
updated_at: string;
}
export type TicketEmailAddressCreate = Omit<TicketEmailAddress, 'id' | 'last_check_at' | 'last_error' | 'emails_processed_count' | 'created_at' | 'updated_at'>;
export const getTicketEmailAddresses = async (): Promise<TicketEmailAddress[]> => {
const response = await apiClient.get('/tickets/email-addresses/');
return response.data;
};
export const createTicketEmailAddress = async (data: TicketEmailAddressCreate): Promise<TicketEmailAddress> => {
const response = await apiClient.post('/tickets/email-addresses/', data);
return response.data;
};
export const updateTicketEmailAddress = async (id: number, data: Partial<TicketEmailAddressCreate>): Promise<TicketEmailAddress> => {
const response = await apiClient.patch(`/tickets/email-addresses/${id}/`, data);
return response.data;
};
export const deleteTicketEmailAddress = async (id: number): Promise<void> => {
await apiClient.delete(`/tickets/email-addresses/${id}/`);
};
export const testImapConnection = async (id: number): Promise<{ status: string; message?: string }> => {
const response = await apiClient.post(`/tickets/email-addresses/${id}/test_imap/`);
return response.data;
};
export const testSmtpConnection = async (id: number): Promise<{ status: string; message?: string }> => {
const response = await apiClient.post(`/tickets/email-addresses/${id}/test_smtp/`);
return response.data;
};
export const fetchEmailsNow = async (id: number): Promise<{ status: string }> => {
const response = await apiClient.post(`/tickets/email-addresses/${id}/fetch_now/`);
return response.data;
};
```
#### 4.2 Create React Query Hooks
**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/hooks/useTicketEmailAddresses.ts` (new file)
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getTicketEmailAddresses,
createTicketEmailAddress,
updateTicketEmailAddress,
deleteTicketEmailAddress,
testImapConnection,
testSmtpConnection,
fetchEmailsNow,
TicketEmailAddress,
TicketEmailAddressCreate,
} from '../api/ticketEmailAddresses';
const QUERY_KEY = 'ticketEmailAddresses';
export const useTicketEmailAddresses = () => {
return useQuery<TicketEmailAddress[]>({
queryKey: [QUERY_KEY],
queryFn: getTicketEmailAddresses,
});
};
export const useCreateTicketEmailAddress = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: TicketEmailAddressCreate) => createTicketEmailAddress(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
};
export const useUpdateTicketEmailAddress = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<TicketEmailAddressCreate> }) =>
updateTicketEmailAddress(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
};
export const useDeleteTicketEmailAddress = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteTicketEmailAddress(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
};
export const useTestImapConnection = () => {
return useMutation({
mutationFn: (id: number) => testImapConnection(id),
});
};
export const useTestSmtpConnection = () => {
return useMutation({
mutationFn: (id: number) => testSmtpConnection(id),
});
};
export const useFetchEmailsNow = () => {
return useMutation({
mutationFn: (id: number) => fetchEmailsNow(id),
});
};
```
### Phase 5: Frontend - React Components
#### 5.1 Email Address Management Component
**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/components/TicketEmailAddressManager.tsx` (new file)
Features:
- List all email addresses for the business
- Add new email address
- Edit existing email address
- Delete email address
- Test IMAP/SMTP connections
- Set default email address
- Color picker for visual identification
- Enable/disable email addresses
#### 5.2 Update Ticket List UI
**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/pages/Tickets.tsx`
Modify ticket rows to include colored left border:
```tsx
<div
className="ticket-row"
style={{
borderLeft: ticket.source_email_address
? `4px solid ${ticket.source_email_address.color}`
: '4px solid transparent'
}}
>
{/* Ticket content */}
</div>
```
#### 5.3 Update Types
**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/types.ts`
```typescript
export interface TicketEmailAddress {
id: number;
display_name: string;
email_address: string;
color: string;
is_active: boolean;
is_default: boolean;
last_check_at?: string;
emails_processed_count: number;
}
export interface Ticket {
// ... existing fields ...
source_email_address?: TicketEmailAddress;
}
```
#### 5.4 Add to Business Settings
**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/pages/Settings.tsx`
Add new tab for "Email Addresses" that renders `TicketEmailAddressManager` component.
### Phase 6: Database Migration
#### 6.1 Create Migration
```bash
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
docker compose -f docker-compose.local.yml exec django python manage.py makemigrations tickets
```
#### 6.2 Run Migration
```bash
docker compose -f docker-compose.local.yml exec django python manage.py migrate tickets
```
#### 6.3 Data Migration (if needed)
If there's existing `TicketEmailSettings` data, create a data migration to convert it to `TicketEmailAddress` records for each tenant.
### Phase 7: Testing
#### 7.1 Backend Tests
- Test email address CRUD operations
- Test email receiver with multiple addresses
- Test email sender using correct source address
- Test tenant isolation
#### 7.2 Frontend Tests
- Test email address list rendering
- Test add/edit/delete operations
- Test connection testing UI
- Test ticket list color borders
### Phase 8: Documentation
#### 8.1 User Documentation
- How to add email addresses
- How to configure IMAP/SMTP settings
- How to test connections
- Color coding explanation
#### 8.2 Developer Documentation
- API endpoints documentation
- Model relationships
- Email processing flow
- Celery task schedule (if applicable)
## Migration Strategy
### Option 1: Keep Legacy System (Recommended)
- Keep `TicketEmailSettings` for platform-level configuration
- New `TicketEmailAddress` for per-business configuration
- Businesses can opt-in to multi-email system
- Existing single-email businesses continue working
### Option 2: Full Migration
- Deprecate `TicketEmailSettings`
- Migrate all existing data to `TicketEmailAddress`
- All businesses use new system
**Recommendation:** Option 1 for backward compatibility
## Risks & Considerations
1. **Security**
- Email passwords stored in database (consider encryption)
- SMTP/IMAP credentials exposure risk
- Recommend OAuth2 for Gmail/Outlook in future
2. **Performance**
- Multiple IMAP connections may increase load
- Consider Celery task queue for email fetching
- Implement rate limiting
3. **Email Deliverability**
- Each business responsible for their own SPF/DKIM records
- No centralized email reputation management
4. **UI/UX**
- Color picker needs to be user-friendly
- Color accessibility (contrast ratio)
- Mobile responsiveness
## Future Enhancements
1. **OAuth2 Support**
- Google Workspace integration
- Microsoft 365 integration
2. **Email Templates Per Address**
- Different signatures per email address
- Custom auto-responses
3. **Analytics**
- Email volume by address
- Response time by address
4. **Auto-Assignment**
- Route tickets to specific staff based on email address
## Implementation Timeline
- **Phase 1-2 (Backend Models & Logic):** 2-3 days
- **Phase 3 (Backend API):** 1-2 days
- **Phase 4-5 (Frontend):** 3-4 days
- **Phase 6-7 (Migration & Testing):** 1-2 days
- **Phase 8 (Documentation):** 1 day
**Total Estimated Time:** 8-12 days
## Approval Required
Before proceeding with implementation, please confirm:
1. ✅ Per-business email addresses (not platform-wide)
2. ✅ Businesses provide their own IMAP/SMTP credentials
3. ✅ Colored left border for visual identification
4. ✅ Email address management in business settings (not platform dashboard)
5. ⚠️ Security approach for storing email passwords
6. ⚠️ Migration strategy (keep legacy vs full migration)
## Questions for Product Owner
1. Should we encrypt email passwords in the database?
2. Do we need email address approval workflow (platform admin approval)?
3. Should there be a limit on number of email addresses per business?
4. Do we need email forwarding (forward to another address)?
5. Should unmatched emails (not tied to a ticket) create new tickets or be ignored?

296
PRODUCTION-READY.md Normal file
View File

@@ -0,0 +1,296 @@
# SmoothSchedule Production Readiness Report
## Status: READY FOR DEPLOYMENT ✓
This document confirms that SmoothSchedule is fully configured and ready for production deployment.
## Configuration Complete ✓
### 1. DigitalOcean Spaces Configuration ✓
- **Access Key ID:** DO801P4R8QXYMY4CE8WZ
- **Secret Access Key:** Configured
- **Bucket Name:** smoothschedule
- **Region:** nyc3
- **Endpoint:** https://nyc3.digitaloceanspaces.com
**Status:** Environment variables configured in `smoothschedule/.envs/.production/.django`
### 2. Backend (Django) ✓
- **Framework:** Django 5.2.8
- **Storage:** django-storages with S3 backend (DigitalOcean Spaces)
- **Database:** PostgreSQL with multi-tenancy support
- **Task Queue:** Celery with Redis
- **Web Server:** Gunicorn behind Traefik
- **SSL/HTTPS:** Let's Encrypt automatic certificates via Traefik
**Production Settings:**
- ✓ SECRET_KEY configured
- ✓ ALLOWED_HOSTS set to `.smoothschedule.com`
- ✓ DEBUG=False (production mode)
- ✓ Static files → DigitalOcean Spaces
- ✓ Media files → DigitalOcean Spaces
- ✓ Security headers configured
- ✓ HTTPS redirect enabled
### 3. Frontend (React) ✓
- **Framework:** React 18 with Vite
- **Build:** Production build ready
- **API Endpoint:** https://smoothschedule.com/api
- **Multi-tenant:** Subdomain-based routing
**Production Settings:**
- ✓ API URL configured for production
- ✓ Build optimization enabled
### 4. Docker Configuration ✓
**Services:**
- ✓ Django (Gunicorn)
- ✓ PostgreSQL
- ✓ Redis
- ✓ Traefik (reverse proxy + SSL)
- ✓ Celery Worker
- ✓ Celery Beat (scheduler)
- ✓ Flower (Celery monitoring)
**Production Compose:** `docker-compose.production.yml`
### 5. SSL/HTTPS ✓
- **Provider:** Let's Encrypt
- **Auto-renewal:** Enabled via Traefik
- **Domains:**
- smoothschedule.com
- www.smoothschedule.com
- platform.smoothschedule.com
- api.smoothschedule.com
- *.smoothschedule.com (wildcard for tenants)
### 6. Security ✓
- ✓ HTTPS enforced
- ✓ Secure cookies
- ✓ CSRF protection
- ✓ Random secret keys
- ✓ Database password protected
- ✓ Flower dashboard password protected
## Deployment Scripts Created ✓
### 1. `server-setup.sh`
**Purpose:** Initial server setup (run once)
**Installs:**
- Docker & Docker Compose
- AWS CLI (for Spaces management)
- UFW firewall
- Fail2ban
**Usage:**
```bash
ssh poduck@smoothschedule.com 'bash -s' < server-setup.sh
```
### 2. `setup-spaces.sh`
**Purpose:** Create and configure DigitalOcean Spaces bucket
**Actions:**
- Creates bucket
- Sets public-read ACL
- Configures CORS
**Usage:**
```bash
ssh poduck@smoothschedule.com
./setup-spaces.sh
```
### 3. `deploy.sh`
**Purpose:** Full deployment pipeline
**Actions:**
- Builds frontend
- Uploads code to server
- Builds Docker images
- Runs migrations
- Collects static files
- Starts services
**Usage:**
```bash
./deploy.sh poduck@smoothschedule.com
```
## Documentation Created ✓
### 1. DEPLOYMENT.md
Comprehensive deployment guide covering:
- Prerequisites
- Step-by-step deployment
- DNS configuration
- SSL setup
- Troubleshooting
- Maintenance
### 2. CLAUDE.md (Updated)
Added production deployment section with:
- Quick deploy commands
- Production URLs
- Management commands
- Environment variables
## What You Need to Do Before Deploying
### Prerequisites Checklist
#### 1. Server Access
- [ ] Ensure you can SSH to: `poduck@smoothschedule.com`
- [ ] Verify sudo password: `chaff/starry`
#### 2. DNS Configuration
Configure these DNS records at your domain registrar:
```
Type Name Value TTL
A smoothschedule.com YOUR_SERVER_IP 300
A *.smoothschedule.com YOUR_SERVER_IP 300
CNAME www smoothschedule.com 300
```
**To find YOUR_SERVER_IP:**
```bash
ping smoothschedule.com
# or
ssh poduck@smoothschedule.com 'curl -4 ifconfig.me'
```
#### 3. Server Firewall Ports
Ensure these ports are open on your server:
- [ ] Port 22 (SSH)
- [ ] Port 80 (HTTP)
- [ ] Port 443 (HTTPS)
- [ ] Port 5555 (Flower - optional)
#### 4. DigitalOcean Spaces
- [ ] Create bucket (run `setup-spaces.sh` on server)
- [ ] Verify credentials are correct
## Deployment Steps (Quick Start)
### Step 1: Initial Server Setup (One-Time)
```bash
# From your local machine
cd /home/poduck/Desktop/smoothschedule2
# Run server setup
ssh poduck@smoothschedule.com 'bash -s' < server-setup.sh
# Note: You'll need to logout/login after this for Docker group changes
```
### Step 2: Setup DigitalOcean Spaces (One-Time)
```bash
# Copy setup script to server
scp setup-spaces.sh poduck@smoothschedule.com:~/
# SSH to server and run it
ssh poduck@smoothschedule.com
./setup-spaces.sh
exit
```
### Step 3: Deploy Application
```bash
# From your local machine
cd /home/poduck/Desktop/smoothschedule2
./deploy.sh poduck@smoothschedule.com
```
### Step 4: Post-Deployment Setup
```bash
# SSH to server
ssh poduck@smoothschedule.com
cd ~/smoothschedule
# Create superuser
docker compose -f docker-compose.production.yml exec django python manage.py createsuperuser
# Create a business tenant
docker compose -f docker-compose.production.yml exec django python manage.py shell
```
Then in the Django shell:
```python
from core.models import Business
from django.contrib.auth import get_user_model
User = get_user_model()
# Create a business
business = Business.objects.create(
name="Demo Business",
subdomain="demo",
schema_name="demo",
)
# Create business owner
owner = User.objects.create_user(
username="demo_owner",
email="owner@demo.com",
password="choose_a_password",
role="owner",
business_subdomain="demo"
)
exit()
```
### Step 5: Verify Deployment
Visit these URLs in your browser:
- https://smoothschedule.com - Main site
- https://platform.smoothschedule.com - Platform dashboard
- https://demo.smoothschedule.com - Demo business
- https://smoothschedule.com:5555 - Flower (Celery monitoring)
## Monitoring & Maintenance
### View Logs
```bash
ssh poduck@smoothschedule.com
cd ~/smoothschedule
docker compose -f docker-compose.production.yml logs -f
```
### Check Status
```bash
docker compose -f docker-compose.production.yml ps
```
### Restart Services
```bash
docker compose -f docker-compose.production.yml restart
```
### Update/Redeploy
Simply run the deploy script again:
```bash
./deploy.sh poduck@smoothschedule.com
```
## Support & Troubleshooting
See [DEPLOYMENT.md](DEPLOYMENT.md) for:
- Detailed troubleshooting steps
- SSL certificate issues
- Database connection problems
- Static files not loading
- Celery task issues
## Summary
**Production Configuration:** Complete
**DigitalOcean Spaces:** Configured
**Docker Setup:** Ready
**SSL/HTTPS:** Automatic via Traefik
**Deployment Scripts:** Created
**Documentation:** Complete
**Next Action:** Run the deployment steps above to go live!
---
**Questions?** See DEPLOYMENT.md or check the logs on the server.

602
PRODUCTION_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,602 @@
# SmoothSchedule - Production Deployment Manual
Complete step-by-step guide for manually deploying SmoothSchedule from scratch on a production server. This guide is useful when you need to reset the entire production deployment or troubleshoot deployment issues.
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Complete Fresh Deployment](#complete-fresh-deployment)
3. [Docker Build & Startup](#docker-build--startup)
4. [Database Initialization](#database-initialization)
5. [Static Files & Migrations](#static-files--migrations)
6. [Verification Steps](#verification-steps)
7. [Troubleshooting](#troubleshooting)
---
## Prerequisites
### Required on Production Server
```bash
# Check these are installed and running
docker --version # Docker 20.10+
docker compose --version # Docker Compose 2.0+
```
### Required Locally (Before Deployment)
1. All code changes committed to git (`main` branch)
2. All missing files added to git and pushed
3. Production environment variables backed up locally
4. `.envs/.production/` directory exists with proper credentials
---
## Complete Fresh Deployment
### Step 1: Save Production Environment Variables Locally
**On your local development machine:**
```bash
# Backup current production environment variables
scp poduck@smoothschedule.com:~/smoothschedule/smoothschedule/.envs/.production/.django \
/tmp/production_django_env_backup
scp poduck@smoothschedule.com:~/smoothschedule/smoothschedule/.envs/.production/.postgres \
/tmp/production_postgres_env_backup
```
### Step 2: Prepare & Commit All Code Changes Locally
**On your local development machine:**
```bash
cd /home/poduck/Desktop/smoothschedule2
# Check for any missing files
git status
# Add all changes and new files
git add -A
# Review staged changes
git diff --cached
# Commit changes
git commit -m "Production deployment: Add missing files and config updates"
# Push to main branch
git push origin main
```
### Step 3: Bring Down Production Containers
**SSH into production server:**
```bash
ssh poduck@smoothschedule.com
cd ~/smoothschedule/smoothschedule
# Stop all containers (preserves volumes)
docker compose -f docker-compose.production.yml down
# Or to completely reset and remove volumes (careful!):
docker compose -f docker-compose.production.yml down -v
```
### Step 4: Remove Production Codebase
**On production server:**
```bash
cd ~
# Remove everything
rm -rf smoothschedule
```
### Step 5: Pull Fresh Code from Git
**On production server:**
```bash
cd ~
# Clone the repository
git clone https://git.talova.net/poduck/smoothschedule.git smoothschedule
cd smoothschedule
```
**Note:** If git authentication fails, configure credentials on the server:
```bash
git config --global user.email "poduck@smoothschedule.com"
git config --global user.name "Poduck"
# Or store credentials
git credential approve
# Then paste: protocol=https
# host=git.talova.net
# username=poduck
# password=chaff/starry
```
### Step 6: Restore Production Environment Variables
**On production server:**
```bash
cd ~/smoothschedule/smoothschedule
# Create the .envs/.production directory if it doesn't exist
mkdir -p .envs/.production
# Restore from backups on local machine or recreate them
# Option A: Use SCP to copy from local machine
scp /tmp/production_django_env_backup poduck@smoothschedule.com:~/smoothschedule/smoothschedule/.envs/.production/.django
scp /tmp/production_postgres_env_backup poduck@smoothschedule.com:~/smoothschedule/smoothschedule/.envs/.production/.postgres
# Option B: Manually recreate the files on the production server
# (see Environment Variables section below)
```
---
## Docker Build & Startup
### Step 7: Build Docker Images
**On production server:**
```bash
cd ~/smoothschedule/smoothschedule
# Build all production Docker images
docker compose -f docker-compose.production.yml build
```
**Expected output:**
```
✓ Successfully built:
- smoothschedule_production_django
- smoothschedule_production_celeryworker
- smoothschedule_production_celerybeat
- smoothschedule_production_flower
- smoothschedule_production_postgres
- smoothschedule_production_traefik
- smoothschedule-awscli
```
### Step 8: Start Containers
**On production server:**
```bash
cd ~/smoothschedule/smoothschedule
# Start all containers in detached mode
docker compose -f docker-compose.production.yml up -d
# Wait for services to stabilize (10-15 seconds)
sleep 15
# Check container status
docker compose -f docker-compose.production.yml ps
```
**Expected status:** All containers should be `Up` or `Healthy`
---
## Database Initialization
### Step 9: Run Database Migrations
**On production server:**
The application uses django-tenants for multi-tenant support. Migrations must be run in this order:
```bash
cd ~/smoothschedule/smoothschedule
# 1. Migrate the public/shared schema (all shared apps)
docker compose -f docker-compose.production.yml exec -T django \
python manage.py migrate_schemas --shared
# Expected output should show multiple migrations applied to "public" schema
```
**If you get "service django is not running":**
The Django container may not be fully started. Wait a bit longer:
```bash
sleep 30 # Wait for Django to initialize
docker compose -f docker-compose.production.yml exec -T django \
python manage.py migrate_schemas --shared
```
---
## Static Files & Migrations
### Step 10: Collect Static Files
**On production server:**
```bash
cd ~/smoothschedule/smoothschedule
docker compose -f docker-compose.production.yml exec -T django \
python manage.py collectstatic --noinput
```
This collects all static files (CSS, JS, images) and uploads them to:
- **Local filesystem:** `smoothschedule/staticfiles/`
- **DigitalOcean Spaces/S3:** Configured via `DJANGO_AWS_*` environment variables
### Step 11: Create Superuser (First Time Only)
**On production server, if this is a fresh installation:**
```bash
cd ~/smoothschedule/smoothschedule
docker compose -f docker-compose.production.yml exec django \
python manage.py createsuperuser
# Follow the prompts to create the admin user
```
### Step 12: Create Initial Tenant (First Time Only)
**On production server, if you need a demo tenant:**
```bash
cd ~/smoothschedule/smoothschedule
docker compose -f docker-compose.production.yml exec django python manage.py shell
```
Then in the Django shell:
```python
from core.models import Tenant, Domain
from django.utils.text import slugify
# Create a tenant
tenant = Tenant.objects.create(
name="Demo Company",
slug="demo",
is_free_trial=False,
is_temporary=False,
)
# Create a domain for the tenant
Domain.objects.create(
domain="demo.smoothschedule.com",
tenant=tenant,
)
print(f"Created tenant: {tenant.name} with domain: demo.smoothschedule.com")
exit()
```
---
## Verification Steps
### Step 13: Verify All Services Are Running
```bash
cd ~/smoothschedule/smoothschedule
# Check container status
docker compose -f docker-compose.production.yml ps
# View logs for any errors
docker compose -f docker-compose.production.yml logs --tail=50
# Check specific service logs
docker compose -f docker-compose.production.yml logs django --tail=30
docker compose -f docker-compose.production.yml logs postgres --tail=30
```
### Step 14: Test API Endpoints
**From your local machine:**
```bash
# Test the backend API
curl -s "https://smoothschedule.com/api/health/" | jq
# Test tenant access
curl -s "https://demo.smoothschedule.com/api/resources/" | jq
# Test platform admin
curl -s "https://platform.smoothschedule.com/api/admin/businesses/" | jq
```
### Step 15: Verify Nginx Frontend
The frontend should be accessible at:
- **Main site:** `https://smoothschedule.com`
- **Platform dashboard:** `https://platform.smoothschedule.com`
- **Tenant subdomain:** `https://demo.smoothschedule.com`
---
## Environment Variables Reference
### Django Configuration (`.envs/.production/.django`)
```bash
# Security & Secrets
DJANGO_SECRET_KEY=your-secret-key-here
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=smoothschedule.com,platform.smoothschedule.com,*.smoothschedule.com
# Admin & URLs
DJANGO_ADMIN_URL=<random-slug>/
FRONTEND_URL=https://platform.smoothschedule.com
PLATFORM_BASE_URL=https://platform.smoothschedule.com
# Celery & Redis
REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/0
# AWS/DigitalOcean Spaces (for media storage)
DJANGO_AWS_ACCESS_KEY_ID=your-access-key
DJANGO_AWS_SECRET_ACCESS_KEY=your-secret-key
DJANGO_AWS_STORAGE_BUCKET_NAME=smoothschedule
DJANGO_AWS_S3_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com
DJANGO_AWS_S3_REGION_NAME=nyc3
DJANGO_AWS_S3_CUSTOM_DOMAIN=smoothschedule.nyc3.digitaloceanspaces.com
# Email Configuration (optional)
MAILGUN_API_KEY=your-mailgun-key
MAILGUN_DOMAIN=mg.smoothschedule.com
# SSL/Security
DJANGO_SECURE_SSL_REDIRECT=True
DJANGO_SECURE_HSTS_SECONDS=60
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=True
DJANGO_SECURE_HSTS_PRELOAD=True
DJANGO_SESSION_COOKIE_SECURE=True
DJANGO_CSRF_COOKIE_SECURE=True
# Sentry (optional - error tracking)
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
SENTRY_TRACES_SAMPLE_RATE=0.1
```
### PostgreSQL Configuration (`.envs/.production/.postgres`)
```bash
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=smoothschedule
POSTGRES_USER=<random-username>
POSTGRES_PASSWORD=<random-secure-password>
```
---
## Troubleshooting
### Django Container Won't Start
**Symptom:** `docker compose ps` shows Django as exited
**Solution:**
```bash
cd ~/smoothschedule/smoothschedule
# View full error logs
docker compose -f docker-compose.production.yml logs django --tail=100
# Check if database is accessible
docker compose -f docker-compose.production.yml logs postgres --tail=20
# Restart Django after fixing the issue
docker compose -f docker-compose.production.yml restart django
```
### Migration Fails with "role does not exist"
**Symptom:** Error when running `migrate_schemas --shared`:
```
FATAL: role "postgres" does not exist
```
**Solution:** The database volume is corrupted. Reset and restart:
```bash
cd ~/smoothschedule/smoothschedule
# Stop and remove all volumes
docker compose -f docker-compose.production.yml down -v
# Restart (this will recreate the database)
docker compose -f docker-compose.production.yml up -d
# Wait for database to initialize (30 seconds)
sleep 30
# Try migrations again
docker compose -f docker-compose.production.yml exec -T django \
python manage.py migrate_schemas --shared
```
### Cannot Connect to Production Server
**Check SSH access:**
```bash
ssh poduck@smoothschedule.com "echo 'Connection successful'"
```
**If that fails:**
- Verify your SSH key is authorized on the server
- Check firewall rules allow SSH (port 22)
- Verify DNS resolves `smoothschedule.com` to the correct IP
### Traefik Certificate Issues
**Symptom:** SSL certificate errors, HTTP redirects failing
**Solution:**
```bash
cd ~/smoothschedule/smoothschedule
# Check Traefik logs
docker compose -f docker-compose.production.yml logs traefik --tail=50
# Restart Traefik to request new certificates
docker compose -f docker-compose.production.yml restart traefik
# Wait for Let's Encrypt to issue certificates (5-10 minutes)
# Check via: https://smoothschedule.com (should show valid cert)
```
### Frontend Not Loading
**Verify nginx container is running:**
```bash
# Check if nginx image was built
docker images | grep smoothschedule
# If missing, rebuild
docker compose -f docker-compose.production.yml build nginx
# Restart the service
docker compose -f docker-compose.production.yml restart
# Check logs
docker logs $(docker ps -q --filter "name=nginx")
```
---
## Quick Reference Commands
```bash
# SSH to production server
ssh poduck@smoothschedule.com
# Navigate to project
cd ~/smoothschedule/smoothschedule
# View all container logs
docker compose -f docker-compose.production.yml logs -f
# View Django logs only
docker compose -f docker-compose.production.yml logs django -f --tail=100
# Stop all containers
docker compose -f docker-compose.production.yml down
# Start all containers
docker compose -f docker-compose.production.yml up -d
# Restart a specific service
docker compose -f docker-compose.production.yml restart django
# Run a Django management command
docker compose -f docker-compose.production.yml exec -T django python manage.py <command>
# Access Django shell
docker compose -f docker-compose.production.yml exec django python manage.py shell
# Create a database backup
docker compose -f docker-compose.production.yml exec -T postgres \
pg_dump -U $POSTGRES_USER smoothschedule > backup.sql
# Execute arbitrary SQL
docker compose -f docker-compose.production.yml exec -T postgres \
psql -U $POSTGRES_USER smoothschedule -c "SELECT version();"
# Check Docker resource usage
docker stats
```
---
## Advanced: Rollback Procedure
If a deployment fails catastrophically:
```bash
# 1. Stop everything
cd ~/smoothschedule/smoothschedule
docker compose -f docker-compose.production.yml down
# 2. Restore from database backup (if available)
docker compose -f docker-compose.production.yml exec -T postgres \
psql -U $POSTGRES_USER smoothschedule < backup.sql
# 3. Checkout previous git commit
cd ~/smoothschedule
git checkout <previous-commit-hash>
# 4. Rebuild and restart
cd smoothschedule
docker compose -f docker-compose.production.yml build
docker compose -f docker-compose.production.yml up -d
```
---
## Monitoring Checklist
After deployment, monitor these for 24 hours:
- [ ] No error logs in `docker compose logs django`
- [ ] API endpoints respond with 200 status
- [ ] SSL certificates are valid (https://smoothschedule.com)
- [ ] Frontend loads without console errors
- [ ] Tenant subdomains work correctly
- [ ] Static files are being served (CSS, JS load)
- [ ] Background tasks execute (check Celery worker logs)
- [ ] No database connection errors
---
## Important Notes
1. **Always backup before major changes:**
- Database: `pg_dump`
- Environment variables: Copy `.envs/.production/` locally
- Code: Git commits
2. **Never modify code on production directly:**
- Change code locally → Git push → Production git pull
- Only edit `.env` files directly on production
3. **Multi-Tenancy Considerations:**
- Each tenant gets a separate PostgreSQL schema
- Migrations apply to "public" schema (shared) first
- Tenant migrations happen automatically on first request to new tenant
4. **Docker Compose File:**
- Always use `-f docker-compose.production.yml` for production
- Never use `-f docker-compose.local.yml` on production
5. **Persistence:**
- Database: `production_postgres_data` volume
- Redis: `production_redis_data` volume
- Traefik certs: `production_traefik` volume
- These are preserved when using `down` (not `down -v`)
---
**Last Updated:** December 2025
**Deployment Version:** Manual v1.0

175
QUICK-REFERENCE.md Normal file
View File

@@ -0,0 +1,175 @@
# SmoothSchedule Quick Reference
## Deployment Commands
### Deploy to Production
```bash
cd /home/poduck/Desktop/smoothschedule2
./deploy.sh poduck@smoothschedule.com
```
### SSH to Server
```bash
ssh poduck@smoothschedule.com
# Password: chaff/starry
```
## Production Management
### Navigate to Project
```bash
cd ~/smoothschedule
```
### View Logs
```bash
# All services
docker compose -f docker-compose.production.yml logs -f
# Specific service
docker compose -f docker-compose.production.yml logs -f django
docker compose -f docker-compose.production.yml logs -f celeryworker
docker compose -f docker-compose.production.yml logs -f traefik
```
### Check Status
```bash
docker compose -f docker-compose.production.yml ps
```
### Restart Services
```bash
# All services
docker compose -f docker-compose.production.yml restart
# Specific service
docker compose -f docker-compose.production.yml restart django
```
### Run Django Commands
```bash
# Migrations
docker compose -f docker-compose.production.yml exec django python manage.py migrate
# Create superuser
docker compose -f docker-compose.production.yml exec django python manage.py createsuperuser
# Django shell
docker compose -f docker-compose.production.yml exec django python manage.py shell
# Collect static files
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
```
## URLs
- **Main Site:** https://smoothschedule.com
- **Platform Dashboard:** https://platform.smoothschedule.com
- **API:** https://smoothschedule.com/api
- **Admin:** https://smoothschedule.com/admin
- **Flower (Celery):** https://smoothschedule.com:5555
## DigitalOcean Spaces
### View Bucket Contents
```bash
aws --profile do-tor1 s3 ls s3://smoothschedule/
aws --profile do-tor1 s3 ls s3://smoothschedule/static/
aws --profile do-tor1 s3 ls s3://smoothschedule/media/
```
### Upload File
```bash
aws --profile do-tor1 s3 cp file.jpg s3://smoothschedule/media/
```
### Public URLs
- **Static:** https://smoothschedule.nyc3.digitaloceanspaces.com/static/
- **Media:** https://smoothschedule.nyc3.digitaloceanspaces.com/media/
## Troubleshooting
### 500 Error
```bash
# Check Django logs
docker compose -f docker-compose.production.yml logs django --tail=100
```
### SSL Not Working
```bash
# Check Traefik logs
docker compose -f docker-compose.production.yml logs traefik
# Verify DNS
dig smoothschedule.com +short
```
### Database Issues
```bash
# Check PostgreSQL
docker compose -f docker-compose.production.yml logs postgres
# Access database
docker compose -f docker-compose.production.yml exec django python manage.py dbshell
```
### Static Files Not Loading
```bash
# Re-collect static files
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
# Check Spaces
aws --profile do-tor1 s3 ls s3://smoothschedule/static/ | head
```
## Backups
### Create Database Backup
```bash
docker compose -f docker-compose.production.yml exec postgres backup
```
### List Backups
```bash
docker compose -f docker-compose.production.yml exec postgres backups
```
### Restore Backup
```bash
docker compose -f docker-compose.production.yml exec postgres restore <backup_file>
```
## Emergency Commands
### Stop All Services
```bash
docker compose -f docker-compose.production.yml down
```
### Start All Services
```bash
docker compose -f docker-compose.production.yml up -d
```
### Rebuild Everything
```bash
docker compose -f docker-compose.production.yml down
docker compose -f docker-compose.production.yml build --no-cache
docker compose -f docker-compose.production.yml up -d
```
### View Resource Usage
```bash
docker stats
```
## Environment Files
- **Backend:** `~/smoothschedule/.envs/.production/.django`
- **Database:** `~/smoothschedule/.envs/.production/.postgres`
## Support
- **Detailed Guide:** See DEPLOYMENT.md
- **Production Status:** See PRODUCTION-READY.md
- **Main Docs:** See CLAUDE.md

View File

@@ -0,0 +1,195 @@
# Calendar Sync Permission - Quick Reference
## What Was Added
A permission gating system for calendar sync features in the Django backend.
## Key Components
### 1. Database Field
```python
# core/models.py - Added to Tenant model
can_use_calendar_sync = models.BooleanField(default=False)
```
### 2. Permission Check Factory
```python
# core/permissions.py - Added to FEATURE_NAMES
'can_use_calendar_sync': 'Calendar Sync',
```
### 3. OAuth Integration
```python
# core/oauth_views.py - Check when purpose is 'calendar'
if purpose == 'calendar':
calendar_permission = HasFeaturePermission('can_use_calendar_sync')
if not calendar_permission().has_permission(request, self):
return Response({'error': 'Feature not available'}, status=403)
```
### 4. Calendar Sync Views
```python
# schedule/calendar_sync_views.py
CalendarListView # GET /api/calendar/list/
CalendarSyncView # POST /api/calendar/sync/
CalendarDeleteView # DELETE /api/calendar/disconnect/
CalendarStatusView # GET /api/calendar/status/
```
## How to Use
### Enable for a Tenant
```bash
# Via Django shell
from core.models import Tenant
tenant = Tenant.objects.get(schema_name='demo')
tenant.can_use_calendar_sync = True
tenant.save()
```
### Use in ViewSet
```python
from rest_framework import viewsets
from core.permissions import HasFeaturePermission
class MyViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, HasFeaturePermission('can_use_calendar_sync')]
```
### Use in APIView
```python
from rest_framework.views import APIView
class MyView(APIView):
permission_classes = [CalendarSyncPermission]
# CalendarSyncPermission = IsAuthenticated + has_feature check
```
## API Endpoints
| Method | Endpoint | Description | Permission |
|--------|----------|-------------|-----------|
| GET | /api/calendar/status/ | Check if calendar sync is available | Auth only |
| GET | /api/calendar/list/ | List connected calendars | Calendar sync |
| POST | /api/calendar/sync/ | Start calendar sync | Calendar sync |
| DELETE | /api/calendar/disconnect/ | Disconnect a calendar | Calendar sync |
| POST | /api/oauth/google/initiate/ | Start Google OAuth for calendar | Calendar sync (if purpose=calendar) |
| POST | /api/oauth/microsoft/initiate/ | Start MS OAuth for calendar | Calendar sync (if purpose=calendar) |
## Testing
### Run tests
```bash
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
docker compose -f docker-compose.local.yml exec django pytest schedule/tests/test_calendar_sync_permissions.py -v
```
### Test endpoints manually
```bash
# Check status (always works)
curl http://lvh.me:8000/api/calendar/status/ -H "Authorization: Bearer <token>"
# List calendars (requires permission)
curl http://lvh.me:8000/api/calendar/list/ -H "Authorization: Bearer <token>"
# Returns 403 if permission not granted
```
## Files Modified
| File | Changes |
|------|---------|
| core/models.py | Added can_use_calendar_sync field |
| core/permissions.py | Added to FEATURE_NAMES |
| core/oauth_views.py | Added permission check for calendar |
## Files Created
| File | Purpose |
|------|---------|
| core/migrations/0016_tenant_can_use_calendar_sync.py | Database migration |
| schedule/calendar_sync_views.py | Calendar sync API views |
| schedule/calendar_sync_urls.py | URL routing |
| schedule/tests/test_calendar_sync_permissions.py | Test suite |
| CALENDAR_SYNC_INTEGRATION.md | Developer guide |
## Permission Check Pattern
```
Request to calendar endpoint
Check: Is user authenticated?
├─ NO → 401 Unauthorized
└─ YES ↓
Check: Does tenant have can_use_calendar_sync=True?
├─ NO → 403 Forbidden (upgrade message)
└─ YES ↓
Process request
├─ Success → 200 OK
└─ Error → 500 Server Error
```
## Example: Full Permission Setup
```python
# 1. Enable feature for tenant
from core.models import Tenant
tenant = Tenant.objects.get(schema_name='demo')
tenant.can_use_calendar_sync = True
tenant.save()
# 2. User tries to access calendar endpoint
# GET /api/calendar/list/
# → Check: tenant.has_feature('can_use_calendar_sync')
# → True! → 200 OK with calendar list
# 3. Without permission
tenant.can_use_calendar_sync = False
tenant.save()
# GET /api/calendar/list/
# → Check: tenant.has_feature('can_use_calendar_sync')
# → False! → 403 Forbidden with upgrade message
```
## Related Documentation
- **Full Guide:** `CALENDAR_SYNC_INTEGRATION.md` in smoothschedule/ folder
- **Implementation Details:** `CALENDAR_SYNC_PERMISSION_IMPLEMENTATION.md` in project root
- **Code:** `schedule/calendar_sync_views.py` (well-commented)
- **Tests:** `schedule/tests/test_calendar_sync_permissions.py`
## Common Tasks
### Check if feature is enabled
```python
tenant.has_feature('can_use_calendar_sync') # Returns bool
```
### Get list of connected calendars
```python
from core.models import OAuthCredential
credentials = OAuthCredential.objects.filter(
tenant=tenant,
purpose='calendar',
is_valid=True
)
```
### Handle permission denied
```python
from core.permissions import HasFeaturePermission
permission = HasFeaturePermission('can_use_calendar_sync')
if not permission().has_permission(request, view):
# User doesn't have permission
# Show upgrade prompt
```
## Notes
- Feature defaults to **False** for all tenants (opt-in)
- Works alongside existing subscription plan system
- Follows same pattern as SMS reminders, webhooks, etc.
- Multi-tenant isolation built-in
- OAuth tokens are encrypted at rest
- All operations logged for audit trail

660
README.md
View File

@@ -1,294 +1,470 @@
# Smooth Schedule - Multi-Tenant SaaS Platform
# SmoothSchedule - Multi-Tenant Scheduling Platform
A production-grade Django skeleton with **strict data isolation** and **high-trust security** for resource orchestration.
A production-ready multi-tenant SaaS platform for resource scheduling, appointments, and business management.
## 🎯 Features
## Features
- **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
- **8-Tier Role Hierarchy**: From SUPERUSER to CUSTOMER with strict permissions
- **Secure Masquerading**: django-hijack with custom permission matrix
- **Full Audit Trail**: Structured logging of all masquerade activity
- **Headless API**: Django Rest Framework (no server-side HTML)
- **Docker Ready**: Complete Docker Compose setup via cookiecutter-django
- **AWS Integration**: S3 storage + Route53 DNS for custom domains
- **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
- **8-Tier Role Hierarchy**: SUPERUSER, PLATFORM_MANAGER, PLATFORM_SALES, PLATFORM_SUPPORT, TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF, CUSTOMER
- **Modern Stack**: Django 5.2 + React 19 + TypeScript + Vite
- **Real-time Updates**: Django Channels + WebSockets
- **Background Tasks**: Celery + Redis
- **Auto SSL**: Let's Encrypt certificates via Traefik
- **Cloud Storage**: DigitalOcean Spaces (S3-compatible)
- **Docker Ready**: Complete Docker Compose setup for dev and production
## 📋 Prerequisites
## Project Structure
- Python 3.9+
- PostgreSQL 14+
- Docker & Docker Compose
- Cookiecutter (`pip install cookiecutter`)
```
smoothschedule2/
├── frontend/ # React + Vite + TypeScript
│ ├── src/
│ │ ├── api/ # API client and hooks
│ │ ├── components/ # Reusable UI components
│ │ ├── hooks/ # React Query hooks
│ │ ├── pages/ # Page components
│ │ └── types.ts # TypeScript interfaces
│ ├── nginx.conf # Production nginx config
│ └── Dockerfile.prod # Production frontend container
├── smoothschedule/ # Django backend
│ ├── config/ # Django settings
│ │ └── settings/
│ │ ├── base.py # Base settings
│ │ ├── local.py # Local development
│ │ └── production.py # Production settings
│ ├── smoothschedule/ # Django apps (domain-based)
│ │ ├── identity/ # Users, tenants, authentication
│ │ │ ├── core/ # Tenant, Domain, middleware
│ │ │ └── users/ # User model, MFA, auth
│ │ ├── scheduling/ # Core scheduling
│ │ │ ├── schedule/ # Resources, Events, Services
│ │ │ ├── contracts/ # E-signatures
│ │ │ └── analytics/ # Business analytics
│ │ ├── communication/ # Notifications, SMS, mobile
│ │ ├── commerce/ # Payments, tickets
│ │ └── platform/ # Admin, public API
│ ├── docker-compose.local.yml
│ └── docker-compose.production.yml
├── deploy.sh # Automated deployment script
└── CLAUDE.md # Development guide
```
## 🚀 Quick Start
---
### 1. Run Setup Script
## Local Development Setup
### Prerequisites
- **Docker** and **Docker Compose** (for backend)
- **Node.js 22+** and **npm** (for frontend)
- **Git**
### Step 1: Clone the Repository
```bash
chmod +x setup_project.sh
./setup_project.sh
git clone https://github.com/your-repo/smoothschedule.git
cd smoothschedule
```
### 2. Configure Environment
Create `.env` file:
```env
# Database
POSTGRES_DB=smoothschedule_db
POSTGRES_USER=smoothschedule_user
POSTGRES_PASSWORD=your_secure_password
# Django
DJANGO_SECRET_KEY=your_secret_key_here
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
# AWS
AWS_ACCESS_KEY_ID=your_aws_key
AWS_SECRET_ACCESS_KEY=your_aws_secret
AWS_STORAGE_BUCKET_NAME=smoothschedule-media
AWS_ROUTE53_HOSTED_ZONE_ID=your_zone_id
```
### 3. Start Services
### Step 2: Start the Backend (Django in Docker)
```bash
docker-compose build
docker-compose up -d
cd smoothschedule
# Start all backend services
docker compose -f docker-compose.local.yml up -d
# Wait for services to initialize (first time takes longer)
sleep 30
# Run database migrations
docker compose -f docker-compose.local.yml exec django python manage.py migrate
# Create a superuser (optional)
docker compose -f docker-compose.local.yml exec django python manage.py createsuperuser
```
### 4. Run Migrations
### Step 3: Start the Frontend (React with Vite)
```bash
# Shared schema
docker-compose run --rm django python manage.py migrate_schemas --shared
cd ../frontend
# Create superuser
docker-compose run --rm django python manage.py createsuperuser
# Install dependencies
npm install
# Start development server
npm run dev
```
### 5. Create First Tenant
### Step 4: Access the Application
The application uses `lvh.me` (resolves to 127.0.0.1) for subdomain-based multi-tenancy:
| URL | Purpose |
|-----|---------|
| http://platform.lvh.me:5173 | Platform admin dashboard |
| http://demo.lvh.me:5173 | Demo tenant (if created) |
| http://lvh.me:8000/api/ | Backend API |
| http://lvh.me:8000/admin/ | Django admin |
**Why `lvh.me`?** Browsers don't allow cookies with `domain=.localhost`, but `lvh.me` resolves to 127.0.0.1 and allows proper cookie sharing across subdomains.
### Local Development Commands
```bash
# Backend commands (always use docker compose)
cd smoothschedule
# View logs
docker compose -f docker-compose.local.yml logs -f django
# Run migrations
docker compose -f docker-compose.local.yml exec django python manage.py migrate
# Django shell
docker compose -f docker-compose.local.yml exec django python manage.py shell
# Run tests
docker compose -f docker-compose.local.yml exec django pytest
# Stop all services
docker compose -f docker-compose.local.yml down
# Frontend commands
cd frontend
# Run tests
npm test
# Type checking
npm run typecheck
# Lint
npm run lint
```
### Creating a Test Tenant
```bash
cd smoothschedule
docker compose -f docker-compose.local.yml exec django python manage.py shell
```
```python
docker-compose run --rm django python manage.py shell
from core.models import Tenant, Domain
from smoothschedule.identity.core.models import Tenant, Domain
# Create tenant
tenant = Tenant.objects.create(
name="Demo Company",
name="Demo Business",
schema_name="demo",
subscription_tier="PROFESSIONAL",
)
# Create domain
Domain.objects.create(
domain="demo.smoothschedule.local",
domain="demo.lvh.me",
tenant=tenant,
is_primary=True,
)
print(f"Created tenant: {tenant.name}")
print(f"Access at: http://demo.lvh.me:5173")
```
---
## Production Deployment
### Prerequisites
- Ubuntu/Debian server with Docker and Docker Compose
- Domain name with DNS configured:
- `A` record: `yourdomain.com` → Server IP
- `A` record: `*.yourdomain.com` → Server IP (wildcard)
- SSH access to the server
### Quick Deploy (Existing Server)
For routine updates to an existing production server:
```bash
# Run tenant migrations
docker-compose run --rm django python manage.py migrate_schemas
# From your local machine
./deploy.sh user@yourdomain.com
# Or deploy specific services
./deploy.sh user@yourdomain.com nginx
./deploy.sh user@yourdomain.com django
```
## 🏗️ Architecture
### Fresh Server Deployment
#### Step 1: Server Setup
SSH into your server and install Docker:
```bash
ssh user@yourdomain.com
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
# Logout and login for group changes
exit
ssh user@yourdomain.com
```
#### Step 2: Clone Repository
```bash
cd ~
git clone https://github.com/your-repo/smoothschedule.git smoothschedule
cd smoothschedule
```
#### Step 3: Configure Environment Variables
Create production environment files:
```bash
mkdir -p smoothschedule/.envs/.production
# Django configuration
cat > smoothschedule/.envs/.production/.django << 'EOF'
DJANGO_SECRET_KEY=your-random-secret-key-here
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=yourdomain.com,*.yourdomain.com
DJANGO_ADMIN_URL=your-secret-admin-path/
FRONTEND_URL=https://platform.yourdomain.com
PLATFORM_BASE_URL=https://platform.yourdomain.com
REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/0
# DigitalOcean Spaces (or S3)
DJANGO_AWS_ACCESS_KEY_ID=your-access-key
DJANGO_AWS_SECRET_ACCESS_KEY=your-secret-key
DJANGO_AWS_STORAGE_BUCKET_NAME=your-bucket
DJANGO_AWS_S3_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com
DJANGO_AWS_S3_REGION_NAME=nyc3
# SSL
DJANGO_SECURE_SSL_REDIRECT=True
DJANGO_SESSION_COOKIE_SECURE=True
DJANGO_CSRF_COOKIE_SECURE=True
# Cloudflare (for wildcard SSL)
CF_DNS_API_TOKEN=your-cloudflare-api-token
EOF
# PostgreSQL configuration
cat > smoothschedule/.envs/.production/.postgres << 'EOF'
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=smoothschedule
POSTGRES_USER=smoothschedule_user
POSTGRES_PASSWORD=your-secure-database-password
EOF
```
#### Step 4: Build and Start
```bash
cd ~/smoothschedule/smoothschedule
# Build all images
docker compose -f docker-compose.production.yml build
# Start services
docker compose -f docker-compose.production.yml up -d
# Wait for startup
sleep 30
# Run migrations
docker compose -f docker-compose.production.yml exec django python manage.py migrate
# Collect static files
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
# Create superuser
docker compose -f docker-compose.production.yml exec django python manage.py createsuperuser
```
#### Step 5: Verify Deployment
```bash
# Check all containers are running
docker compose -f docker-compose.production.yml ps
# View logs
docker compose -f docker-compose.production.yml logs -f
# Test endpoints
curl https://yourdomain.com/api/health/
```
### Production URLs
| URL | Purpose |
|-----|---------|
| https://yourdomain.com | Marketing site |
| https://platform.yourdomain.com | Platform admin |
| https://*.yourdomain.com | Tenant subdomains |
| https://api.yourdomain.com | API (if configured) |
| https://yourdomain.com:5555 | Flower (Celery monitoring) |
### Production Management Commands
```bash
ssh user@yourdomain.com
cd ~/smoothschedule/smoothschedule
# View logs
docker compose -f docker-compose.production.yml logs -f django
# Restart services
docker compose -f docker-compose.production.yml restart
# Run migrations
docker compose -f docker-compose.production.yml exec django python manage.py migrate
# Django shell
docker compose -f docker-compose.production.yml exec django python manage.py shell
# Database backup
docker compose -f docker-compose.production.yml exec postgres pg_dump -U smoothschedule_user smoothschedule > backup.sql
```
---
## Architecture
### Multi-Tenancy Model
```
┌─────────────────────────────────────────┐
│ PostgreSQL Database │
├─────────────────────────────────────────┤
public (shared schema) │
├─ Tenants │
├─ Domains │
│ ├─ Users │
└─ PermissionGrants │
├─────────────────────────────────────────┤
tenant_demo (schema for Demo Company) │
├─ Appointments │
│ ├─ Resources │
└─ Customers │
├─────────────────────────────────────────┤
│ tenant_acme (schema for Acme Corp) │
│ ├─ Appointments │
│ ├─ Resources │
│ └─ Customers │
└─────────────────────────────────────────┘
PostgreSQL Database
├── public (shared schema)
│ ├── Tenants
├── Domains
├── Users
└── PermissionGrants
├── demo (tenant schema)
├── Resources
│ ├── Events
├── Services
└── Customers
└── acme (tenant schema)
├── Resources
├── Events
└── ...
```
### Role Hierarchy
| Role | Level | Access Scope |
|---------------------|----------|---------------------------|
| SUPERUSER | Platform | All tenants (god mode) |
| PLATFORM_MANAGER | Platform | All tenants |
| PLATFORM_SALES | Platform | Demo accounts only |
| PLATFORM_SUPPORT | Platform | Tenant users |
| TENANT_OWNER | Tenant | Own tenant (full access) |
| TENANT_MANAGER | Tenant | Own tenant |
| TENANT_STAFF | Tenant | Own tenant (limited) |
| CUSTOMER | Tenant | Own data only |
| Role | Level | Access |
|------|-------|--------|
| SUPERUSER | Platform | All tenants (god mode) |
| PLATFORM_MANAGER | Platform | All tenants |
| PLATFORM_SALES | Platform | Demo accounts only |
| PLATFORM_SUPPORT | Platform | Can masquerade as tenant users |
| TENANT_OWNER | Tenant | Full tenant access |
| TENANT_MANAGER | Tenant | Most tenant features |
| TENANT_STAFF | Tenant | Limited tenant access |
| CUSTOMER | Tenant | Own data only |
### Masquerading Matrix
| Hijacker Role | Can Masquerade As |
|--------------------|----------------------------------|
| SUPERUSER | Anyone |
| PLATFORM_SUPPORT | Tenant users |
| PLATFORM_SALES | Demo accounts (`is_temporary=True`) |
| TENANT_OWNER | Staff in same tenant |
| Others | No one |
**Security Rules:**
- Cannot hijack yourself
- Cannot hijack SUPERUSERs (except by other SUPERUSERs)
- Maximum depth: 1 (no hijack chains)
- All attempts logged to `logs/masquerade.log`
## 📁 Project Structure
### Request Flow
```
smoothschedule/
├── config/
└── settings.py # Multi-tenancy & security config
├── core/
├── models.py # Tenant, Domain, PermissionGrant
├── permissions.py # Hijack permission matrix
│ ├── middleware.py # Masquerade audit logging
│ └── admin.py # Django admin for core models
├── users/
│ ├── models.py # Custom User with 8-tier roles
│ └── admin.py # User admin with hijack button
├── logs/
│ ├── security.log # General security events
│ └── masquerade.log # Hijack activity (JSON)
└── setup_project.sh # Automated setup script
Browser → Traefik (SSL) → nginx (frontend) or django (API)
React SPA
/api/* → django:5000
/ws/* → django:5000 (WebSocket)
```
## 🔐 Security Features
### Audit Logging
All masquerade activity is logged in JSON format:
```json
{
"timestamp": "2024-01-15T10:30:00Z",
"action": "HIJACK_START",
"hijacker_email": "support@smoothschedule.com",
"hijacked_email": "customer@demo.com",
"ip_address": "192.168.1.1",
"session_key": "abc123..."
}
```
### Permission Grants (30-Minute Window)
Time-limited elevated permissions:
```python
from core.models import PermissionGrant
grant = PermissionGrant.create_grant(
grantor=admin_user,
grantee=support_user,
action="view_billing",
reason="Customer requested billing support",
duration_minutes=30,
)
# Check if active
if grant.is_active():
# Perform privileged action
pass
```
## 🧪 Testing Masquerading
1. Access Django Admin: `http://localhost:8000/admin/`
2. Create test users with different roles
3. Click "Hijack" button next to a user
4. Verify audit logs: `docker-compose exec django cat logs/masquerade.log`
## 📊 Admin Interface
- **Tenant Management**: View tenants, domains, subscription tiers
- **User Management**: Color-coded roles, masquerade buttons
- **Permission Grants**: Active/expired/revoked status, bulk revoke
- **Domain Verification**: AWS Route53 integration status
## 🛠️ Development
### Adding Tenant Apps
Edit `config/settings.py`:
```python
TENANT_APPS = [
'django.contrib.contenttypes',
'appointments', # Your app
'resources', # Your app
'billing', # Your app
]
```
### Custom Domain Setup
```python
domain = Domain.objects.create(
domain="app.customdomain.com",
tenant=tenant,
is_custom_domain=True,
route53_zone_id="Z1234567890ABC",
)
```
Then configure Route53 CNAME: `app.customdomain.com``smoothschedule.yourhost.com`
## 📖 Key Files Reference
| File | Purpose |
|------|---------|
| `setup_project.sh` | Automated project initialization |
| `config/settings.py` | Multi-tenancy, middleware, security config |
| `core/models.py` | Tenant, Domain, PermissionGrant models |
| `core/permissions.py` | Masquerading permission matrix |
| `core/middleware.py` | Audit logging for masquerading |
| `users/models.py` | Custom User with 8-tier roles |
## 📝 Important Notes
- **Django Admin**: The ONLY HTML interface (everything else is API)
- **Middleware Order**: `TenantMainMiddleware` must be first, `MasqueradeAuditMiddleware` after `HijackUserMiddleware`
- **Tenant Isolation**: Each tenant's data is in a separate PostgreSQL schema
- **Production**: Update `SECRET_KEY`, database credentials, and AWS keys via environment variables
## 🐛 Troubleshooting
**Cannot create tenant users:**
- Error: "Users with role TENANT_STAFF must be assigned to a tenant"
- Solution: Set `user.tenant = tenant_instance` before saving
**Hijack button doesn't appear:**
- Check `HIJACK_AUTHORIZATION_CHECK` in settings
- Verify `HijackUserAdminMixin` in `users/admin.py`
- Ensure user has permission per matrix rules
**Migrations fail:**
- Run shared migrations first: `migrate_schemas --shared`
- Then run tenant migrations: `migrate_schemas`
## 📄 License
MIT
## 🤝 Contributing
This is a production skeleton. Extend `TENANT_APPS` with your business logic.
---
**Built with ❤️ for multi-tenant SaaS perfection**
## Configuration Files
### Backend
| File | Purpose |
|------|---------|
| `smoothschedule/docker-compose.local.yml` | Local Docker services |
| `smoothschedule/docker-compose.production.yml` | Production Docker services |
| `smoothschedule/.envs/.local/` | Local environment variables |
| `smoothschedule/.envs/.production/` | Production environment variables |
| `smoothschedule/config/settings/` | Django settings |
| `smoothschedule/compose/production/traefik/traefik.yml` | Traefik routing config |
### Frontend
| File | Purpose |
|------|---------|
| `frontend/.env.development` | Local environment variables |
| `frontend/.env.production` | Production environment variables |
| `frontend/nginx.conf` | Production nginx config |
| `frontend/vite.config.ts` | Vite bundler config |
---
## Troubleshooting
### Backend won't start
```bash
# Check Docker logs
docker compose -f docker-compose.local.yml logs django
# Common issues:
# - Database not ready: wait longer, then restart django
# - Missing migrations: run migrate command
# - Port conflict: check if 8000 is in use
```
### Frontend can't connect to API
```bash
# Verify backend is running
curl http://lvh.me:8000/api/
# Check CORS settings in Django
# Ensure CORS_ALLOWED_ORIGINS includes http://platform.lvh.me:5173
```
### WebSockets disconnecting
```bash
# Check nginx has /ws/ proxy configured
# Verify django is running ASGI (Daphne)
# Check production traefik/nginx logs
```
### Multi-tenant issues
```bash
# Check tenant exists
docker compose exec django python manage.py shell
>>> from smoothschedule.identity.core.models import Tenant, Domain
>>> Tenant.objects.all()
>>> Domain.objects.all()
```
---
## Additional Documentation
- **[CLAUDE.md](CLAUDE.md)** - Development guide, coding standards, architecture details
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
- **[PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md)** - Step-by-step manual deployment
---
## License
MIT

218
deploy.sh Executable file
View File

@@ -0,0 +1,218 @@
#!/bin/bash
# SmoothSchedule Production Deployment Script
# Usage: ./deploy.sh [server_user@server_host] [services...]
# Example: ./deploy.sh poduck@smoothschedule.com # Build all
# Example: ./deploy.sh poduck@smoothschedule.com traefik # Build only traefik
# Example: ./deploy.sh poduck@smoothschedule.com django nginx # Build django and nginx
#
# Available services: django, traefik, nginx, postgres, celeryworker, celerybeat, flower, awscli
# Use --no-migrate to skip migrations (useful for config-only changes like traefik)
#
# This script deploys from git repository, not local files.
# Changes must be committed and pushed before deploying.
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Parse arguments
SERVER=""
SERVICES=""
SKIP_MIGRATE=false
for arg in "$@"; do
if [[ "$arg" == "--no-migrate" ]]; then
SKIP_MIGRATE=true
elif [[ -z "$SERVER" ]]; then
SERVER="$arg"
else
SERVICES="$SERVICES $arg"
fi
done
SERVER=${SERVER:-"poduck@smoothschedule.com"}
SERVICES=$(echo "$SERVICES" | xargs) # Trim whitespace
REPO_URL="https://git.talova.net/poduck/smoothschedule.git"
REMOTE_DIR="/home/poduck/smoothschedule"
echo -e "${GREEN}==================================="
echo "SmoothSchedule Deployment"
echo "===================================${NC}"
echo "Target server: $SERVER"
if [[ -n "$SERVICES" ]]; then
echo "Services to rebuild: $SERVICES"
else
echo "Services to rebuild: ALL"
fi
if [[ "$SKIP_MIGRATE" == "true" ]]; then
echo "Migrations: SKIPPED"
fi
echo ""
# Function to print status
print_status() {
echo -e "${GREEN}>>> $1${NC}"
}
print_warning() {
echo -e "${YELLOW}>>> $1${NC}"
}
print_error() {
echo -e "${RED}>>> $1${NC}"
}
# Step 1: Check for uncommitted changes
print_status "Step 1: Checking for uncommitted changes..."
if [[ -n $(git status --porcelain) ]]; then
print_error "You have uncommitted changes. Please commit and push before deploying."
git status --short
exit 1
fi
# Check if local is ahead of remote
LOCAL_COMMIT=$(git rev-parse HEAD)
REMOTE_COMMIT=$(git rev-parse @{u} 2>/dev/null || echo "")
if [[ -z "$REMOTE_COMMIT" ]]; then
print_error "No upstream branch configured. Please push your changes first."
exit 1
fi
if [[ "$LOCAL_COMMIT" != "$REMOTE_COMMIT" ]]; then
print_warning "Local branch differs from remote. Checking if ahead..."
AHEAD=$(git rev-list --count @{u}..HEAD)
if [[ "$AHEAD" -gt 0 ]]; then
print_error "You have $AHEAD unpushed commit(s). Please push before deploying."
exit 1
fi
fi
print_status "All changes committed and pushed!"
# Step 2: Deploy on server
print_status "Step 2: Deploying on server..."
ssh "$SERVER" "bash -s" << ENDSSH
set -e
echo ">>> Setting up project directory..."
# Backup .envs if they exist (secrets not in git)
if [ -d "$REMOTE_DIR/smoothschedule/.envs" ]; then
echo ">>> Backing up .envs secrets..."
cp -r "$REMOTE_DIR/smoothschedule/.envs" /tmp/.envs-backup
elif [ -d "$REMOTE_DIR/.envs" ]; then
# Old structure - .envs was at root level
echo ">>> Backing up .envs secrets (old location)..."
cp -r "$REMOTE_DIR/.envs" /tmp/.envs-backup
fi
# Backup .ssh if it exists (SSH keys not in git)
if [ -d "$REMOTE_DIR/smoothschedule/.ssh" ]; then
echo ">>> Backing up .ssh keys..."
cp -r "$REMOTE_DIR/smoothschedule/.ssh" /tmp/.ssh-backup
elif [ -d "$REMOTE_DIR/.ssh" ]; then
# Old structure
echo ">>> Backing up .ssh keys (old location)..."
cp -r "$REMOTE_DIR/.ssh" /tmp/.ssh-backup
fi
if [ ! -d "$REMOTE_DIR/.git" ]; then
echo ">>> Cloning repository for the first time..."
# Remove old non-git deployment if exists
if [ -d "$REMOTE_DIR" ]; then
rm -rf "$REMOTE_DIR"
fi
git clone "$REPO_URL" "$REMOTE_DIR"
else
echo ">>> Repository exists, pulling latest changes..."
cd "$REMOTE_DIR"
git fetch origin
git reset --hard origin/main
fi
cd "$REMOTE_DIR"
# Restore .envs secrets
if [ -d /tmp/.envs-backup ] && [ "$(ls -A /tmp/.envs-backup 2>/dev/null)" ]; then
echo ">>> Restoring .envs secrets..."
mkdir -p "$REMOTE_DIR/smoothschedule/.envs"
cp -r /tmp/.envs-backup/* "$REMOTE_DIR/smoothschedule/.envs/"
rm -rf /tmp/.envs-backup
fi
# Restore .ssh keys
if [ -d /tmp/.ssh-backup ] && [ "$(ls -A /tmp/.ssh-backup 2>/dev/null)" ]; then
echo ">>> Restoring .ssh keys..."
mkdir -p "$REMOTE_DIR/smoothschedule/.ssh"
cp -r /tmp/.ssh-backup/* "$REMOTE_DIR/smoothschedule/.ssh/"
rm -rf /tmp/.ssh-backup
fi
echo ">>> Current commit:"
git log -1 --oneline
cd smoothschedule
# Build images (all or specific services)
if [[ -n "$SERVICES" ]]; then
echo ">>> Building Docker images: $SERVICES..."
docker compose -f docker-compose.production.yml build $SERVICES
else
echo ">>> Building all Docker images..."
docker compose -f docker-compose.production.yml build
fi
echo ">>> Starting containers..."
docker compose -f docker-compose.production.yml up -d
echo ">>> Waiting for containers to start..."
sleep 5
# Run migrations unless skipped
if [[ "$SKIP_MIGRATE" != "true" ]]; then
echo ">>> Running database migrations..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python manage.py migrate'
echo ">>> Collecting static files..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python manage.py collectstatic --noinput'
echo ">>> Seeding/updating platform plugins for all tenants..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python -c "
import django
django.setup()
from django_tenants.utils import get_tenant_model
from django.core.management import call_command
Tenant = get_tenant_model()
for tenant in Tenant.objects.exclude(schema_name=\"public\"):
print(f\" Seeding plugins for {tenant.schema_name}...\")
call_command(\"tenant_command\", \"seed_platform_plugins\", schema=tenant.schema_name, verbosity=0)
print(\" Done!\")
"'
else
echo ">>> Skipping migrations (--no-migrate flag used)"
fi
echo ">>> Deployment complete!"
ENDSSH
echo ""
print_status "==================================="
print_status "Deployment Complete!"
print_status "==================================="
echo ""
echo "Your application should now be running at:"
echo " - https://smoothschedule.com"
echo " - https://platform.smoothschedule.com"
echo " - https://*.smoothschedule.com (tenant subdomains)"
echo ""
echo "To view logs:"
echo " ssh $SERVER 'cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml logs -f'"
echo ""
echo "To check status:"
echo " ssh $SERVER 'cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml ps'"

34
email_templates/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Email Template Pack - Styles & Variations
This directory contains a set of uniquely styled email templates. Each category offers multiple design aesthetics to suit different business brands.
## Categories & Styles
### 1. Appointment Confirmation (`/confirmation`)
- **Modern Blue (`modern_blue.html`)**: Clean, corporate, uses `Segoe UI`, rounded corners, and a blue hero header. Ideal for medical, tech, or professional services.
- **Classic Serif (`classic_serif.html`)**: Elegant, uses `Georgia/Times`, borders instead of shadows, warm beige background (`#faf9f6`). Perfect for law firms, salons, or luxury brands.
- **Bold Dark (`bold_dark.html`)**: High contrast, dark mode aesthetic (`#111111` background), bold typography (`Helvetica Neue`), vibrant pink accents. Great for gyms, modern barbershops, or nightlife venues.
### 2. Appointment Reminder (`/reminder`)
- **Soft & Clean (`soft_clean.html`)**: Minimalist, uses circle imagery, ample whitespace, soft pink/rose color palette. Friendly and non-intrusive.
- **Urgent Bold (`urgent_bold.html`)**: Uses red accents and bold `Arial Black` fonts to convey urgency. "Action Required" styling to reduce no-shows.
- **Personal Note (`personal_note.html`)**: A simple, letter-style layout using serif fonts on a cream background. Feels like a handwritten note from the owner.
### 3. Marketing / Welcome (`/marketing`)
- **Vibrant (`welcome_vibrant.html`)**: Uses a gradient top bar, bold typography, and image collages. High energy, designed to excite new customers.
- **Minimalist Promo (`minimalist_promo.html`)**: Monochromatic, fashion-forward design with a large hero image and a prominent discount code box. High impact.
- **Newsletter Grid (`newsletter_grid.html`)**: A classic multi-column layout for monthly updates, featuring a main story and secondary news items. Clean and readable.
### 4. Reports (`/reports`)
- **Monthly Data (`monthly_data.html`)**: A utility-focused layout with a data grid, performance chart placeholder, and clean typography. Designed for clarity and readability.
- **Weekly Snapshot (`weekly_cards.html`)**: A dashboard-style dark mode email with card-based statistics (Revenue, Bookings, etc.) for quick scanning.
- **Staff Leaderboard (`staff_leaderboard.html`)**: A ranked list view with avatars and performance metrics to highlight top employees. Motivating and clear.
## Image Assets
Templates use `https://placehold.co` for dynamic image generation to ensure immediate previewability without requiring local asset hosting.
- **Banners**: 600x200px
- **Icons**: 80x80px or 120x120px
- **Colors**: Matched to the template theme (e.g., `#4f46e5` for modern blue).
## Usage
Copy the HTML code from the desired style file into your email sending service or SmoothSchedule template editor. Ensure all `{{TAGS}}` are replaced with actual data.

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Appointment Confirmed - Bold</title>
</head>
<body style="margin: 0; padding: 0; background-color: #000000; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 20px;">
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #111111; border-radius: 24px; overflow: hidden;">
<!-- Header Image -->
<tr>
<td style="position: relative;">
<img src="https://placehold.co/600x300/db2777/ffffff?text=CONFIRMED&font=montserrat" alt="Confirmed" style="width: 100%; height: auto; display: block;">
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 48px 40px;">
<h1 style="margin: 0 0 16px; color: #ffffff; font-size: 32px; font-weight: 800; letter-spacing: -0.03em;">
Ready for you, {{CUSTOMER_NAME}}.
</h1>
<p style="margin: 0 0 40px; color: #a1a1aa; font-size: 18px; line-height: 1.5;">
Your slot is locked in. We've got everything prepared for your upcoming visit.
</p>
<!-- Grid Layout for Details -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td width="48%" style="background-color: #18181b; padding: 24px; border-radius: 16px; vertical-align: top;">
<p style="margin: 0 0 8px; color: #db2777; font-size: 12px; font-weight: 700; text-transform: uppercase;">Service</p>
<p style="margin: 0; color: #ffffff; font-size: 16px; font-weight: 600;">{{APPOINTMENT_SERVICE}}</p>
</td>
<td width="4%"></td>
<td width="48%" style="background-color: #18181b; padding: 24px; border-radius: 16px; vertical-align: top;">
<p style="margin: 0 0 8px; color: #db2777; font-size: 12px; font-weight: 700; text-transform: uppercase;">When</p>
<p style="margin: 0; color: #ffffff; font-size: 16px; font-weight: 600;">{{APPOINTMENT_DATE}}<br><span style="color: #a1a1aa; font-weight: 400;">{{APPOINTMENT_TIME}}</span></p>
</td>
</tr>
</table>
<!-- QR Code Placeholder -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin-top: 40px;">
<tr>
<td align="center">
<div style="background-color: #ffffff; padding: 16px; border-radius: 12px; display: inline-block;">
<img src="https://placehold.co/150x150/000000/ffffff?text=QR+Code&font=roboto" alt="Check-in QR" style="display: block;">
</div>
<p style="margin: 16px 0 0; color: #52525b; font-size: 12px;">Scan at front desk to check in</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 0 40px 40px; text-align: center;">
<p style="margin: 0; color: #52525b; font-size: 14px;">
{{BUSINESS_NAME}} • {{BUSINESS_PHONE}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Appointment Confirmed - Classic</title>
</head>
<body style="margin: 0; padding: 0; background-color: #faf9f6; font-family: 'Georgia', 'Times New Roman', serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 40px 0;">
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; border: 1px solid #e7e5e4; border-top: 4px solid #1c1917;">
<!-- Header -->
<tr>
<td style="padding: 40px 40px 20px; text-align: center;">
<img src="https://placehold.co/120x60/1c1917/ffffff?text=LOGO&font=playfair-display" alt="Logo" style="height: 60px; width: auto;">
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 20px 60px 40px;">
<h1 style="margin: 0 0 24px; color: #1c1917; font-size: 28px; font-weight: 400; text-align: center; letter-spacing: -0.02em;">
Appointment Confirmation
</h1>
<p style="margin: 0 0 24px; color: #44403c; font-size: 16px; line-height: 1.8; text-align: center;">
Dear {{CUSTOMER_NAME}},<br><br>
We are pleased to confirm your appointment with {{BUSINESS_NAME}}. Please review the details below.
</p>
<!-- Divider -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="border-bottom: 1px solid #e7e5e4; padding-bottom: 20px; margin-bottom: 20px;"></td>
</tr>
</table>
<!-- Details -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin-top: 20px;">
<tr>
<td style="padding: 12px 0; color: #78716c; font-family: 'Arial', sans-serif; font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em;">Service</td>
<td style="padding: 12px 0; text-align: right; color: #1c1917; font-size: 16px;">{{APPOINTMENT_SERVICE}}</td>
</tr>
<tr>
<td style="border-bottom: 1px solid #f5f5f4; padding: 0;" colspan="2"></td>
</tr>
<tr>
<td style="padding: 12px 0; color: #78716c; font-family: 'Arial', sans-serif; font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em;">Date</td>
<td style="padding: 12px 0; text-align: right; color: #1c1917; font-size: 16px;">{{APPOINTMENT_DATE}}</td>
</tr>
<tr>
<td style="border-bottom: 1px solid #f5f5f4; padding: 0;" colspan="2"></td>
</tr>
<tr>
<td style="padding: 12px 0; color: #78716c; font-family: 'Arial', sans-serif; font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em;">Time</td>
<td style="padding: 12px 0; text-align: right; color: #1c1917; font-size: 16px;">{{APPOINTMENT_TIME}}</td>
</tr>
</table>
<!-- Map / Location Image Placeholder -->
<div style="margin-top: 30px; border: 1px solid #e7e5e4; padding: 4px;">
<img src="https://placehold.co/500x150/f5f5f4/a8a29e?text=Location+Map&font=lora" alt="Location" style="width: 100%; height: auto; display: block;">
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #1c1917; padding: 30px; text-align: center;">
<p style="margin: 0; color: #d6d3d1; font-family: 'Arial', sans-serif; font-size: 13px; line-height: 1.6;">
{{BUSINESS_NAME}}<br>
{{BUSINESS_PHONE}} | {{BUSINESS_EMAIL}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Appointment Confirmed - Modern</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 40px 0;">
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);">
<!-- Hero Image Section -->
<tr>
<td style="background-color: #4f46e5; text-align: center;">
<!-- Using a placeholder for the hero image -->
<img src="https://placehold.co/600x200/4f46e5/ffffff?text=Appointment+Confirmed&font=roboto" alt="Appointment Confirmed" style="width: 100%; height: auto; display: block;">
</td>
</tr>
<!-- Content Section -->
<tr>
<td style="padding: 40px;">
<h1 style="margin: 0 0 20px; color: #111827; font-size: 24px; font-weight: 700; line-height: 1.2;">
You're All Set, {{CUSTOMER_NAME}}!
</h1>
<p style="margin: 0 0 24px; color: #4b5563; font-size: 16px; line-height: 1.6;">
We are excited to see you at <strong>{{BUSINESS_NAME}}</strong>. Your appointment has been confirmed for the following time:
</p>
<!-- Appointment Card -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #f9fafb; border-radius: 12px; border: 1px solid #e5e7eb;">
<tr>
<td style="padding: 24px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="padding-bottom: 16px; border-bottom: 1px solid #e5e7eb;">
<p style="margin: 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Service</p>
<p style="margin: 4px 0 0; color: #111827; font-size: 18px; font-weight: 600;">{{APPOINTMENT_SERVICE}}</p>
</td>
</tr>
<tr>
<td style="padding-top: 16px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td width="50%" valign="top">
<p style="margin: 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Date</p>
<p style="margin: 4px 0 0; color: #111827; font-size: 16px;">{{APPOINTMENT_DATE}}</p>
</td>
<td width="50%" valign="top">
<p style="margin: 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Time</p>
<p style="margin: 4px 0 0; color: #111827; font-size: 16px;">{{APPOINTMENT_TIME}}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin-top: 32px;">
<tr>
<td align="center">
<a href="#" style="display: inline-block; background-color: #4f46e5; color: #ffffff; font-size: 16px; font-weight: 600; text-decoration: none; padding: 12px 32px; border-radius: 8px; transition: background-color 0.2s;">
Manage Appointment
</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #1f2937; padding: 32px; text-align: center;">
<img src="https://placehold.co/40x40/ffffff/1f2937?text=L" alt="Logo" style="width: 40px; height: 40px; border-radius: 8px; margin-bottom: 16px;">
<p style="margin: 0 0 8px; color: #9ca3af; font-size: 14px;">
{{BUSINESS_NAME}}
</p>
<p style="margin: 0; color: #6b7280; font-size: 12px;">
{{BUSINESS_PHONE}} • {{BUSINESS_EMAIL}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Marketing - Minimalist Promo</title>
</head>
<body style="margin: 0; padding: 0; background-color: #ffffff; font-family: 'Courier New', Courier, monospace;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 40px 20px;">
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="border: 2px solid #000000;">
<!-- Big Hero Image -->
<tr>
<td>
<img src="https://placehold.co/600x400/000000/ffffff?text=FLASH+SALE&font=monoton" alt="Flash Sale" style="width: 100%; height: auto; display: block;">
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px; text-align: center;">
<h1 style="margin: 0 0 20px; color: #000000; font-size: 36px; font-weight: 700; text-transform: uppercase; letter-spacing: 2px;">
limited time only
</h1>
<p style="margin: 0 0 40px; color: #333333; font-size: 16px; line-height: 1.6; font-family: 'Helvetica', sans-serif;">
Treat yourself to something special. For the next 48 hours, get exclusive access to our VIP booking slots and a special discount.
</p>
<!-- Coupon Code Box -->
<div style="border: 2px dashed #000000; padding: 20px; display: inline-block; margin-bottom: 40px;">
<p style="margin: 0 0 5px; font-size: 12px; color: #666; font-family: sans-serif;">USE CODE:</p>
<span style="font-size: 32px; font-weight: 900; color: #d946ef;">VIP20</span>
</div>
<br>
<a href="#" style="background-color: #000000; color: #ffffff; padding: 18px 40px; text-decoration: none; font-weight: bold; font-size: 14px; text-transform: uppercase; display: inline-block;">
Claim Offer
</a>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="border-top: 2px solid #000000; padding: 20px; text-align: center;">
<p style="margin: 0; font-size: 12px; color: #000000;">
{{BUSINESS_NAME}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Marketing - Newsletter Grid</title>
</head>
<body style="margin: 0; padding: 0; background-color: #e5e7eb; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<!-- Top Bar -->
<tr>
<td style="background-color: #374151; padding: 10px 0; text-align: center; color: #d1d5db; font-size: 12px;">
View this email in your browser
</td>
</tr>
<tr>
<td align="center" style="padding: 40px 20px;">
<!-- Logo Header -->
<table width="640" cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom: 20px;">
<tr>
<td align="center">
<h1 style="margin: 0; color: #1f2937; font-size: 28px; font-weight: 300; letter-spacing: 1px;">{{BUSINESS_NAME}} <span style="color: #3b82f6; font-weight: 700;">MONTHLY</span></h1>
</td>
</tr>
</table>
<!-- Main Feature -->
<table width="640" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; margin-bottom: 20px;">
<tr>
<td>
<img src="https://placehold.co/640x320/3b82f6/ffffff?text=New+Service+Launch&font=roboto" alt="Feature" style="width: 100%; height: auto; display: block;">
</td>
</tr>
<tr>
<td style="padding: 30px;">
<h2 style="margin: 0 0 10px; color: #111827; font-size: 24px;">Introducing Our New Premium Service</h2>
<p style="margin: 0 0 20px; color: #4b5563; line-height: 1.6;">
We've been listening to your feedback and are excited to announce a brand new way to experience {{BUSINESS_NAME}}. Our new premium tier offers extended hours and dedicated support.
</p>
<a href="#" style="color: #3b82f6; text-decoration: none; font-weight: 600;">Read more &rarr;</a>
</td>
</tr>
</table>
<!-- Two Column Grid -->
<table width="640" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td width="310" valign="top" style="background-color: #ffffff; padding-bottom: 20px;">
<img src="https://placehold.co/310x200/10b981/ffffff?text=Staff+Spotlight" alt="Staff" style="width: 100%; height: auto; display: block;">
<div style="padding: 20px;">
<h3 style="margin: 0 0 10px; color: #111827; font-size: 18px;">Employee of the Month</h3>
<p style="margin: 0 0 15px; color: #6b7280; font-size: 14px; line-height: 1.5;">
Meet Sarah, our lead specialist who has gone above and beyond this month.
</p>
</div>
</td>
<td width="20"><!-- Gutter --></td>
<td width="310" valign="top" style="background-color: #ffffff; padding-bottom: 20px;">
<img src="https://placehold.co/310x200/f59e0b/ffffff?text=Community" alt="Community" style="width: 100%; height: auto; display: block;">
<div style="padding: 20px;">
<h3 style="margin: 0 0 10px; color: #111827; font-size: 18px;">Community Events</h3>
<p style="margin: 0 0 15px; color: #6b7280; font-size: 14px; line-height: 1.5;">
Join us this weekend for our local charity drive.
</p>
</div>
</td>
</tr>
</table>
<!-- Footer -->
<table width="640" cellpadding="0" cellspacing="0" role="presentation" style="margin-top: 40px;">
<tr>
<td align="center">
<p style="margin: 0 0 10px; color: #9ca3af; font-size: 12px;">
© {{TODAY}} {{BUSINESS_NAME}}. All rights reserved.
</p>
<p style="margin: 0; color: #9ca3af; font-size: 12px;">
{{BUSINESS_ADDRESS}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Marketing - Vibrant</title>
</head>
<body style="margin: 0; padding: 0; background-color: #ffffff; font-family: 'Verdana', sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<!-- Colorful Top Bar -->
<tr>
<td height="8" style="background: linear-gradient(90deg, #8b5cf6 0%, #ec4899 100%);"></td>
</tr>
<tr>
<td align="center" style="padding: 40px 20px;">
<table width="600" cellpadding="0" cellspacing="0" role="presentation">
<!-- Logo -->
<tr>
<td align="center" style="padding-bottom: 40px;">
<img src="https://placehold.co/80x80/8b5cf6/ffffff?text=S&font=montserrat" alt="SmoothSchedule" style="display: block; border-radius: 50%;">
</td>
</tr>
<!-- Hero -->
<tr>
<td align="center">
<h1 style="margin: 0 0 20px; color: #111827; font-size: 42px; font-weight: 900; letter-spacing: -1px;">
Welcome to the family.
</h1>
<p style="margin: 0 0 40px; color: #6b7280; font-size: 18px; max-width: 480px;">
Thanks for joining <strong>{{BUSINESS_NAME}}</strong>! We're thrilled to have you on board.
</p>
</td>
</tr>
<!-- Hero Image collage -->
<tr>
<td style="padding-bottom: 40px;">
<img src="https://placehold.co/600x300/f3f4f6/d1d5db?text=Lifestyle+Image+Collage" alt="Lifestyle" style="width: 100%; height: auto; border-radius: 12px; display: block;">
</td>
</tr>
<!-- Features Grid -->
<tr>
<td>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td width="33%" valign="top" style="padding-right: 10px;">
<h3 style="margin: 0 0 8px; color: #111827; font-size: 16px;">Expert Staff</h3>
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Top-tier professionals ready to serve.</p>
</td>
<td width="33%" valign="top" style="padding: 0 10px;">
<h3 style="margin: 0 0 8px; color: #111827; font-size: 16px;">Easy Booking</h3>
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Schedule anytime, anywhere.</p>
</td>
<td width="33%" valign="top" style="padding-left: 10px;">
<h3 style="margin: 0 0 8px; color: #111827; font-size: 16px;">Best Value</h3>
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Premium service at great rates.</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- CTA -->
<tr>
<td align="center" style="padding-top: 60px;">
<a href="#" style="background-color: #111827; color: #ffffff; padding: 16px 40px; border-radius: 50px; text-decoration: none; font-weight: 600; font-size: 16px;">Book Your First Visit</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reminder - Personal Note</title>
</head>
<body style="margin: 0; padding: 0; background-color: #fdfbf7; font-family: 'Georgia', serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 60px 20px;">
<table width="500" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; border: 1px solid #e7e5e4; padding: 60px 40px; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);">
<tr>
<td>
<p style="margin: 0 0 20px; font-size: 16px; color: #44403c; line-height: 1.6;">
Dear {{CUSTOMER_NAME}},
</p>
<p style="margin: 0 0 20px; font-size: 16px; color: #44403c; line-height: 1.6;">
I'm writing to confirm that we're still on for your <strong>{{APPOINTMENT_SERVICE}}</strong> tomorrow, <strong>{{APPOINTMENT_DATE}}</strong> at <strong>{{APPOINTMENT_TIME}}</strong>.
</p>
<p style="margin: 0 0 40px; font-size: 16px; color: #44403c; line-height: 1.6;">
Looking forward to our session.
</p>
<p style="margin: 0; font-size: 16px; color: #44403c; line-height: 1.6;">
Warmly,<br><br>
<span style="font-style: italic; font-size: 18px; color: #1c1917;">{{BUSINESS_NAME}}</span>
</p>
</td>
</tr>
</table>
<p style="margin-top: 20px; font-family: sans-serif; font-size: 12px; color: #a8a29e; text-align: center;">
<a href="#" style="color: #a8a29e; text-decoration: underline;">Reschedule</a> or <a href="#" style="color: #a8a29e; text-decoration: underline;">Cancel</a>
</p>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reminder - Soft & Clean</title>
</head>
<body style="margin: 0; padding: 0; background-color: #fff1f2; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 60px 0;">
<table width="500" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; border-radius: 20px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.01);">
<!-- Circle Image Top -->
<tr>
<td align="center" style="padding-top: 40px;">
<img src="https://placehold.co/120x120/fb7185/ffffff?text=Soon&font=playfair-display" alt="Soon" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover; display: block;">
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 30px 50px 50px; text-align: center;">
<h1 style="margin: 0 0 16px; color: #881337; font-size: 24px; font-weight: 600;">
Just a Friendly Reminder
</h1>
<p style="margin: 0 0 32px; color: #4c0519; font-size: 16px; line-height: 1.6;">
Hi {{CUSTOMER_NAME}}, your appointment with {{BUSINESS_NAME}} is coming up soon!
</p>
<div style="background-color: #fff1f2; border-radius: 12px; padding: 20px; display: inline-block; width: 100%; box-sizing: border-box;">
<p style="margin: 0 0 8px; color: #be123c; font-size: 18px; font-weight: 700;">{{APPOINTMENT_DATE}}</p>
<p style="margin: 0; color: #9f1239; font-size: 24px; font-weight: 300;">{{APPOINTMENT_TIME}}</p>
</div>
<p style="margin: 32px 0 0; color: #9ca3af; font-size: 13px;">
Need to make changes? <a href="#" style="color: #fb7185; text-decoration: underline;">Reschedule here</a>
</p>
</td>
</tr>
</table>
<!-- Simple Footer -->
<table width="500" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="padding-top: 20px; text-align: center;">
<p style="margin: 0; color: #f43f5e; font-size: 12px;">
{{BUSINESS_NAME}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reminder - Urgent</title>
</head>
<body style="margin: 0; padding: 0; background-color: #fef2f2; font-family: 'Arial Black', 'Arial Bold', sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 20px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 600px; border-left: 8px solid #ef4444; background-color: #ffffff;">
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 10px; color: #ef4444; font-size: 14px; letter-spacing: 1px; text-transform: uppercase;">Action Required</p>
<h1 style="margin: 0 0 30px; color: #111827; font-size: 36px; line-height: 1;">
Don't Forget<br>Your Visit.
</h1>
<img src="https://placehold.co/520x250/ef4444/ffffff?text=TOMORROW&font=oswald" alt="Tomorrow" style="width: 100%; height: auto; display: block; margin-bottom: 30px;">
<p style="margin: 0 0 20px; color: #374151; font-family: 'Arial', sans-serif; font-size: 16px; line-height: 1.6;">
<strong>{{CUSTOMER_NAME}}</strong>, we're holding your spot for <strong>{{APPOINTMENT_SERVICE}}</strong>.
</p>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #111827; color: #ffffff; padding: 20px;">
<tr>
<td align="center">
<p style="margin: 0; font-size: 20px;">{{APPOINTMENT_DATE}} @ {{APPOINTMENT_TIME}}</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="background-color: #f3f4f6; padding: 20px; text-align: center;">
<p style="margin: 0; color: #6b7280; font-family: 'Arial', sans-serif; font-size: 12px;">
{{BUSINESS_NAME}} - {{BUSINESS_PHONE}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Monthly Report - Data Heavy</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Roboto', 'Helvetica', sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 20px;">
<!-- Header -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 800px; margin-bottom: 20px;">
<tr>
<td style="padding: 20px 0;">
<h1 style="margin: 0; color: #0f172a; font-size: 20px; font-weight: 500;">
<span style="color: #3b82f6; font-weight: 700;">Smooth</span>Schedule Report
</h1>
</td>
<td align="right">
<p style="margin: 0; color: #64748b; font-size: 14px;">{{TODAY}}</p>
</td>
</tr>
</table>
<!-- Main Card -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 800px; background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px;">
<tr>
<td style="padding: 30px;">
<h2 style="margin: 0 0 20px; color: #0f172a; font-size: 18px;">Performance Summary</h2>
<!-- Chart Placeholder -->
<img src="https://placehold.co/740x200/f1f5f9/94a3b8?text=Interactive+Revenue+Chart&font=roboto" alt="Chart" style="width: 100%; height: auto; border-radius: 4px; margin-bottom: 30px;">
<!-- Data Grid -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse: collapse;">
<tr style="background-color: #f8fafc;">
<th style="text-align: left; padding: 12px; border-bottom: 2px solid #e2e8f0; color: #475569; font-size: 12px; text-transform: uppercase;">Metric</th>
<th style="text-align: right; padding: 12px; border-bottom: 2px solid #e2e8f0; color: #475569; font-size: 12px; text-transform: uppercase;">Value</th>
<th style="text-align: right; padding: 12px; border-bottom: 2px solid #e2e8f0; color: #475569; font-size: 12px; text-transform: uppercase;">Change</th>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; color: #1e293b;">Total Revenue</td>
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #1e293b; font-weight: 600;">$12,450</td>
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #10b981;">+12% ▲</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; color: #1e293b;">Appointments</td>
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #1e293b; font-weight: 600;">142</td>
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #10b981;">+5% ▲</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; color: #1e293b;">New Customers</td>
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #1e293b; font-weight: 600;">28</td>
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right; color: #ef4444;">-2% ▼</td>
</tr>
</table>
<p style="margin: 20px 0 0; color: #64748b; font-size: 14px;">
This report was automatically generated for <strong>{{BUSINESS_NAME}}</strong>.
</p>
</td>
</tr>
<tr>
<td style="background-color: #f1f5f9; padding: 15px 30px; text-align: center; border-top: 1px solid #e2e8f0;">
<a href="#" style="color: #3b82f6; font-size: 14px; text-decoration: none; font-weight: 500;">View Full Report in Dashboard →</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Report - Staff Leaderboard</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: 'Arial', sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 40px 20px;">
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<tr>
<td style="padding: 30px; border-bottom: 1px solid #e5e7eb;">
<h1 style="margin: 0; color: #111827; font-size: 20px;">Staff Performance</h1>
<p style="margin: 5px 0 0; color: #6b7280; font-size: 14px;">Top performers for {{TODAY}}</p>
</td>
</tr>
<!-- List -->
<tr>
<td style="padding: 0 30px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<!-- Item 1 -->
<tr>
<td style="padding: 20px 0; border-bottom: 1px solid #f3f4f6;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td width="50">
<img src="https://placehold.co/40x40/10b981/ffffff?text=1" alt="Rank 1" style="border-radius: 50%; display: block;">
</td>
<td>
<p style="margin: 0; font-weight: 600; color: #111827;">Sarah Johnson</p>
<p style="margin: 2px 0 0; font-size: 12px; color: #6b7280;">32 Appointments</p>
</td>
<td align="right" width="100">
<p style="margin: 0; font-weight: 700; color: #10b981;">$3,200</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Item 2 -->
<tr>
<td style="padding: 20px 0; border-bottom: 1px solid #f3f4f6;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td width="50">
<img src="https://placehold.co/40x40/3b82f6/ffffff?text=2" alt="Rank 2" style="border-radius: 50%; display: block;">
</td>
<td>
<p style="margin: 0; font-weight: 600; color: #111827;">Mike Chen</p>
<p style="margin: 2px 0 0; font-size: 12px; color: #6b7280;">28 Appointments</p>
</td>
<td align="right" width="100">
<p style="margin: 0; font-weight: 700; color: #111827;">$2,850</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Item 3 -->
<tr>
<td style="padding: 20px 0;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td width="50">
<img src="https://placehold.co/40x40/6b7280/ffffff?text=3" alt="Rank 3" style="border-radius: 50%; display: block;">
</td>
<td>
<p style="margin: 0; font-weight: 600; color: #111827;">Jessica Williams</p>
<p style="margin: 2px 0 0; font-size: 12px; color: #6b7280;">25 Appointments</p>
</td>
<td align="right" width="100">
<p style="margin: 0; font-weight: 700; color: #111827;">$2,100</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f9fafb; padding: 20px 30px; border-radius: 0 0 12px 12px;">
<p style="margin: 0; font-size: 13px; color: #6b7280; text-align: center;">
Great work team! 🚀
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Report - Weekly Snapshot</title>
</head>
<body style="margin: 0; padding: 0; background-color: #1e293b; font-family: 'Roboto', sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 40px 20px;">
<table width="600" cellpadding="0" cellspacing="0" role="presentation">
<!-- Header -->
<tr>
<td style="padding-bottom: 30px;">
<h1 style="margin: 0; color: #ffffff; font-size: 24px;">Weekly Snapshot</h1>
<p style="margin: 5px 0 0; color: #94a3b8;">Week of {{TODAY}}</p>
</td>
</tr>
<!-- Stats Grid -->
<tr>
<td>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<!-- Card 1 -->
<td width="290" style="background-color: #334155; border-radius: 8px; padding: 25px; vertical-align: top;">
<img src="https://placehold.co/40x40/3b82f6/ffffff?text=$" alt="Revenue" style="width: 40px; height: 40px; border-radius: 8px; margin-bottom: 15px;">
<p style="margin: 0 0 5px; color: #94a3b8; font-size: 13px; text-transform: uppercase;">Revenue</p>
<p style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">$4,250</p>
<p style="margin: 5px 0 0; color: #4ade80; font-size: 12px;">↑ 15% vs last week</p>
</td>
<td width="20"></td>
<!-- Card 2 -->
<td width="290" style="background-color: #334155; border-radius: 8px; padding: 25px; vertical-align: top;">
<img src="https://placehold.co/40x40/8b5cf6/ffffff?text=#" alt="Bookings" style="width: 40px; height: 40px; border-radius: 8px; margin-bottom: 15px;">
<p style="margin: 0 0 5px; color: #94a3b8; font-size: 13px; text-transform: uppercase;">Bookings</p>
<p style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">84</p>
<p style="margin: 5px 0 0; color: #94a3b8; font-size: 12px;">→ Stable</p>
</td>
</tr>
<tr><td height="20"></td></tr>
<tr>
<!-- Card 3 -->
<td width="290" style="background-color: #334155; border-radius: 8px; padding: 25px; vertical-align: top;">
<img src="https://placehold.co/40x40/f59e0b/ffffff?text=★" alt="Rating" style="width: 40px; height: 40px; border-radius: 8px; margin-bottom: 15px;">
<p style="margin: 0 0 5px; color: #94a3b8; font-size: 13px; text-transform: uppercase;">Avg Rating</p>
<p style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">4.9</p>
</td>
<td width="20"></td>
<!-- Card 4 -->
<td width="290" style="background-color: #334155; border-radius: 8px; padding: 25px; vertical-align: top;">
<img src="https://placehold.co/40x40/ef4444/ffffff?text=!" alt="Cancellations" style="width: 40px; height: 40px; border-radius: 8px; margin-bottom: 15px;">
<p style="margin: 0 0 5px; color: #94a3b8; font-size: 13px; text-transform: uppercase;">Cancellations</p>
<p style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">3</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding-top: 40px; text-align: center;">
<a href="#" style="color: #3b82f6; text-decoration: none; font-size: 14px;">View detailed analytics</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

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

View File

@@ -1,3 +1,3 @@
# Production environment variables
# Set VITE_API_URL to your production API URL
VITE_API_URL=https://api.yourdomain.com
# Use relative API URL - will use same origin as the page
VITE_API_URL=https://api.smoothschedule.com

View File

@@ -0,0 +1,201 @@
# Navigation Redesign Plan
## Overview
Redesigning both the main sidebar and settings page navigation to be more organized and scalable.
## Current Issues
### Main Sidebar
- 15+ items in a flat list with no grouping
- Dropdowns (Plugins, Help) hide important items
- No visual hierarchy or section headers
- Settings isolated at bottom
### Settings Page
- 7 horizontal tabs getting crowded
- Not scalable for new settings
- No logical grouping
---
## Phase 1: Refactor Main Sidebar (COMPLETED)
### New Structure with Grouped Sections
```
[Logo] Business Name
subdomain.smoothschedule
○ Dashboard
○ Scheduler
○ Tasks
MANAGE
○ Customers
○ Services
○ Resources
○ Staff
COMMUNICATE
○ Messages
○ Tickets
MONEY
○ Payments
EXTEND
○ Plugins
○ Email Templates
──────────
○ Settings
○ Help & Docs
──────────
[User] Sign Out
```
### Files Created/Modified
- `src/components/navigation/SidebarComponents.tsx` - Shared components (DONE)
- `src/components/Sidebar.tsx` - Refactor to use new components (TODO)
---
## Phase 2: Settings Sidebar Layout
### New Settings Structure
Settings becomes a sub-application with its own sidebar:
```
/settings
├── /general - Business name, timezone, etc.
├── /branding - Logo, colors, display mode
├── /resource-types - Resource type management
├── /domains - Custom domains
├── /api-tokens - API access tokens
├── /authentication - OAuth, social login
├── /email - Email addresses for tickets
├── /communication - SMS & calling credits
├── /billing - Subscription, credits, invoices (future)
```
### Settings Sidebar Layout
```
┌──────────────────┬──────────────────────────────────────────┐
│ ← Back to App │ │
│ │ [Page Title] │
│ BUSINESS │ [Page Description] │
│ ○ General │ │
│ ○ Branding │ [Content Area] │
│ ○ Resource Types │ │
│ │ │
│ INTEGRATIONS │ │
│ ○ Domains │ │
│ ○ API & Webhooks │ │
│ │ │
│ ACCESS │ │
│ ○ Authentication │ │
│ │ │
│ COMMUNICATION │ │
│ ○ Email Setup │ │
│ ○ SMS & Calling │ │
│ │ │
│ BILLING │ │
│ ○ Credits │ │
│ │ │
└──────────────────┴──────────────────────────────────────────┘
```
### Files to Create
1. `src/components/navigation/SettingsSidebar.tsx` - Settings-specific sidebar
2. `src/layouts/SettingsLayout.tsx` - Layout wrapper with sidebar + content
3. Split `src/pages/Settings.tsx` into:
- `src/pages/settings/GeneralSettings.tsx`
- `src/pages/settings/BrandingSettings.tsx`
- `src/pages/settings/ResourceTypesSettings.tsx`
- `src/pages/settings/DomainsSettings.tsx`
- `src/pages/settings/ApiTokensSettings.tsx`
- `src/pages/settings/AuthenticationSettings.tsx`
- `src/pages/settings/EmailSettings.tsx`
- `src/pages/settings/CommunicationSettings.tsx`
### Route Updates (in App.tsx or routes file)
```tsx
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="/settings/general" replace />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="branding" element={<BrandingSettings />} />
<Route path="resource-types" element={<ResourceTypesSettings />} />
<Route path="domains" element={<DomainsSettings />} />
<Route path="api-tokens" element={<ApiTokensSettings />} />
<Route path="authentication" element={<AuthenticationSettings />} />
<Route path="email" element={<EmailSettings />} />
<Route path="communication" element={<CommunicationSettings />} />
</Route>
```
---
## Implementation Order
1. ✅ Create shared sidebar components (`SidebarComponents.tsx`)
2. ✅ Refactor main `Sidebar.tsx` to use grouped sections
3. ✅ Create `SettingsLayout.tsx` (includes sidebar)
4. ⏳ Split Settings.tsx into sub-pages
5. ⬜ Update routes in App.tsx
6. ⬜ Test all navigation flows
## Files Created So Far
- `src/components/navigation/SidebarComponents.tsx` - Shared nav components
- `src/components/Sidebar.tsx` - Refactored with grouped sections
- `src/layouts/SettingsLayout.tsx` - Settings page wrapper with sidebar
- `src/pages/settings/` - Directory for settings sub-pages (in progress)
---
## Component APIs
### SidebarSection
```tsx
<SidebarSection title="MANAGE" isCollapsed={isCollapsed}>
<SidebarItem to="/customers" icon={Users} label="Customers" />
</SidebarSection>
```
### SidebarItem
```tsx
<SidebarItem
to="/settings"
icon={Settings}
label="Settings"
isCollapsed={isCollapsed}
exact={true}
badge="3" // optional badge
/>
```
### SettingsSidebarSection / SettingsSidebarItem
```tsx
<SettingsSidebarSection title="BUSINESS">
<SettingsSidebarItem
to="/settings/general"
icon={Building2}
label="General"
description="Business name, timezone"
/>
</SettingsSidebarSection>
```
---
## Notes
- Main sidebar uses white/transparent colors (gradient background)
- Settings sidebar uses gray/brand colors (white background)
- Both support collapsed state on main sidebar
- Settings sidebar is always expanded (no collapse)
- Mobile: Main sidebar becomes drawer, Settings sidebar becomes sheet/drawer

View File

@@ -2,10 +2,31 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="120x120" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- CSP: Disabled in development due to browser extension conflicts. Enable in production via server headers. -->
<title>Smooth Schedule - Multi-Tenant Scheduling</title>
<title>Smooth Schedule | Online Appointment Scheduling Software</title>
<meta name="description" content="The all-in-one scheduling platform for service businesses. Manage appointments, staff, and payments effortlessly. Start free today." />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Smooth Schedule | Online Appointment Scheduling Software" />
<meta property="og:description" content="The all-in-one scheduling platform for service businesses. Manage appointments, staff, and payments effortlessly." />
<meta property="og:image" content="https://smoothschedule.com/og-image.png" />
<meta property="og:url" content="https://smoothschedule.com" />
<meta property="og:site_name" content="Smooth Schedule" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Smooth Schedule | Online Appointment Scheduling Software" />
<meta name="twitter:description" content="The all-in-one scheduling platform for service businesses. Manage appointments, staff, and payments effortlessly." />
<meta name="twitter:image" content="https://smoothschedule.com/og-image.png" />
<!-- Additional SEO -->
<meta name="robots" content="noindex, nofollow" />
<meta name="author" content="Smooth Schedule Inc." />
<link rel="canonical" href="https://smoothschedule.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Ensure full height for the app */

View File

@@ -45,7 +45,49 @@ http {
add_header Cache-Control "public, immutable";
}
# Handle SPA routing - serve index.html for all routes
# Proxy API requests to Django
location /api/ {
proxy_pass http://django:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy Admin requests to Django
location /admin/ {
proxy_pass http://django:5000;
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 WebSocket connections to Django (Daphne/ASGI)
location /ws/ {
proxy_pass http://django:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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_read_timeout 86400;
}
# Proxy Static/Media to Django (if served by WhiteNoise/Django)
location /static/ {
proxy_pass http://django:5000;
proxy_set_header Host $host;
}
location /media/ {
proxy_pass http://django:5000;
proxy_set_header Host $host;
}
# Handle SPA routing - serve index.html for all other routes
location / {
try_files $uri $uri/ /index.html;
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,18 +6,24 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@react-google-maps/api": "^2.20.7",
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.5.3",
"@tanstack/react-query": "^5.90.10",
"@types/react-grid-layout": "^1.3.6",
"@types/react-syntax-highlighter": "^15.5.13",
"axios": "^1.13.2",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.24",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-grid-layout": "^1.5.2",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.3.5",
"react-phone-number-input": "^3.4.14",
@@ -29,27 +35,38 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.15",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.2.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.0.15"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed"
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
}
}

View File

@@ -1,84 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- generic [ref=e7]:
- generic [ref=e9]:
- img [ref=e10]
- generic [ref=e16]: Smooth Schedule
- generic [ref=e17]:
- heading "Orchestrate your business with precision." [level=1] [ref=e18]
- paragraph [ref=e19]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
- generic [ref=e24]: © 2025 Smooth Schedule Inc.
- generic [ref=e26]:
- generic [ref=e27]:
- heading "Welcome back" [level=2] [ref=e28]
- paragraph [ref=e29]: Please enter your details to sign in.
- generic [ref=e30]:
- generic [ref=e31]:
- generic [ref=e32]:
- generic [ref=e33]: Username
- generic [ref=e34]:
- generic:
- img
- textbox "Username" [active] [ref=e35]:
- /placeholder: Enter your username
- text: superuser
- generic [ref=e36]:
- generic [ref=e37]: Password
- generic [ref=e38]:
- generic:
- img
- textbox "Password" [ref=e39]:
- /placeholder: ••••••••
- button "Sign in" [ref=e40]:
- generic [ref=e41]:
- text: Sign in
- img [ref=e42]
- generic [ref=e50]: Or continue with
- button "🇺🇸 English" [ref=e53]:
- img [ref=e54]
- generic [ref=e58]: 🇺🇸
- generic [ref=e59]: English
- img [ref=e60]
- generic [ref=e62]:
- heading "🔓 Quick Login (Dev Only)" [level=3] [ref=e64]:
- generic [ref=e65]: 🔓
- generic [ref=e66]: Quick Login (Dev Only)
- generic [ref=e67]:
- button "Platform Superuser SUPERUSER" [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: Platform Superuser
- generic [ref=e71]: SUPERUSER
- button "Platform Manager PLATFORM_MANAGER" [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]: Platform Manager
- generic [ref=e75]: PLATFORM_MANAGER
- button "Platform Sales PLATFORM_SALES" [ref=e76]:
- generic [ref=e77]:
- generic [ref=e78]: Platform Sales
- generic [ref=e79]: PLATFORM_SALES
- button "Platform Support PLATFORM_SUPPORT" [ref=e80]:
- generic [ref=e81]:
- generic [ref=e82]: Platform Support
- generic [ref=e83]: PLATFORM_SUPPORT
- button "Business Owner TENANT_OWNER" [ref=e84]:
- generic [ref=e85]:
- generic [ref=e86]: Business Owner
- generic [ref=e87]: TENANT_OWNER
- button "Business Manager TENANT_MANAGER" [ref=e88]:
- generic [ref=e89]:
- generic [ref=e90]: Business Manager
- generic [ref=e91]: TENANT_MANAGER
- button "Staff Member TENANT_STAFF" [ref=e92]:
- generic [ref=e93]:
- generic [ref=e94]: Staff Member
- generic [ref=e95]: TENANT_STAFF
- button "Customer CUSTOMER" [ref=e96]:
- generic [ref=e97]:
- generic [ref=e98]: Customer
- generic [ref=e99]: CUSTOMER
- generic [ref=e100]:
- text: "Password for all:"
- code [ref=e101]: test123
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 603 KiB

View File

@@ -1,84 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- generic [ref=e7]:
- generic [ref=e9]:
- img [ref=e10]
- generic [ref=e16]: Smooth Schedule
- generic [ref=e17]:
- heading "Orchestrate your business with precision." [level=1] [ref=e18]
- paragraph [ref=e19]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
- generic [ref=e24]: © 2025 Smooth Schedule Inc.
- generic [ref=e26]:
- generic [ref=e27]:
- heading "Welcome back" [level=2] [ref=e28]
- paragraph [ref=e29]: Please enter your details to sign in.
- generic [ref=e30]:
- generic [ref=e31]:
- generic [ref=e32]:
- generic [ref=e33]: Username
- generic [ref=e34]:
- generic:
- img
- textbox "Username" [active] [ref=e35]:
- /placeholder: Enter your username
- text: superuser
- generic [ref=e36]:
- generic [ref=e37]: Password
- generic [ref=e38]:
- generic:
- img
- textbox "Password" [ref=e39]:
- /placeholder: ••••••••
- button "Sign in" [ref=e40]:
- generic [ref=e41]:
- text: Sign in
- img [ref=e42]
- generic [ref=e49]: Or continue with
- button "🇺🇸 English" [ref=e52]:
- img [ref=e53]
- generic [ref=e56]: 🇺🇸
- generic [ref=e57]: English
- img [ref=e58]
- generic [ref=e60]:
- heading "🔓 Quick Login (Dev Only)" [level=3] [ref=e62]:
- generic [ref=e63]: 🔓
- generic [ref=e64]: Quick Login (Dev Only)
- generic [ref=e65]:
- button "Platform Superuser SUPERUSER" [ref=e66]:
- generic [ref=e67]:
- generic [ref=e68]: Platform Superuser
- generic [ref=e69]: SUPERUSER
- button "Platform Manager PLATFORM_MANAGER" [ref=e70]:
- generic [ref=e71]:
- generic [ref=e72]: Platform Manager
- generic [ref=e73]: PLATFORM_MANAGER
- button "Platform Sales PLATFORM_SALES" [ref=e74]:
- generic [ref=e75]:
- generic [ref=e76]: Platform Sales
- generic [ref=e77]: PLATFORM_SALES
- button "Platform Support PLATFORM_SUPPORT" [ref=e78]:
- generic [ref=e79]:
- generic [ref=e80]: Platform Support
- generic [ref=e81]: PLATFORM_SUPPORT
- button "Business Owner TENANT_OWNER" [ref=e82]:
- generic [ref=e83]:
- generic [ref=e84]: Business Owner
- generic [ref=e85]: TENANT_OWNER
- button "Business Manager TENANT_MANAGER" [ref=e86]:
- generic [ref=e87]:
- generic [ref=e88]: Business Manager
- generic [ref=e89]: TENANT_MANAGER
- button "Staff Member TENANT_STAFF" [ref=e90]:
- generic [ref=e91]:
- generic [ref=e92]: Staff Member
- generic [ref=e93]: TENANT_STAFF
- button "Customer CUSTOMER" [ref=e94]:
- generic [ref=e95]:
- generic [ref=e96]: Customer
- generic [ref=e97]: CUSTOMER
- generic [ref=e98]:
- text: "Password for all:"
- code [ref=e99]: test123
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 KiB

View File

@@ -0,0 +1,71 @@
# Page snapshot
```yaml
- generic [ref=e1]:
- generic [ref=e3]:
- generic [ref=e5]:
- button "Collapse sidebar" [ref=e6]:
- img [ref=e7]
- generic [ref=e13]:
- heading "Smooth Schedule" [level=1] [ref=e14]
- paragraph [ref=e15]: superuser
- navigation [ref=e16]:
- paragraph [ref=e17]: Operations
- link "Dashboard" [ref=e18] [cursor=pointer]:
- /url: /platform/dashboard
- img [ref=e19]
- generic [ref=e24]: Dashboard
- link "Businesses" [ref=e25] [cursor=pointer]:
- /url: /platform/businesses
- img [ref=e26]
- generic [ref=e30]: Businesses
- link "Users" [ref=e31] [cursor=pointer]:
- /url: /platform/users
- img [ref=e32]
- generic [ref=e37]: Users
- link "Support" [active] [ref=e38] [cursor=pointer]:
- /url: /platform/support
- img [ref=e39]
- generic [ref=e41]: Support
- paragraph [ref=e42]: System
- link "Staff" [ref=e43] [cursor=pointer]:
- /url: /platform/staff
- img [ref=e44]
- generic [ref=e46]: Staff
- link "Platform Settings" [ref=e47] [cursor=pointer]:
- /url: /platform/settings
- img [ref=e48]
- generic [ref=e51]: Platform Settings
- generic [ref=e52]:
- link "Help" [ref=e53] [cursor=pointer]:
- /url: /help/ticketing
- img [ref=e54]
- generic [ref=e57]: Help
- link "API Docs" [ref=e58] [cursor=pointer]:
- /url: /help/api
- img [ref=e59]
- generic [ref=e62]: API Docs
- generic [ref=e63]:
- banner [ref=e64]:
- generic [ref=e66]:
- img [ref=e67]
- generic [ref=e70]: smoothschedule.com
- generic [ref=e71]: /
- generic [ref=e72]: Admin Console
- generic [ref=e73]:
- button [ref=e74]:
- img [ref=e75]
- button "Open notifications" [ref=e78]:
- img [ref=e79]
- button "Super User Superuser SU" [ref=e83]:
- generic [ref=e84]:
- paragraph [ref=e85]: Super User
- paragraph [ref=e86]: Superuser
- generic [ref=e87]: SU
- img [ref=e88]
- main [ref=e90]:
- generic [ref=e91]:
- img [ref=e92]
- paragraph [ref=e94]: Error loading tickets
- generic [ref=e95]: $0k
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long

View File

@@ -22,7 +22,7 @@ export default defineConfig({
/* Shared settings for all the projects below */
use: {
/* Base URL for all tests */
baseURL: 'http://lvh.me:5174',
baseURL: 'http://lvh.me:5173',
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
@@ -52,7 +52,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://lvh.me:5174',
url: 'http://lvh.me:5173',
reuseExistingServer: !process.env.CI,
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,12 @@
# robots.txt - SmoothSchedule
# Currently blocking all crawlers - site not yet live
User-agent: *
Disallow: /
# When ready to go live, replace above with:
# User-agent: *
# Allow: /
# Disallow: /api/
# Disallow: /admin/
# Sitemap: https://smoothschedule.com/sitemap.xml

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://smoothschedule.com/</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://smoothschedule.com/features</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://smoothschedule.com/pricing</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://smoothschedule.com/about</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://smoothschedule.com/contact</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://smoothschedule.com/signup</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://smoothschedule.com/privacy</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://smoothschedule.com/terms</loc>
<lastmod>2024-12-04</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>

View File

@@ -2,19 +2,20 @@
* Main App Component - Integrated with Real API
*/
import React, { useState } from 'react';
import React, { useState, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
import { useCurrentBusiness } from './hooks/useBusiness';
import { useUpdateBusiness } from './hooks/useBusiness';
import { usePlanFeatures } from './hooks/usePlanFeatures';
import { setCookie } from './utils/cookies';
// Import Login Page
import LoginPage from './pages/LoginPage';
import MFAVerifyPage from './pages/MFAVerifyPage';
import OAuthCallback from './pages/OAuthCallback';
const LoginPage = React.lazy(() => import('./pages/LoginPage'));
const MFAVerifyPage = React.lazy(() => import('./pages/MFAVerifyPage'));
const OAuthCallback = React.lazy(() => import('./pages/OAuthCallback'));
// Import layouts
import BusinessLayout from './layouts/BusinessLayout';
@@ -23,51 +24,107 @@ import CustomerLayout from './layouts/CustomerLayout';
import MarketingLayout from './layouts/MarketingLayout';
// Import marketing pages
import HomePage from './pages/marketing/HomePage';
import FeaturesPage from './pages/marketing/FeaturesPage';
import PricingPage from './pages/marketing/PricingPage';
import AboutPage from './pages/marketing/AboutPage';
import ContactPage from './pages/marketing/ContactPage';
import SignupPage from './pages/marketing/SignupPage';
const HomePage = React.lazy(() => import('./pages/marketing/HomePage'));
const FeaturesPage = React.lazy(() => import('./pages/marketing/FeaturesPage'));
const PricingPage = React.lazy(() => import('./pages/marketing/PricingPage'));
const AboutPage = React.lazy(() => import('./pages/marketing/AboutPage'));
const ContactPage = React.lazy(() => import('./pages/marketing/ContactPage'));
const SignupPage = React.lazy(() => import('./pages/marketing/SignupPage'));
const PrivacyPolicyPage = React.lazy(() => import('./pages/marketing/PrivacyPolicyPage'));
const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfServicePage'));
// Import pages
import Dashboard from './pages/Dashboard';
import Scheduler from './pages/Scheduler';
import Customers from './pages/Customers';
import Settings from './pages/Settings';
import Payments from './pages/Payments';
import Resources from './pages/Resources';
import Services from './pages/Services';
import Staff from './pages/Staff';
import CustomerDashboard from './pages/customer/CustomerDashboard';
import CustomerSupport from './pages/customer/CustomerSupport';
import ResourceDashboard from './pages/resource/ResourceDashboard';
import BookingPage from './pages/customer/BookingPage';
import TrialExpired from './pages/TrialExpired';
import Upgrade from './pages/Upgrade';
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const StaffDashboard = React.lazy(() => import('./pages/StaffDashboard'));
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
const Customers = React.lazy(() => import('./pages/Customers'));
const Settings = React.lazy(() => import('./pages/Settings'));
const Payments = React.lazy(() => import('./pages/Payments'));
const Messages = React.lazy(() => import('./pages/Messages'));
const Resources = React.lazy(() => import('./pages/Resources'));
const Services = React.lazy(() => import('./pages/Services'));
const Staff = React.lazy(() => import('./pages/Staff'));
const TimeBlocks = React.lazy(() => import('./pages/TimeBlocks'));
const MyAvailability = React.lazy(() => import('./pages/MyAvailability'));
const CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDashboard'));
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
const CustomerBilling = React.lazy(() => import('./pages/customer/CustomerBilling'));
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
// Import platform pages
import PlatformDashboard from './pages/platform/PlatformDashboard';
import PlatformBusinesses from './pages/platform/PlatformBusinesses';
import PlatformSupportPage from './pages/platform/PlatformSupport';
import PlatformUsers from './pages/platform/PlatformUsers';
import PlatformStaff from './pages/platform/PlatformStaff';
import PlatformSettings from './pages/platform/PlatformSettings';
import ProfileSettings from './pages/ProfileSettings';
import VerifyEmail from './pages/VerifyEmail';
import EmailVerificationRequired from './pages/EmailVerificationRequired';
import AcceptInvitePage from './pages/AcceptInvitePage';
import TenantOnboardPage from './pages/TenantOnboardPage';
import Tickets from './pages/Tickets'; // Import Tickets page
import HelpGuide from './pages/HelpGuide'; // Import Platform Guide page
import HelpTicketing from './pages/HelpTicketing'; // Import Help page for ticketing
import HelpApiDocs from './pages/HelpApiDocs'; // Import API documentation page
import HelpPluginDocs from './pages/HelpPluginDocs'; // Import Plugin documentation page
import PlatformSupport from './pages/PlatformSupport'; // Import Platform Support page (for businesses to contact SmoothSchedule)
import PluginMarketplace from './pages/PluginMarketplace'; // Import Plugin Marketplace page
import MyPlugins from './pages/MyPlugins'; // Import My Plugins page
import Tasks from './pages/Tasks'; // Import Tasks page for scheduled plugin executions
import EmailTemplates from './pages/EmailTemplates'; // Import Email Templates page
const PlatformDashboard = React.lazy(() => import('./pages/platform/PlatformDashboard'));
const PlatformBusinesses = React.lazy(() => import('./pages/platform/PlatformBusinesses'));
const PlatformSupportPage = React.lazy(() => import('./pages/platform/PlatformSupport'));
const PlatformEmailAddresses = React.lazy(() => import('./pages/platform/PlatformEmailAddresses'));
const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers'));
const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'));
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage'));
const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page
const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platform Guide page
const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing
const HelpApiDocs = React.lazy(() => import('./pages/HelpApiDocs')); // Import API documentation page
const HelpPluginDocs = React.lazy(() => import('./pages/help/HelpPluginDocs')); // Import Plugin documentation page
const HelpEmailSettings = React.lazy(() => import('./pages/HelpEmailSettings')); // Import Email settings help page
// Import new help pages
const HelpDashboard = React.lazy(() => import('./pages/help/HelpDashboard'));
const HelpScheduler = React.lazy(() => import('./pages/help/HelpScheduler'));
const HelpTasks = React.lazy(() => import('./pages/help/HelpTasks'));
const HelpCustomers = React.lazy(() => import('./pages/help/HelpCustomers'));
const HelpServices = React.lazy(() => import('./pages/help/HelpServices'));
const HelpResources = React.lazy(() => import('./pages/help/HelpResources'));
const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff'));
const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks'));
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments'));
const HelpContracts = React.lazy(() => import('./pages/help/HelpContracts'));
const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins'));
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
const HelpSettingsBooking = React.lazy(() => import('./pages/help/HelpSettingsBooking'));
const HelpSettingsAppearance = React.lazy(() => import('./pages/help/HelpSettingsAppearance'));
const HelpSettingsEmail = React.lazy(() => import('./pages/help/HelpSettingsEmail'));
const HelpSettingsDomains = React.lazy(() => import('./pages/help/HelpSettingsDomains'));
const HelpSettingsApi = React.lazy(() => import('./pages/help/HelpSettingsApi'));
const HelpSettingsAuth = React.lazy(() => import('./pages/help/HelpSettingsAuth'));
const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling'));
const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
const GeneralSettings = React.lazy(() => import('./pages/settings/GeneralSettings'));
const BrandingSettings = React.lazy(() => import('./pages/settings/BrandingSettings'));
const ResourceTypesSettings = React.lazy(() => import('./pages/settings/ResourceTypesSettings'));
const BookingSettings = React.lazy(() => import('./pages/settings/BookingSettings'));
const CustomDomainsSettings = React.lazy(() => import('./pages/settings/CustomDomainsSettings'));
const ApiSettings = React.lazy(() => import('./pages/settings/ApiSettings'));
const AuthenticationSettings = React.lazy(() => import('./pages/settings/AuthenticationSettings'));
const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'));
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
const queryClient = new QueryClient({
@@ -140,6 +197,7 @@ const AppContent: React.FC = () => {
const updateBusinessMutation = useUpdateBusiness();
const masqueradeMutation = useMasquerade();
const logoutMutation = useLogout();
const { canUse } = usePlanFeatures();
// Apply dark mode class and persist to localStorage
React.useEffect(() => {
@@ -147,6 +205,30 @@ const AppContent: React.FC = () => {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
}, [darkMode]);
// Set noindex/nofollow for app subdomains (platform, business subdomains)
// Only the root domain marketing pages should be indexed
React.useEffect(() => {
const hostname = window.location.hostname;
const parts = hostname.split('.');
const hasSubdomain = parts.length > 2 || (parts.length === 2 && parts[0] !== 'localhost');
// Check if we're on a subdomain (platform.*, demo.*, etc.)
const isSubdomain = hostname !== 'localhost' && hostname !== '127.0.0.1' && parts.length > 2;
if (isSubdomain) {
// Always noindex/nofollow on subdomains (app areas)
let metaRobots = document.querySelector('meta[name="robots"]');
if (metaRobots) {
metaRobots.setAttribute('content', 'noindex, nofollow');
} else {
metaRobots = document.createElement('meta');
metaRobots.setAttribute('name', 'robots');
metaRobots.setAttribute('content', 'noindex, nofollow');
document.head.appendChild(metaRobots);
}
}
}, []);
// Handle tokens in URL (from login or masquerade redirect)
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -188,11 +270,6 @@ const AppContent: React.FC = () => {
setCookie('access_token', accessToken, 7);
setCookie('refresh_token', refreshToken, 7);
// Clear session cookie to prevent interference with JWT
// (Django session cookie might take precedence over JWT)
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.lvh.me';
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// Clean URL
const newUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, '', newUrl);
@@ -215,45 +292,100 @@ const AppContent: React.FC = () => {
// Helper to detect root domain (for marketing site)
const isRootDomain = (): boolean => {
const hostname = window.location.hostname;
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
// Root domain has no subdomain (just the base domain like smoothschedule.com or lvh.me)
const parts = hostname.split('.');
return hostname === 'localhost' || hostname === '127.0.0.1' || parts.length === 2;
};
// On root domain, ALWAYS show marketing site (even if logged in)
// Logged-in users will see a "Go to Dashboard" link in the navbar
if (isRootDomain()) {
return (
<Routes>
<Route element={<MarketingLayout user={user} />}>
<Route path="/" element={<HomePage />} />
<Route path="/features" element={<FeaturesPage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/signup" element={<SignupPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route element={<MarketingLayout user={user} />}>
<Route path="/" element={<HomePage />} />
<Route path="/features" element={<FeaturesPage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/terms" element={<TermsOfServicePage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
// Not authenticated on subdomain - show login
// Not authenticated - show appropriate page based on subdomain
if (!user) {
const currentHostname = window.location.hostname;
const hostnameParts = currentHostname.split('.');
const baseDomain = hostnameParts.length >= 2
? hostnameParts.slice(-2).join('.')
: currentHostname;
const isRootDomainForUnauthUser = currentHostname === baseDomain || currentHostname === 'localhost';
const isPlatformSubdomain = hostnameParts[0] === 'platform';
const currentSubdomain = hostnameParts[0];
// Check if we're on a business subdomain (not root, not platform, not api)
const isBusinessSubdomain = !isRootDomainForUnauthUser && !isPlatformSubdomain && currentSubdomain !== 'api';
// For business subdomains, show the tenant landing page with login option
if (isBusinessSubdomain) {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<TenantLandingPage subdomain={currentSubdomain} />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
// For root domain or platform subdomain, show marketing site / login
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route element={<MarketingLayout user={user} />}>
<Route path="/" element={<HomePage />} />
<Route path="/features" element={<FeaturesPage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/terms" element={<TermsOfServicePage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
@@ -264,38 +396,43 @@ const AppContent: React.FC = () => {
// Subdomain validation for logged-in users
const currentHostname = window.location.hostname;
const isPlatformDomain = currentHostname === 'platform.lvh.me';
const currentSubdomain = currentHostname.split('.')[0];
const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api';
const hostnameParts = currentHostname.split('.');
const baseDomain = hostnameParts.length >= 2
? hostnameParts.slice(-2).join('.')
: currentHostname;
const protocol = window.location.protocol;
const isPlatformDomain = currentHostname === `platform.${baseDomain}`;
const currentSubdomain = hostnameParts[0];
const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api' && currentHostname !== baseDomain;
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
const isCustomer = user.role === 'customer';
// RULE: Platform users must be on platform subdomain (not business subdomains)
// RULE: Platform users on business subdomains should be redirected to platform subdomain
if (isPlatformUser && isBusinessSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://platform.lvh.me${port}/`;
window.location.href = `${protocol}//platform.${baseDomain}${port}/`;
return <LoadingScreen />;
}
// RULE: Business users must be on their own business subdomain
if (isBusinessUser && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/`;
return <LoadingScreen />;
}
// RULE: Customers must be on their business subdomain
if (isCustomer && isPlatformDomain && user.business_subdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/`;
return <LoadingScreen />;
}
if (isCustomer && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
window.location.href = `${protocol}//${user.business_subdomain}.${baseDomain}${port}/`;
return <LoadingScreen />;
}
@@ -325,49 +462,53 @@ const AppContent: React.FC = () => {
if (isPlatformUser) {
return (
<Routes>
<Route
element={
<PlatformLayout
user={user}
darkMode={darkMode}
toggleTheme={toggleTheme}
onSignOut={handleSignOut}
/>
}
>
{(user.role === 'superuser' || user.role === 'platform_manager') && (
<>
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
<Route path="/platform/businesses" element={<PlatformBusinesses onMasquerade={handleMasquerade} />} />
<Route path="/platform/users" element={<PlatformUsers onMasquerade={handleMasquerade} />} />
<Route path="/platform/staff" element={<PlatformStaff />} />
</>
)}
<Route path="/platform/support" element={<PlatformSupportPage />} />
<Route path="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
<Route path="/help/plugins" element={<HelpPluginDocs />} />
{user.role === 'superuser' && (
<Route path="/platform/settings" element={<PlatformSettings />} />
)}
<Route path="/platform/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route
path="*"
element={
<Navigate
to={
user.role === 'superuser' || user.role === 'platform_manager'
? '/platform/dashboard'
: '/platform/support'
}
<PlatformLayout
user={user}
darkMode={darkMode}
toggleTheme={toggleTheme}
onSignOut={handleSignOut}
/>
}
/>
</Route>
</Routes>
>
{(user.role === 'superuser' || user.role === 'platform_manager') && (
<>
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
<Route path="/platform/businesses" element={<PlatformBusinesses onMasquerade={handleMasquerade} />} />
<Route path="/platform/users" element={<PlatformUsers onMasquerade={handleMasquerade} />} />
<Route path="/platform/staff" element={<PlatformStaff />} />
</>
)}
<Route path="/platform/support" element={<PlatformSupportPage />} />
<Route path="/platform/email-addresses" element={<PlatformEmailAddresses />} />
<Route path="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
<Route path="/help/plugins" element={<HelpPluginDocs />} />
<Route path="/help/email" element={<HelpEmailSettings />} />
{user.role === 'superuser' && (
<Route path="/platform/settings" element={<PlatformSettings />} />
)}
<Route path="/platform/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route
path="*"
element={
<Navigate
to={
user.role === 'superuser' || user.role === 'platform_manager'
? '/platform/dashboard'
: '/platform/support'
}
/>
}
/>
</Route>
</Routes>
</Suspense>
);
}
@@ -399,26 +540,28 @@ const AppContent: React.FC = () => {
}
return (
<Routes>
<Route
element={
<CustomerLayout
business={business}
user={user}
darkMode={darkMode}
toggleTheme={toggleTheme}
/>
}
>
<Route path="/" element={<CustomerDashboard />} />
<Route path="/book" element={<BookingPage />} />
<Route path="/payments" element={<Payments />} />
<Route path="/support" element={<CustomerSupport />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
</Routes>
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route
element={
<CustomerLayout
business={business}
user={user}
darkMode={darkMode}
toggleTheme={toggleTheme}
/>
}
>
<Route path="/" element={<CustomerDashboard />} />
<Route path="/book" element={<BookingPage />} />
<Route path="/payments" element={<CustomerBilling />} />
<Route path="/support" element={<CustomerSupport />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
</Routes>
</Suspense>
);
}
@@ -431,8 +574,7 @@ const AppContent: React.FC = () => {
if (businessError || !business) {
// If user has a business subdomain, redirect them there
if (user.business_subdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
window.location.href = buildSubdomainUrl(user.business_subdomain, '/');
return <LoadingScreen />;
}
@@ -468,11 +610,13 @@ const AppContent: React.FC = () => {
// Check if email verification is required
if (!user.email_verified) {
return (
<Routes>
<Route path="/email-verification-required" element={<EmailVerificationRequired />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/email-verification-required" replace />} />
</Routes>
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/email-verification-required" element={<EmailVerificationRequired />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/email-verification-required" replace />} />
</Routes>
</Suspense>
);
}
@@ -487,157 +631,270 @@ const AppContent: React.FC = () => {
// If trial expired and not on allowed route, redirect to trial-expired
if (isTrialExpired && !isOnAllowedRoute) {
return (
<Routes>
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route
path="/settings"
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/trial-expired" />}
/>
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
</Routes>
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Route path="/profile" element={<ProfileSettings />} />
{/* Trial-expired users can access billing settings to upgrade */}
<Route
path="/settings/*"
element={hasAccess(['owner']) ? <Navigate to="/upgrade" /> : <Navigate to="/trial-expired" />}
/>
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
</Routes>
</Suspense>
);
}
return (
<Routes>
<Route
element={
<BusinessLayout
business={business}
user={user}
darkMode={darkMode}
toggleTheme={toggleTheme}
onSignOut={handleSignOut}
updateBusiness={handleUpdateBusiness}
/>
}
>
{/* Trial and Upgrade Routes */}
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route
element={
<BusinessLayout
business={business}
user={user}
darkMode={darkMode}
toggleTheme={toggleTheme}
onSignOut={handleSignOut}
updateBusiness={handleUpdateBusiness}
/>
}
>
{/* Trial and Upgrade Routes */}
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
{/* Regular Routes */}
<Route
path="/"
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
<Route path="/help/plugins" element={<HelpPluginDocs />} />
<Route
path="/plugins/marketplace"
element={
hasAccess(['owner', 'manager']) ? (
<PluginMarketplace />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/plugins/my-plugins"
element={
hasAccess(['owner', 'manager']) ? (
<MyPlugins />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/tasks"
element={
hasAccess(['owner', 'manager']) ? (
<Tasks />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/email-templates"
element={
hasAccess(['owner', 'manager']) ? (
<EmailTemplates />
) : (
<Navigate to="/" />
)
}
/>
<Route path="/support" element={<PlatformSupport />} />
<Route
path="/customers"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/services"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Services />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/resources"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/staff"
element={
hasAccess(['owner', 'manager']) ? (
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/payments"
element={
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
}
/>
<Route
path="/messages"
element={
hasAccess(['owner', 'manager']) ? (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Messages</h1>
<p className="text-gray-600">Messages feature coming soon...</p>
</div>
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/settings"
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/" />}
/>
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
</Routes>
{/* Regular Routes */}
<Route
path="/"
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
/>
{/* Staff Schedule - vertical timeline view */}
<Route
path="/my-schedule"
element={
hasAccess(['staff']) ? (
<StaffSchedule user={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/tickets" element={<Tickets />} />
<Route
path="/help"
element={
user.role === 'staff' ? (
<StaffHelp user={user} />
) : (
<HelpComprehensive />
)
}
/>
<Route path="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
<Route path="/help/plugins/docs" element={<HelpPluginDocs />} />
<Route path="/help/email" element={<HelpEmailSettings />} />
{/* New help pages */}
<Route path="/help/dashboard" element={<HelpDashboard />} />
<Route path="/help/scheduler" element={<HelpScheduler />} />
<Route path="/help/tasks" element={<HelpTasks />} />
<Route path="/help/customers" element={<HelpCustomers />} />
<Route path="/help/services" element={<HelpServices />} />
<Route path="/help/resources" element={<HelpResources />} />
<Route path="/help/staff" element={<HelpStaff />} />
<Route path="/help/time-blocks" element={<HelpTimeBlocks />} />
<Route path="/help/messages" element={<HelpMessages />} />
<Route path="/help/payments" element={<HelpPayments />} />
<Route path="/help/contracts" element={<HelpContracts />} />
<Route path="/help/plugins" element={<HelpPlugins />} />
<Route path="/help/settings/general" element={<HelpSettingsGeneral />} />
<Route path="/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
<Route path="/help/settings/booking" element={<HelpSettingsBooking />} />
<Route path="/help/settings/appearance" element={<HelpSettingsAppearance />} />
<Route path="/help/settings/email" element={<HelpSettingsEmail />} />
<Route path="/help/settings/domains" element={<HelpSettingsDomains />} />
<Route path="/help/settings/api" element={<HelpSettingsApi />} />
<Route path="/help/settings/auth" element={<HelpSettingsAuth />} />
<Route path="/help/settings/billing" element={<HelpSettingsBilling />} />
<Route path="/help/settings/quota" element={<HelpSettingsQuota />} />
<Route
path="/plugins/marketplace"
element={
hasAccess(['owner', 'manager']) ? (
<PluginMarketplace />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/plugins/my-plugins"
element={
hasAccess(['owner', 'manager']) ? (
<MyPlugins />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/plugins/create"
element={
hasAccess(['owner', 'manager']) ? (
<CreatePlugin />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/tasks"
element={
hasAccess(['owner', 'manager']) ? (
<Tasks />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/email-templates"
element={
hasAccess(['owner', 'manager']) ? (
<EmailTemplates />
) : (
<Navigate to="/" />
)
}
/>
<Route path="/support" element={<PlatformSupport />} />
<Route
path="/customers"
element={
hasAccess(['owner', 'manager']) ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/services"
element={
hasAccess(['owner', 'manager']) ? (
<Services />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/resources"
element={
hasAccess(['owner', 'manager']) ? (
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/staff"
element={
hasAccess(['owner', 'manager']) ? (
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/time-blocks"
element={
hasAccess(['owner', 'manager']) ? (
<TimeBlocks />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/my-availability"
element={
hasAccess(['staff', 'resource']) ? (
<MyAvailability user={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/contracts"
element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
<Contracts />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/contracts/templates"
element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
<ContractTemplates />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/payments"
element={
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
}
/>
<Route
path="/messages"
element={
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
<Messages />
) : (
<Navigate to="/" />
)
}
/>
{/* Settings Routes with Nested Layout */}
{hasAccess(['owner']) ? (
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="/settings/general" replace />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="branding" element={<BrandingSettings />} />
<Route path="resource-types" element={<ResourceTypesSettings />} />
<Route path="booking" element={<BookingSettings />} />
<Route path="email-templates" element={<EmailTemplates />} />
<Route path="custom-domains" element={<CustomDomainsSettings />} />
<Route path="api" element={<ApiSettings />} />
<Route path="authentication" element={<AuthenticationSettings />} />
<Route path="email" element={<EmailSettings />} />
<Route path="sms-calling" element={<CommunicationSettings />} />
<Route path="billing" element={<BillingSettings />} />
<Route path="quota" element={<QuotaSettings />} />
</Route>
) : (
<Route path="/settings/*" element={<Navigate to="/" />} />
)}
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
</Routes>
</Suspense>
);
}

View File

@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
post: vi.fn(),
get: vi.fn(),
},
}));
import {
login,
logout,
getCurrentUser,
refreshToken,
masquerade,
stopMasquerade,
} from '../auth';
import apiClient from '../client';
describe('auth API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('login', () => {
it('sends credentials to login endpoint', async () => {
const mockResponse = {
data: {
access: 'access-token',
refresh: 'refresh-token',
user: { id: 1, email: 'test@example.com' },
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await login({ email: 'test@example.com', password: 'password' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/login/', {
email: 'test@example.com',
password: 'password',
});
expect(result).toEqual(mockResponse.data);
});
it('returns MFA required response', async () => {
const mockResponse = {
data: {
mfa_required: true,
user_id: 1,
mfa_methods: ['TOTP', 'SMS'],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await login({ email: 'test@example.com', password: 'password' });
expect(result.mfa_required).toBe(true);
expect(result.mfa_methods).toContain('TOTP');
});
});
describe('logout', () => {
it('calls logout endpoint', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await logout();
expect(apiClient.post).toHaveBeenCalledWith('/auth/logout/');
});
});
describe('getCurrentUser', () => {
it('fetches current user from API', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
name: 'Test User',
role: 'owner',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUser });
const result = await getCurrentUser();
expect(apiClient.get).toHaveBeenCalledWith('/auth/me/');
expect(result).toEqual(mockUser);
});
});
describe('refreshToken', () => {
it('sends refresh token to API', async () => {
const mockResponse = { data: { access: 'new-access-token' } };
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await refreshToken('old-refresh-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/refresh/', {
refresh: 'old-refresh-token',
});
expect(result.access).toBe('new-access-token');
});
});
describe('masquerade', () => {
it('sends masquerade request with user_pk', async () => {
const mockResponse = {
data: {
access: 'masq-access',
refresh: 'masq-refresh',
user: { id: 2, email: 'other@example.com' },
masquerade_stack: [{ user_id: 1, username: 'admin', role: 'superuser' }],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await masquerade(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/acquire/', {
user_pk: 2,
hijack_history: undefined,
});
expect(result.masquerade_stack).toHaveLength(1);
});
it('sends masquerade request with history', async () => {
const history = [{ user_id: 1, username: 'admin', role: 'superuser' as const }];
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await masquerade(2, history);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/acquire/', {
user_pk: 2,
hijack_history: history,
});
});
});
describe('stopMasquerade', () => {
it('sends release request with masquerade stack', async () => {
const stack = [{ user_id: 1, username: 'admin', role: 'superuser' as const }];
const mockResponse = {
data: {
access: 'orig-access',
refresh: 'orig-refresh',
user: { id: 1 },
masquerade_stack: [],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await stopMasquerade(stack);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/release/', {
masquerade_stack: stack,
});
expect(result.masquerade_stack).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,212 @@
/**
* Tests for Billing API client functions
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import apiClient from '../client';
import {
getEntitlements,
getCurrentSubscription,
getPlans,
getAddOns,
getInvoices,
getInvoice,
Entitlements,
Subscription,
PlanVersion,
AddOnProduct,
Invoice,
} from '../billing';
// Mock the API client
vi.mock('../client', () => ({
default: {
get: vi.fn(),
},
}));
describe('Billing API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getEntitlements', () => {
it('fetches entitlements from /api/me/entitlements/', async () => {
const mockEntitlements: Entitlements = {
can_use_sms_reminders: true,
can_use_mobile_app: false,
max_users: 10,
max_resources: 25,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockEntitlements });
const result = await getEntitlements();
expect(apiClient.get).toHaveBeenCalledWith('/me/entitlements/');
expect(result).toEqual(mockEntitlements);
});
it('returns empty object on error', async () => {
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('Network error'));
const result = await getEntitlements();
expect(result).toEqual({});
});
});
describe('getCurrentSubscription', () => {
it('fetches subscription from /api/me/subscription/', async () => {
const mockSubscription: Subscription = {
id: 1,
status: 'active',
plan_version: {
id: 10,
name: 'Pro Plan v1',
is_legacy: false,
plan: { code: 'pro', name: 'Pro' },
price_monthly_cents: 7900,
price_yearly_cents: 79000,
},
current_period_start: '2024-01-01T00:00:00Z',
current_period_end: '2024-02-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockSubscription });
const result = await getCurrentSubscription();
expect(apiClient.get).toHaveBeenCalledWith('/me/subscription/');
expect(result).toEqual(mockSubscription);
});
it('returns null when no subscription (404)', async () => {
const error = { response: { status: 404 } };
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
const result = await getCurrentSubscription();
expect(result).toBeNull();
});
});
describe('getPlans', () => {
it('fetches public plans from /api/billing/plans/', async () => {
const mockPlans: PlanVersion[] = [
{
id: 1,
name: 'Free Plan',
is_legacy: false,
is_public: true,
plan: { code: 'free', name: 'Free' },
price_monthly_cents: 0,
price_yearly_cents: 0,
},
{
id: 2,
name: 'Pro Plan',
is_legacy: false,
is_public: true,
plan: { code: 'pro', name: 'Pro' },
price_monthly_cents: 7900,
price_yearly_cents: 79000,
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockPlans });
const result = await getPlans();
expect(apiClient.get).toHaveBeenCalledWith('/billing/plans/');
expect(result).toEqual(mockPlans);
expect(result).toHaveLength(2);
});
});
describe('getAddOns', () => {
it('fetches active add-ons from /api/billing/addons/', async () => {
const mockAddOns: AddOnProduct[] = [
{
id: 1,
code: 'sms_pack',
name: 'SMS Pack',
price_monthly_cents: 500,
is_active: true,
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockAddOns });
const result = await getAddOns();
expect(apiClient.get).toHaveBeenCalledWith('/billing/addons/');
expect(result).toEqual(mockAddOns);
});
});
describe('getInvoices', () => {
it('fetches invoices from /api/billing/invoices/', async () => {
const mockInvoices: Invoice[] = [
{
id: 1,
status: 'paid',
period_start: '2024-01-01T00:00:00Z',
period_end: '2024-02-01T00:00:00Z',
subtotal_amount: 7900,
total_amount: 7900,
plan_name_at_billing: 'Pro Plan',
created_at: '2024-01-01T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoices });
const result = await getInvoices();
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/');
expect(result).toEqual(mockInvoices);
});
});
describe('getInvoice', () => {
it('fetches a single invoice by ID', async () => {
const mockInvoice: Invoice = {
id: 1,
status: 'paid',
period_start: '2024-01-01T00:00:00Z',
period_end: '2024-02-01T00:00:00Z',
subtotal_amount: 7900,
total_amount: 7900,
plan_name_at_billing: 'Pro Plan',
created_at: '2024-01-01T00:00:00Z',
lines: [
{
id: 1,
line_type: 'plan',
description: 'Pro Plan',
quantity: 1,
unit_amount: 7900,
total_amount: 7900,
},
],
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoice });
const result = await getInvoice(1);
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/1/');
expect(result).toEqual(mockInvoice);
});
it('returns null when invoice not found (404)', async () => {
const error = { response: { status: 404 } };
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
const result = await getInvoice(999);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,632 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getResources,
getBusinessUsers,
getBusinessOAuthSettings,
updateBusinessOAuthSettings,
getBusinessOAuthCredentials,
updateBusinessOAuthCredentials,
} from '../business';
import apiClient from '../client';
describe('business API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getResources', () => {
it('fetches all resources from API', async () => {
const mockResources = [
{
id: '1',
name: 'Resource 1',
type: 'STAFF',
maxConcurrentEvents: 1,
},
{
id: '2',
name: 'Resource 2',
type: 'EQUIPMENT',
maxConcurrentEvents: 3,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResources });
const result = await getResources();
expect(apiClient.get).toHaveBeenCalledWith('/resources/');
expect(result).toEqual(mockResources);
});
it('returns empty array when no resources exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getResources();
expect(result).toEqual([]);
});
});
describe('getBusinessUsers', () => {
it('fetches all business users from API', async () => {
const mockUsers = [
{
id: '1',
email: 'owner@example.com',
name: 'Business Owner',
role: 'owner',
},
{
id: '2',
email: 'staff@example.com',
name: 'Staff Member',
role: 'staff',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getBusinessUsers();
expect(apiClient.get).toHaveBeenCalledWith('/business/users/');
expect(result).toEqual(mockUsers);
});
it('returns empty array when no users exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinessUsers();
expect(result).toEqual([]);
});
});
describe('getBusinessOAuthSettings', () => {
it('fetches OAuth settings and transforms snake_case to camelCase', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(apiClient.get).toHaveBeenCalledWith('/business/oauth-settings/');
expect(result).toEqual({
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
});
});
it('handles empty enabled providers array', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.settings.enabledProviders).toEqual([]);
expect(result.availableProviders).toEqual([]);
});
it('handles undefined enabled_providers by using empty array', async () => {
const mockBackendResponse = {
settings: {
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.settings.enabledProviders).toEqual([]);
});
it('handles undefined available_providers by using empty array', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: ['google'],
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.availableProviders).toEqual([]);
});
});
describe('updateBusinessOAuthSettings', () => {
it('updates OAuth settings and transforms camelCase to snake_case', async () => {
const frontendSettings = {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthSettings(frontendSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
});
expect(result).toEqual({
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
});
});
it('sends only provided fields to backend', async () => {
const partialSettings = {
enabledProviders: ['google'],
};
const mockBackendResponse = {
settings: {
enabled_providers: ['google'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
enabled_providers: ['google'],
});
});
it('handles updating only allowRegistration', async () => {
const partialSettings = {
allowRegistration: false,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
allow_registration: false,
});
});
it('handles updating only autoLinkByEmail', async () => {
const partialSettings = {
autoLinkByEmail: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
auto_link_by_email: true,
});
});
it('handles updating only useCustomCredentials', async () => {
const partialSettings = {
useCustomCredentials: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
use_custom_credentials: true,
});
});
it('handles boolean false values correctly', async () => {
const settings = {
allowRegistration: false,
autoLinkByEmail: false,
useCustomCredentials: false,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(settings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
});
});
it('does not send undefined fields', async () => {
const settings = {};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(settings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {});
});
});
describe('getBusinessOAuthCredentials', () => {
it('fetches OAuth credentials from API', async () => {
const mockBackendResponse = {
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id',
client_secret: '',
has_secret: false,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(apiClient.get).toHaveBeenCalledWith('/business/oauth-credentials/');
expect(result).toEqual({
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: true,
});
});
it('handles empty credentials object', async () => {
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(result.credentials).toEqual({});
expect(result.useCustomCredentials).toBe(false);
});
it('handles undefined credentials by using empty object', async () => {
const mockBackendResponse = {
use_custom_credentials: false,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(result.credentials).toEqual({});
});
});
describe('updateBusinessOAuthCredentials', () => {
it('updates OAuth credentials', async () => {
const credentials = {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
useCustomCredentials: true,
};
const mockBackendResponse = {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
has_secret: true,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(credentials);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
use_custom_credentials: true,
});
expect(result).toEqual({
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
});
});
it('updates only credentials without useCustomCredentials', async () => {
const data = {
credentials: {
microsoft: {
client_id: 'microsoft-id',
},
},
};
const mockBackendResponse = {
credentials: {
microsoft: {
client_id: 'microsoft-id',
client_secret: '',
has_secret: false,
},
},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
microsoft: {
client_id: 'microsoft-id',
},
},
});
});
it('updates only useCustomCredentials without credentials', async () => {
const data = {
useCustomCredentials: false,
};
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
use_custom_credentials: false,
});
});
it('handles partial credential updates', async () => {
const data = {
credentials: {
google: {
client_id: 'updated-id',
},
microsoft: {
client_secret: 'updated-secret',
},
},
};
const mockBackendResponse = {
credentials: {
google: {
client_id: 'updated-id',
client_secret: 'existing-secret',
has_secret: true,
},
microsoft: {
client_id: 'existing-id',
client_secret: 'updated-secret',
has_secret: true,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
google: {
client_id: 'updated-id',
},
microsoft: {
client_secret: 'updated-secret',
},
},
});
expect(result.credentials.google.client_id).toBe('updated-id');
expect(result.credentials.microsoft.client_secret).toBe('updated-secret');
});
it('handles empty data object', async () => {
const data = {};
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {});
});
it('handles undefined credentials in response by using empty object', async () => {
const data = {
useCustomCredentials: true,
};
const mockBackendResponse = {
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(data);
expect(result.credentials).toEqual({});
});
});
});

View File

@@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import axios from 'axios';
// Mock dependencies
vi.mock('../../utils/cookies', () => ({
getCookie: vi.fn(),
setCookie: vi.fn(),
deleteCookie: vi.fn(),
}));
vi.mock('../../utils/domain', () => ({
getBaseDomain: vi.fn(() => 'lvh.me'),
}));
vi.mock('../config', () => ({
API_BASE_URL: 'http://api.lvh.me:8000',
getSubdomain: vi.fn(),
}));
describe('api/client', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
describe('request interceptor', () => {
it('adds auth token from cookie when available', async () => {
const cookies = await import('../../utils/cookies');
const config = await import('../config');
vi.mocked(cookies.getCookie).mockReturnValue('test-token-123');
vi.mocked(config.getSubdomain).mockReturnValue(null);
// Re-import client to apply mocks
vi.resetModules();
// Mock the interceptors
const mockConfig = {
headers: {} as Record<string, string>,
};
// Simulate what the request interceptor does
const token = cookies.getCookie('access_token');
if (token) {
mockConfig.headers['Authorization'] = `Token ${token}`;
}
expect(mockConfig.headers['Authorization']).toBe('Token test-token-123');
});
it('does not add auth header when no token', async () => {
const cookies = await import('../../utils/cookies');
vi.mocked(cookies.getCookie).mockReturnValue(null);
const mockConfig = {
headers: {} as Record<string, string>,
};
const token = cookies.getCookie('access_token');
if (token) {
mockConfig.headers['Authorization'] = `Token ${token}`;
}
expect(mockConfig.headers['Authorization']).toBeUndefined();
});
it('adds business subdomain header when on business site', async () => {
const config = await import('../config');
vi.mocked(config.getSubdomain).mockReturnValue('demo');
const mockConfig = {
headers: {} as Record<string, string>,
};
const subdomain = config.getSubdomain();
if (subdomain && subdomain !== 'platform') {
mockConfig.headers['X-Business-Subdomain'] = subdomain;
}
expect(mockConfig.headers['X-Business-Subdomain']).toBe('demo');
});
it('does not add subdomain header on platform site', async () => {
const config = await import('../config');
vi.mocked(config.getSubdomain).mockReturnValue('platform');
const mockConfig = {
headers: {} as Record<string, string>,
};
const subdomain = config.getSubdomain();
if (subdomain && subdomain !== 'platform') {
mockConfig.headers['X-Business-Subdomain'] = subdomain;
}
expect(mockConfig.headers['X-Business-Subdomain']).toBeUndefined();
});
it('adds sandbox mode header when in test mode', async () => {
// Set sandbox mode in localStorage
window.localStorage.setItem('sandbox_mode', 'true');
const mockConfig = {
headers: {} as Record<string, string>,
};
// Simulate the getSandboxMode logic
let isSandbox = false;
try {
isSandbox = window.localStorage.getItem('sandbox_mode') === 'true';
} catch {
isSandbox = false;
}
if (isSandbox) {
mockConfig.headers['X-Sandbox-Mode'] = 'true';
}
expect(mockConfig.headers['X-Sandbox-Mode']).toBe('true');
});
it('does not add sandbox header when not in test mode', async () => {
localStorage.removeItem('sandbox_mode');
const mockConfig = {
headers: {} as Record<string, string>,
};
const isSandbox = localStorage.getItem('sandbox_mode') === 'true';
if (isSandbox) {
mockConfig.headers['X-Sandbox-Mode'] = 'true';
}
expect(mockConfig.headers['X-Sandbox-Mode']).toBeUndefined();
});
});
describe('getSandboxMode', () => {
it('returns false when localStorage throws', () => {
// Simulate localStorage throwing (e.g., in private browsing)
const originalGetItem = localStorage.getItem;
localStorage.getItem = () => {
throw new Error('Access denied');
};
// Function should return false on error
let result = false;
try {
result = localStorage.getItem('sandbox_mode') === 'true';
} catch {
result = false;
}
expect(result).toBe(false);
localStorage.getItem = originalGetItem;
});
it('returns false when sandbox_mode is not set', () => {
localStorage.removeItem('sandbox_mode');
const result = localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(false);
});
it('returns true when sandbox_mode is "true"', () => {
window.localStorage.setItem('sandbox_mode', 'true');
const result = window.localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(true);
});
it('returns false when sandbox_mode is "false"', () => {
localStorage.setItem('sandbox_mode', 'false');
const result = localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock the domain module before importing config
vi.mock('../../utils/domain', () => ({
getBaseDomain: vi.fn(),
isRootDomain: vi.fn(),
}));
// Helper to mock window.location
const mockLocation = (hostname: string, protocol = 'https:', port = '') => {
Object.defineProperty(window, 'location', {
value: {
hostname,
protocol,
port,
},
writable: true,
});
};
describe('api/config', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
// Clear any env vars
delete (import.meta as unknown as { env: Record<string, unknown> }).env.VITE_API_URL;
});
describe('getSubdomain', () => {
it('returns null for root domain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(true);
mockLocation('lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBeNull();
});
it('returns subdomain for business site', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('demo.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('demo');
});
it('returns null for platform subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('platform.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBeNull();
});
it('returns subdomain for www', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('www.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('www');
});
it('returns subdomain for api', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('api.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('api');
});
it('handles production business subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('acme-corp.smoothschedule.com');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('acme-corp');
});
});
describe('isPlatformSite', () => {
it('returns true for platform subdomain', async () => {
mockLocation('platform.lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(true);
});
it('returns true for platform in production', async () => {
mockLocation('platform.smoothschedule.com');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(true);
});
it('returns false for business subdomain', async () => {
mockLocation('demo.lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(false);
});
it('returns false for root domain', async () => {
mockLocation('lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(false);
});
});
describe('isBusinessSite', () => {
it('returns true for business subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('demo.lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(true);
});
it('returns false for platform site', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('platform.lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(false);
});
it('returns false for root domain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(true);
mockLocation('lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(false);
});
});
});

View File

@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getCustomDomains,
addCustomDomain,
deleteCustomDomain,
verifyCustomDomain,
setPrimaryDomain,
} from '../customDomains';
import apiClient from '../client';
describe('customDomains API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getCustomDomains', () => {
it('fetches all custom domains for the current business', async () => {
const mockDomains = [
{
id: 1,
domain: 'example.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
verified_at: '2024-01-02T00:00:00Z',
},
{
id: 2,
domain: 'custom.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token456',
dns_txt_record: 'smoothschedule-verify=token456',
dns_txt_record_name: '_smoothschedule.custom.com',
created_at: '2024-01-03T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomains });
const result = await getCustomDomains();
expect(apiClient.get).toHaveBeenCalledWith('/business/domains/');
expect(result).toEqual(mockDomains);
expect(result).toHaveLength(2);
});
it('returns empty array when no domains exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getCustomDomains();
expect(apiClient.get).toHaveBeenCalledWith('/business/domains/');
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('addCustomDomain', () => {
it('adds a new custom domain with lowercase and trimmed domain', async () => {
const mockDomain = {
id: 1,
domain: 'example.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await addCustomDomain('Example.com');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'example.com',
});
expect(result).toEqual(mockDomain);
});
it('transforms domain to lowercase before sending', async () => {
const mockDomain = {
id: 1,
domain: 'uppercase.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.uppercase.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain('UPPERCASE.COM');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'uppercase.com',
});
});
it('trims whitespace from domain before sending', async () => {
const mockDomain = {
id: 1,
domain: 'trimmed.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.trimmed.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain(' trimmed.com ');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'trimmed.com',
});
});
it('transforms domain with both uppercase and whitespace', async () => {
const mockDomain = {
id: 1,
domain: 'mixed.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.mixed.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain(' MiXeD.COM ');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'mixed.com',
});
});
});
describe('deleteCustomDomain', () => {
it('deletes a custom domain by ID', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteCustomDomain(1);
expect(apiClient.delete).toHaveBeenCalledWith('/business/domains/1/');
});
it('returns void on successful deletion', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await deleteCustomDomain(42);
expect(result).toBeUndefined();
});
});
describe('verifyCustomDomain', () => {
it('verifies a custom domain and returns verification status', async () => {
const mockResponse = {
verified: true,
message: 'Domain verified successfully',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyCustomDomain(1);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/1/verify/');
expect(result).toEqual(mockResponse);
expect(result.verified).toBe(true);
expect(result.message).toBe('Domain verified successfully');
});
it('returns failure status when verification fails', async () => {
const mockResponse = {
verified: false,
message: 'DNS records not found. Please check your configuration.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyCustomDomain(2);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/2/verify/');
expect(result.verified).toBe(false);
expect(result.message).toContain('DNS records not found');
});
it('handles different domain IDs correctly', async () => {
const mockResponse = {
verified: true,
message: 'Success',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await verifyCustomDomain(999);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/999/verify/');
});
});
describe('setPrimaryDomain', () => {
it('sets a custom domain as primary', async () => {
const mockDomain = {
id: 1,
domain: 'example.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
verified_at: '2024-01-02T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await setPrimaryDomain(1);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/1/set-primary/');
expect(result).toEqual(mockDomain);
expect(result.is_primary).toBe(true);
});
it('returns updated domain with is_primary flag', async () => {
const mockDomain = {
id: 5,
domain: 'newprimary.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token789',
dns_txt_record: 'smoothschedule-verify=token789',
dns_txt_record_name: '_smoothschedule.newprimary.com',
created_at: '2024-01-05T00:00:00Z',
verified_at: '2024-01-06T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await setPrimaryDomain(5);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/5/set-primary/');
expect(result.id).toBe(5);
expect(result.domain).toBe('newprimary.com');
expect(result.is_primary).toBe(true);
});
});
});

View File

@@ -0,0 +1,649 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
searchDomains,
getDomainPrices,
registerDomain,
getRegisteredDomains,
getDomainRegistration,
updateNameservers,
toggleAutoRenew,
renewDomain,
syncDomain,
getSearchHistory,
DomainAvailability,
DomainPrice,
DomainRegisterRequest,
DomainRegistration,
DomainSearchHistory,
} from '../domains';
import apiClient from '../client';
describe('domains API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('searchDomains', () => {
it('searches for domains with default TLDs', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'example.com',
available: true,
price: 12.99,
premium: false,
premium_price: null,
},
{
domain: 'example.net',
available: false,
price: null,
premium: false,
premium_price: null,
},
{
domain: 'example.org',
available: true,
price: 14.99,
premium: false,
premium_price: null,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('example');
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/search/', {
query: 'example',
tlds: ['.com', '.net', '.org'],
});
expect(result).toEqual(mockResults);
expect(result).toHaveLength(3);
});
it('searches for domains with custom TLDs', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'mybusiness.io',
available: true,
price: 39.99,
premium: false,
premium_price: null,
},
{
domain: 'mybusiness.dev',
available: true,
price: 12.99,
premium: false,
premium_price: null,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('mybusiness', ['.io', '.dev']);
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/search/', {
query: 'mybusiness',
tlds: ['.io', '.dev'],
});
expect(result).toEqual(mockResults);
});
it('handles premium domain results', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'premium.com',
available: true,
price: 12.99,
premium: true,
premium_price: 5000.0,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('premium');
expect(result[0].premium).toBe(true);
expect(result[0].premium_price).toBe(5000.0);
});
});
describe('getDomainPrices', () => {
it('fetches domain prices for all TLDs', async () => {
const mockPrices: DomainPrice[] = [
{
tld: '.com',
registration: 12.99,
renewal: 14.99,
transfer: 12.99,
},
{
tld: '.net',
registration: 14.99,
renewal: 16.99,
transfer: 14.99,
},
{
tld: '.org',
registration: 14.99,
renewal: 16.99,
transfer: 14.99,
},
{
tld: '.io',
registration: 39.99,
renewal: 39.99,
transfer: 39.99,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPrices });
const result = await getDomainPrices();
expect(apiClient.get).toHaveBeenCalledWith('/domains/search/prices/');
expect(result).toEqual(mockPrices);
expect(result).toHaveLength(4);
});
});
describe('registerDomain', () => {
it('registers a new domain with full contact information', async () => {
const registerRequest: DomainRegisterRequest = {
domain: 'newbusiness.com',
years: 2,
whois_privacy: true,
auto_renew: true,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
contact: {
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
phone: '+1.5551234567',
address: '123 Main St',
city: 'New York',
state: 'NY',
zip_code: '10001',
country: 'US',
},
auto_configure: true,
};
const mockRegistration: DomainRegistration = {
id: 1,
domain: 'newbusiness.com',
status: 'pending',
registered_at: null,
expires_at: null,
auto_renew: true,
whois_privacy: true,
purchase_price: 25.98,
renewal_price: null,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-15T10:00:00Z',
registrant_first_name: 'John',
registrant_last_name: 'Doe',
registrant_email: 'john@example.com',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRegistration });
const result = await registerDomain(registerRequest);
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/register/', registerRequest);
expect(result).toEqual(mockRegistration);
expect(result.status).toBe('pending');
});
it('registers domain without optional nameservers', async () => {
const registerRequest: DomainRegisterRequest = {
domain: 'simple.com',
years: 1,
whois_privacy: false,
auto_renew: false,
contact: {
first_name: 'Jane',
last_name: 'Smith',
email: 'jane@example.com',
phone: '+1.5559876543',
address: '456 Oak Ave',
city: 'Boston',
state: 'MA',
zip_code: '02101',
country: 'US',
},
auto_configure: false,
};
const mockRegistration: DomainRegistration = {
id: 2,
domain: 'simple.com',
status: 'pending',
registered_at: null,
expires_at: null,
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: null,
nameservers: [],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-15T10:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRegistration });
const result = await registerDomain(registerRequest);
expect(result.whois_privacy).toBe(false);
expect(result.auto_renew).toBe(false);
expect(result.nameservers).toEqual([]);
});
});
describe('getRegisteredDomains', () => {
it('fetches all registered domains for current business', async () => {
const mockDomains: DomainRegistration[] = [
{
id: 1,
domain: 'business1.com',
status: 'active',
registered_at: '2023-01-15T10:00:00Z',
expires_at: '2025-01-15T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 365,
is_expiring_soon: false,
created_at: '2023-01-15T09:00:00Z',
},
{
id: 2,
domain: 'business2.net',
status: 'active',
registered_at: '2024-01-01T10:00:00Z',
expires_at: '2024-03-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 14.99,
renewal_price: 16.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 30,
is_expiring_soon: true,
created_at: '2024-01-01T09:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomains });
const result = await getRegisteredDomains();
expect(apiClient.get).toHaveBeenCalledWith('/domains/registrations/');
expect(result).toEqual(mockDomains);
expect(result).toHaveLength(2);
expect(result[1].is_expiring_soon).toBe(true);
});
it('handles empty domain list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getRegisteredDomains();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('getDomainRegistration', () => {
it('fetches a single domain registration by ID', async () => {
const mockDomain: DomainRegistration = {
id: 5,
domain: 'example.com',
status: 'active',
registered_at: '2023-06-01T10:00:00Z',
expires_at: '2025-06-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com', 'ns3.digitalocean.com'],
days_until_expiry: 500,
is_expiring_soon: false,
created_at: '2023-06-01T09:30:00Z',
registrant_first_name: 'Alice',
registrant_last_name: 'Johnson',
registrant_email: 'alice@example.com',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomain });
const result = await getDomainRegistration(5);
expect(apiClient.get).toHaveBeenCalledWith('/domains/registrations/5/');
expect(result).toEqual(mockDomain);
expect(result.registrant_email).toBe('alice@example.com');
});
it('fetches domain with failed status', async () => {
const mockDomain: DomainRegistration = {
id: 10,
domain: 'failed.com',
status: 'failed',
registered_at: null,
expires_at: null,
auto_renew: false,
whois_privacy: false,
purchase_price: null,
renewal_price: null,
nameservers: [],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-10T10:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomain });
const result = await getDomainRegistration(10);
expect(result.status).toBe('failed');
expect(result.registered_at).toBeNull();
});
});
describe('updateNameservers', () => {
it('updates nameservers for a domain', async () => {
const nameservers = [
'ns1.customdns.com',
'ns2.customdns.com',
'ns3.customdns.com',
'ns4.customdns.com',
];
const mockUpdated: DomainRegistration = {
id: 3,
domain: 'updated.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: nameservers,
days_until_expiry: 100,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await updateNameservers(3, nameservers);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/3/update_nameservers/', {
nameservers: nameservers,
});
expect(result.nameservers).toEqual(nameservers);
expect(result.nameservers).toHaveLength(4);
});
it('updates to default DigitalOcean nameservers', async () => {
const nameservers = ['ns1.digitalocean.com', 'ns2.digitalocean.com', 'ns3.digitalocean.com'];
const mockUpdated: DomainRegistration = {
id: 7,
domain: 'reset.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: nameservers,
days_until_expiry: 200,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await updateNameservers(7, nameservers);
expect(result.nameservers).toEqual(nameservers);
});
});
describe('toggleAutoRenew', () => {
it('enables auto-renewal for a domain', async () => {
const mockUpdated: DomainRegistration = {
id: 4,
domain: 'autorenew.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 150,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await toggleAutoRenew(4, true);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/4/toggle_auto_renew/', {
auto_renew: true,
});
expect(result.auto_renew).toBe(true);
});
it('disables auto-renewal for a domain', async () => {
const mockUpdated: DomainRegistration = {
id: 6,
domain: 'noautorenew.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 60,
is_expiring_soon: true,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await toggleAutoRenew(6, false);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/6/toggle_auto_renew/', {
auto_renew: false,
});
expect(result.auto_renew).toBe(false);
});
});
describe('renewDomain', () => {
it('renews domain for 1 year (default)', async () => {
const mockRenewed: DomainRegistration = {
id: 8,
domain: 'renew.com',
status: 'active',
registered_at: '2022-01-01T10:00:00Z',
expires_at: '2025-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 365,
is_expiring_soon: false,
created_at: '2022-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(8);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/8/renew/', {
years: 1,
});
expect(result).toEqual(mockRenewed);
});
it('renews domain for multiple years', async () => {
const mockRenewed: DomainRegistration = {
id: 9,
domain: 'longterm.com',
status: 'active',
registered_at: '2022-01-01T10:00:00Z',
expires_at: '2027-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 1095,
is_expiring_soon: false,
created_at: '2022-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(9, 5);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/9/renew/', {
years: 5,
});
expect(result).toEqual(mockRenewed);
});
it('renews domain for 2 years', async () => {
const mockRenewed: DomainRegistration = {
id: 11,
domain: 'twoyears.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2026-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 730,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(11, 2);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/11/renew/', {
years: 2,
});
expect(result.expires_at).toBe('2026-01-01T10:00:00Z');
});
});
describe('syncDomain', () => {
it('syncs domain information from NameSilo', async () => {
const mockSynced: DomainRegistration = {
id: 12,
domain: 'synced.com',
status: 'active',
registered_at: '2023-05-15T10:00:00Z',
expires_at: '2024-05-15T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.namesilo.com', 'ns2.namesilo.com'],
days_until_expiry: 120,
is_expiring_soon: false,
created_at: '2023-05-15T09:30:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSynced });
const result = await syncDomain(12);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/12/sync/');
expect(result).toEqual(mockSynced);
});
it('syncs domain and updates status', async () => {
const mockSynced: DomainRegistration = {
id: 13,
domain: 'expired.com',
status: 'expired',
registered_at: '2020-01-01T10:00:00Z',
expires_at: '2023-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: [],
days_until_expiry: -365,
is_expiring_soon: false,
created_at: '2020-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSynced });
const result = await syncDomain(13);
expect(result.status).toBe('expired');
expect(result.days_until_expiry).toBeLessThan(0);
});
});
describe('getSearchHistory', () => {
it('fetches domain search history', async () => {
const mockHistory: DomainSearchHistory[] = [
{
id: 1,
searched_domain: 'example.com',
was_available: true,
price: 12.99,
searched_at: '2024-01-15T10:00:00Z',
},
{
id: 2,
searched_domain: 'taken.com',
was_available: false,
price: null,
searched_at: '2024-01-15T10:05:00Z',
},
{
id: 3,
searched_domain: 'premium.com',
was_available: true,
price: 5000.0,
searched_at: '2024-01-15T10:10:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHistory });
const result = await getSearchHistory();
expect(apiClient.get).toHaveBeenCalledWith('/domains/history/');
expect(result).toEqual(mockHistory);
expect(result).toHaveLength(3);
expect(result[1].was_available).toBe(false);
expect(result[2].price).toBe(5000.0);
});
it('handles empty search history', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getSearchHistory();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,877 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getMFAStatus,
sendPhoneVerification,
verifyPhone,
enableSMSMFA,
setupTOTP,
verifyTOTPSetup,
generateBackupCodes,
getBackupCodesStatus,
disableMFA,
sendMFALoginCode,
verifyMFALogin,
listTrustedDevices,
revokeTrustedDevice,
revokeAllTrustedDevices,
} from '../mfa';
import apiClient from '../client';
describe('MFA API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================================================
// MFA Status
// ============================================================================
describe('getMFAStatus', () => {
it('fetches MFA status from API', async () => {
const mockStatus = {
mfa_enabled: true,
mfa_method: 'TOTP' as const,
methods: ['TOTP' as const, 'BACKUP' as const],
phone_last_4: '1234',
phone_verified: true,
totp_verified: true,
backup_codes_count: 8,
backup_codes_generated_at: '2024-01-01T00:00:00Z',
trusted_devices_count: 2,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/status/');
expect(result).toEqual(mockStatus);
});
it('returns status when MFA is disabled', async () => {
const mockStatus = {
mfa_enabled: false,
mfa_method: 'NONE' as const,
methods: [],
phone_last_4: null,
phone_verified: false,
totp_verified: false,
backup_codes_count: 0,
backup_codes_generated_at: null,
trusted_devices_count: 0,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(result.mfa_enabled).toBe(false);
expect(result.mfa_method).toBe('NONE');
expect(result.methods).toHaveLength(0);
});
it('returns status with both SMS and TOTP enabled', async () => {
const mockStatus = {
mfa_enabled: true,
mfa_method: 'BOTH' as const,
methods: ['SMS' as const, 'TOTP' as const, 'BACKUP' as const],
phone_last_4: '5678',
phone_verified: true,
totp_verified: true,
backup_codes_count: 10,
backup_codes_generated_at: '2024-01-15T12:00:00Z',
trusted_devices_count: 3,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(result.mfa_method).toBe('BOTH');
expect(result.methods).toContain('SMS');
expect(result.methods).toContain('TOTP');
expect(result.methods).toContain('BACKUP');
});
});
// ============================================================================
// SMS Setup
// ============================================================================
describe('sendPhoneVerification', () => {
it('sends phone verification code', async () => {
const mockResponse = {
data: {
success: true,
message: 'Verification code sent to +1234567890',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendPhoneVerification('+1234567890');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/send/', {
phone: '+1234567890',
});
expect(result).toEqual(mockResponse.data);
expect(result.success).toBe(true);
});
it('handles different phone number formats', async () => {
const mockResponse = {
data: { success: true, message: 'Code sent' },
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await sendPhoneVerification('555-123-4567');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/send/', {
phone: '555-123-4567',
});
});
});
describe('verifyPhone', () => {
it('verifies phone with valid code', async () => {
const mockResponse = {
data: {
success: true,
message: 'Phone number verified successfully',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyPhone('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/verify/', {
code: '123456',
});
expect(result.success).toBe(true);
});
it('handles verification failure', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid verification code',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyPhone('000000');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
describe('enableSMSMFA', () => {
it('enables SMS MFA successfully', async () => {
const mockResponse = {
data: {
success: true,
message: 'SMS MFA enabled successfully',
mfa_method: 'SMS',
backup_codes: ['code1', 'code2', 'code3'],
backup_codes_message: 'Save these backup codes',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await enableSMSMFA();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/sms/enable/');
expect(result.success).toBe(true);
expect(result.mfa_method).toBe('SMS');
expect(result.backup_codes).toHaveLength(3);
});
it('enables SMS MFA without generating backup codes', async () => {
const mockResponse = {
data: {
success: true,
message: 'SMS MFA enabled',
mfa_method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await enableSMSMFA();
expect(result.success).toBe(true);
expect(result.backup_codes).toBeUndefined();
});
});
// ============================================================================
// TOTP Setup (Authenticator App)
// ============================================================================
describe('setupTOTP', () => {
it('initializes TOTP setup with QR code', async () => {
const mockResponse = {
data: {
success: true,
secret: 'JBSWY3DPEHPK3PXP',
qr_code: 'data:image/png;base64,iVBORw0KGgoAAAANS...',
provisioning_uri: 'otpauth://totp/SmoothSchedule:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=SmoothSchedule',
message: 'Scan the QR code with your authenticator app',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await setupTOTP();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/setup/');
expect(result.success).toBe(true);
expect(result.secret).toBe('JBSWY3DPEHPK3PXP');
expect(result.qr_code).toContain('data:image/png');
expect(result.provisioning_uri).toContain('otpauth://totp/');
});
it('returns provisioning URI for manual entry', async () => {
const mockResponse = {
data: {
success: true,
secret: 'SECRETKEY123',
qr_code: 'data:image/png;base64,ABC...',
provisioning_uri: 'otpauth://totp/App:user@test.com?secret=SECRETKEY123',
message: 'Setup message',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await setupTOTP();
expect(result.provisioning_uri).toContain('SECRETKEY123');
});
});
describe('verifyTOTPSetup', () => {
it('verifies TOTP code and completes setup', async () => {
const mockResponse = {
data: {
success: true,
message: 'TOTP authentication enabled successfully',
mfa_method: 'TOTP',
backup_codes: ['backup1', 'backup2', 'backup3', 'backup4', 'backup5'],
backup_codes_message: 'Store these codes securely',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyTOTPSetup('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/verify/', {
code: '123456',
});
expect(result.success).toBe(true);
expect(result.mfa_method).toBe('TOTP');
expect(result.backup_codes).toHaveLength(5);
});
it('handles invalid TOTP code', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid TOTP code',
mfa_method: '',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyTOTPSetup('000000');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
// ============================================================================
// Backup Codes
// ============================================================================
describe('generateBackupCodes', () => {
it('generates new backup codes', async () => {
const mockResponse = {
data: {
success: true,
backup_codes: [
'AAAA-BBBB-CCCC',
'DDDD-EEEE-FFFF',
'GGGG-HHHH-IIII',
'JJJJ-KKKK-LLLL',
'MMMM-NNNN-OOOO',
'PPPP-QQQQ-RRRR',
'SSSS-TTTT-UUUU',
'VVVV-WWWW-XXXX',
'YYYY-ZZZZ-1111',
'2222-3333-4444',
],
message: 'Backup codes generated successfully',
warning: 'Previous backup codes have been invalidated',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await generateBackupCodes();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/backup-codes/');
expect(result.success).toBe(true);
expect(result.backup_codes).toHaveLength(10);
expect(result.warning).toContain('invalidated');
});
it('generates codes in correct format', async () => {
const mockResponse = {
data: {
success: true,
backup_codes: ['CODE-1234-ABCD', 'CODE-5678-EFGH'],
message: 'Generated',
warning: 'Old codes invalidated',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await generateBackupCodes();
result.backup_codes.forEach(code => {
expect(code).toMatch(/^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$/);
});
});
});
describe('getBackupCodesStatus', () => {
it('returns backup codes status', async () => {
const mockResponse = {
data: {
count: 8,
generated_at: '2024-01-15T10:30:00Z',
},
};
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
const result = await getBackupCodesStatus();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/backup-codes/status/');
expect(result.count).toBe(8);
expect(result.generated_at).toBe('2024-01-15T10:30:00Z');
});
it('returns status when no codes exist', async () => {
const mockResponse = {
data: {
count: 0,
generated_at: null,
},
};
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
const result = await getBackupCodesStatus();
expect(result.count).toBe(0);
expect(result.generated_at).toBeNull();
});
});
// ============================================================================
// Disable MFA
// ============================================================================
describe('disableMFA', () => {
it('disables MFA with password', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA has been disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ password: 'mypassword123' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
password: 'mypassword123',
});
expect(result.success).toBe(true);
expect(result.message).toContain('disabled');
});
it('disables MFA with valid MFA code', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA disabled successfully',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ mfa_code: '123456' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
mfa_code: '123456',
});
expect(result.success).toBe(true);
});
it('handles both password and MFA code', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await disableMFA({ password: 'pass', mfa_code: '654321' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
password: 'pass',
mfa_code: '654321',
});
});
it('handles incorrect credentials', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid password or MFA code',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ password: 'wrongpass' });
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
// ============================================================================
// MFA Login Challenge
// ============================================================================
describe('sendMFALoginCode', () => {
it('sends SMS code for login', async () => {
const mockResponse = {
data: {
success: true,
message: 'Verification code sent to your phone',
method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendMFALoginCode(42, 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 42,
method: 'SMS',
});
expect(result.success).toBe(true);
expect(result.method).toBe('SMS');
});
it('defaults to SMS method when not specified', async () => {
const mockResponse = {
data: {
success: true,
message: 'Code sent',
method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await sendMFALoginCode(123);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 123,
method: 'SMS',
});
});
it('sends TOTP method (no actual code sent)', async () => {
const mockResponse = {
data: {
success: true,
message: 'Use your authenticator app',
method: 'TOTP',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendMFALoginCode(99, 'TOTP');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 99,
method: 'TOTP',
});
expect(result.method).toBe('TOTP');
});
});
describe('verifyMFALogin', () => {
it('verifies MFA code and completes login', async () => {
const mockResponse = {
data: {
success: true,
access: 'access-token-xyz',
refresh: 'refresh-token-abc',
user: {
id: 42,
email: 'user@example.com',
username: 'john_doe',
first_name: 'John',
last_name: 'Doe',
full_name: 'John Doe',
role: 'owner',
business_subdomain: 'business1',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(42, '123456', 'TOTP', false);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 42,
code: '123456',
method: 'TOTP',
trust_device: false,
});
expect(result.success).toBe(true);
expect(result.access).toBe('access-token-xyz');
expect(result.user.email).toBe('user@example.com');
});
it('verifies SMS code', async () => {
const mockResponse = {
data: {
success: true,
access: 'token1',
refresh: 'token2',
user: {
id: 1,
email: 'test@test.com',
username: 'test',
first_name: 'Test',
last_name: 'User',
full_name: 'Test User',
role: 'staff',
business_subdomain: null,
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(1, '654321', 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 1,
code: '654321',
method: 'SMS',
trust_device: false,
});
expect(result.success).toBe(true);
});
it('verifies backup code', async () => {
const mockResponse = {
data: {
success: true,
access: 'token-a',
refresh: 'token-b',
user: {
id: 5,
email: 'backup@test.com',
username: 'backup_user',
first_name: 'Backup',
last_name: 'Test',
full_name: 'Backup Test',
role: 'manager',
business_subdomain: 'company',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(5, 'AAAA-BBBB-CCCC', 'BACKUP');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 5,
code: 'AAAA-BBBB-CCCC',
method: 'BACKUP',
trust_device: false,
});
expect(result.success).toBe(true);
});
it('trusts device after successful verification', async () => {
const mockResponse = {
data: {
success: true,
access: 'trusted-access',
refresh: 'trusted-refresh',
user: {
id: 10,
email: 'trusted@example.com',
username: 'trusted',
first_name: 'Trusted',
last_name: 'User',
full_name: 'Trusted User',
role: 'owner',
business_subdomain: 'trusted-biz',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await verifyMFALogin(10, '999888', 'TOTP', true);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 10,
code: '999888',
method: 'TOTP',
trust_device: true,
});
});
it('defaults trustDevice to false', async () => {
const mockResponse = {
data: {
success: true,
access: 'a',
refresh: 'b',
user: {
id: 1,
email: 'e@e.com',
username: 'u',
first_name: 'F',
last_name: 'L',
full_name: 'F L',
role: 'staff',
business_subdomain: null,
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await verifyMFALogin(1, '111111', 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 1,
code: '111111',
method: 'SMS',
trust_device: false,
});
});
it('handles invalid MFA code', async () => {
const mockResponse = {
data: {
success: false,
access: '',
refresh: '',
user: {
id: 0,
email: '',
username: '',
first_name: '',
last_name: '',
full_name: '',
role: '',
business_subdomain: null,
mfa_enabled: false,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(1, 'invalid', 'TOTP');
expect(result.success).toBe(false);
});
});
// ============================================================================
// Trusted Devices
// ============================================================================
describe('listTrustedDevices', () => {
it('lists all trusted devices', async () => {
const mockDevices = {
devices: [
{
id: 1,
name: 'Chrome on Windows',
ip_address: '192.168.1.100',
created_at: '2024-01-01T10:00:00Z',
last_used_at: '2024-01-15T14:30:00Z',
expires_at: '2024-02-01T10:00:00Z',
is_current: true,
},
{
id: 2,
name: 'Safari on iPhone',
ip_address: '192.168.1.101',
created_at: '2024-01-05T12:00:00Z',
last_used_at: '2024-01-14T09:15:00Z',
expires_at: '2024-02-05T12:00:00Z',
is_current: false,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/devices/');
expect(result.devices).toHaveLength(2);
expect(result.devices[0].is_current).toBe(true);
expect(result.devices[1].name).toBe('Safari on iPhone');
});
it('returns empty list when no devices', async () => {
const mockDevices = { devices: [] };
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
expect(result.devices).toHaveLength(0);
});
it('includes device metadata', async () => {
const mockDevices = {
devices: [
{
id: 99,
name: 'Firefox on Linux',
ip_address: '10.0.0.50',
created_at: '2024-01-10T08:00:00Z',
last_used_at: '2024-01-16T16:45:00Z',
expires_at: '2024-02-10T08:00:00Z',
is_current: false,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
const device = result.devices[0];
expect(device.id).toBe(99);
expect(device.name).toBe('Firefox on Linux');
expect(device.ip_address).toBe('10.0.0.50');
expect(device.created_at).toBeTruthy();
expect(device.last_used_at).toBeTruthy();
expect(device.expires_at).toBeTruthy();
});
});
describe('revokeTrustedDevice', () => {
it('revokes a specific device', async () => {
const mockResponse = {
data: {
success: true,
message: 'Device revoked successfully',
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeTrustedDevice(42);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/42/');
expect(result.success).toBe(true);
expect(result.message).toContain('revoked');
});
it('handles different device IDs', async () => {
const mockResponse = {
data: { success: true, message: 'Revoked' },
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
await revokeTrustedDevice(999);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/999/');
});
it('handles device not found', async () => {
const mockResponse = {
data: {
success: false,
message: 'Device not found',
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeTrustedDevice(0);
expect(result.success).toBe(false);
expect(result.message).toContain('not found');
});
});
describe('revokeAllTrustedDevices', () => {
it('revokes all trusted devices', async () => {
const mockResponse = {
data: {
success: true,
message: 'All devices revoked successfully',
count: 5,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/revoke-all/');
expect(result.success).toBe(true);
expect(result.count).toBe(5);
expect(result.message).toContain('All devices revoked');
});
it('returns zero count when no devices to revoke', async () => {
const mockResponse = {
data: {
success: true,
message: 'No devices to revoke',
count: 0,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(result.count).toBe(0);
});
it('includes count of revoked devices', async () => {
const mockResponse = {
data: {
success: true,
message: 'Devices revoked',
count: 12,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(result.count).toBe(12);
expect(result.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getNotifications,
getUnreadCount,
markNotificationRead,
markAllNotificationsRead,
clearAllNotifications,
} from '../notifications';
import apiClient from '../client';
describe('notifications API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getNotifications', () => {
it('fetches all notifications without params', async () => {
const mockNotifications = [
{ id: 1, verb: 'created', read: false, timestamp: '2024-01-01T00:00:00Z' },
{ id: 2, verb: 'updated', read: true, timestamp: '2024-01-02T00:00:00Z' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockNotifications });
const result = await getNotifications();
expect(apiClient.get).toHaveBeenCalledWith('/notifications/');
expect(result).toEqual(mockNotifications);
});
it('applies read filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ read: false });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?read=false');
});
it('applies limit parameter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ limit: 10 });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?limit=10');
});
it('applies multiple parameters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ read: true, limit: 5 });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?read=true&limit=5');
});
});
describe('getUnreadCount', () => {
it('returns unread count', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { count: 5 } });
const result = await getUnreadCount();
expect(apiClient.get).toHaveBeenCalledWith('/notifications/unread_count/');
expect(result).toBe(5);
});
it('returns 0 when no unread notifications', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { count: 0 } });
const result = await getUnreadCount();
expect(result).toBe(0);
});
});
describe('markNotificationRead', () => {
it('marks single notification as read', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await markNotificationRead(42);
expect(apiClient.post).toHaveBeenCalledWith('/notifications/42/mark_read/');
});
});
describe('markAllNotificationsRead', () => {
it('marks all notifications as read', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await markAllNotificationsRead();
expect(apiClient.post).toHaveBeenCalledWith('/notifications/mark_all_read/');
});
});
describe('clearAllNotifications', () => {
it('clears all read notifications', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await clearAllNotifications();
expect(apiClient.delete).toHaveBeenCalledWith('/notifications/clear_all/');
});
});
});

View File

@@ -0,0 +1,441 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getOAuthProviders,
initiateOAuth,
handleOAuthCallback,
getOAuthConnections,
disconnectOAuth,
} from '../oauth';
import apiClient from '../client';
describe('oauth API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOAuthProviders', () => {
it('fetches list of enabled OAuth providers', async () => {
const mockProviders = [
{
name: 'google',
display_name: 'Google',
icon: 'google-icon.svg',
},
{
name: 'microsoft',
display_name: 'Microsoft',
icon: 'microsoft-icon.svg',
},
{
name: 'github',
display_name: 'GitHub',
icon: 'github-icon.svg',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: mockProviders },
});
const result = await getOAuthProviders();
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/providers/');
expect(result).toEqual(mockProviders);
});
it('returns empty array when no providers enabled', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: [] },
});
const result = await getOAuthProviders();
expect(result).toEqual([]);
});
it('extracts providers from nested response', async () => {
const mockProviders = [
{
name: 'google',
display_name: 'Google',
icon: 'google-icon.svg',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: mockProviders },
});
const result = await getOAuthProviders();
// Verify it returns response.data.providers, not response.data
expect(result).toEqual(mockProviders);
expect(Array.isArray(result)).toBe(true);
});
});
describe('initiateOAuth', () => {
it('initiates OAuth flow for Google', async () => {
const mockResponse = {
authorization_url: 'https://accounts.google.com/o/oauth2/auth?client_id=123&redirect_uri=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('google');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/google/authorize/');
expect(result).toEqual(mockResponse);
expect(result.authorization_url).toContain('accounts.google.com');
});
it('initiates OAuth flow for Microsoft', async () => {
const mockResponse = {
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('microsoft');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/microsoft/authorize/');
expect(result).toEqual(mockResponse);
});
it('initiates OAuth flow for GitHub', async () => {
const mockResponse = {
authorization_url: 'https://github.com/login/oauth/authorize?client_id=xyz&scope=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('github');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/github/authorize/');
expect(result.authorization_url).toContain('github.com');
});
it('includes state parameter in authorization URL', async () => {
const mockResponse = {
authorization_url: 'https://provider.com/auth?state=random-state-token',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('google');
expect(result.authorization_url).toContain('state=');
});
});
describe('handleOAuthCallback', () => {
it('exchanges authorization code for tokens', async () => {
const mockResponse = {
access: 'access-token-123',
refresh: 'refresh-token-456',
user: {
id: 1,
username: 'johndoe',
email: 'john@example.com',
name: 'John Doe',
role: 'owner',
is_staff: false,
is_superuser: false,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('google', 'auth-code-xyz', 'state-token-abc');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/google/callback/', {
code: 'auth-code-xyz',
state: 'state-token-abc',
});
expect(result).toEqual(mockResponse);
expect(result.access).toBe('access-token-123');
expect(result.refresh).toBe('refresh-token-456');
expect(result.user.email).toBe('john@example.com');
});
it('handles callback with business user', async () => {
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 2,
username: 'staffmember',
email: 'staff@business.com',
name: 'Staff Member',
role: 'staff',
is_staff: true,
is_superuser: false,
business: 5,
business_name: 'My Business',
business_subdomain: 'mybiz',
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('microsoft', 'code-123', 'state-456');
expect(result.user.business).toBe(5);
expect(result.user.business_name).toBe('My Business');
expect(result.user.business_subdomain).toBe('mybiz');
});
it('handles callback with avatar URL', async () => {
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 3,
username: 'user',
email: 'user@example.com',
name: 'User Name',
role: 'customer',
avatar_url: 'https://avatar.com/user.jpg',
is_staff: false,
is_superuser: false,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('github', 'code-abc', 'state-def');
expect(result.user.avatar_url).toBe('https://avatar.com/user.jpg');
});
it('handles superuser login via OAuth', async () => {
const mockResponse = {
access: 'admin-access-token',
refresh: 'admin-refresh-token',
user: {
id: 1,
username: 'admin',
email: 'admin@platform.com',
name: 'Platform Admin',
role: 'superuser',
is_staff: true,
is_superuser: true,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('google', 'admin-code', 'admin-state');
expect(result.user.is_superuser).toBe(true);
expect(result.user.role).toBe('superuser');
});
it('sends correct data for different providers', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
access: 'token',
refresh: 'token',
user: { id: 1, email: 'test@test.com', name: 'Test', role: 'owner', is_staff: false, is_superuser: false, username: 'test' },
},
});
await handleOAuthCallback('github', 'code-1', 'state-1');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/github/callback/', {
code: 'code-1',
state: 'state-1',
});
await handleOAuthCallback('microsoft', 'code-2', 'state-2');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/microsoft/callback/', {
code: 'code-2',
state: 'state-2',
});
});
});
describe('getOAuthConnections', () => {
it('fetches list of connected OAuth accounts', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'google',
provider_user_id: 'google-user-123',
email: 'user@gmail.com',
connected_at: '2024-01-15T10:30:00Z',
},
{
id: 'conn-2',
provider: 'microsoft',
provider_user_id: 'ms-user-456',
email: 'user@outlook.com',
connected_at: '2024-02-20T14:45:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/connections/');
expect(result).toEqual(mockConnections);
expect(result).toHaveLength(2);
});
it('returns empty array when no connections exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: [] },
});
const result = await getOAuthConnections();
expect(result).toEqual([]);
});
it('extracts connections from nested response', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'github',
provider_user_id: 'github-123',
connected_at: '2024-03-01T09:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
// Verify it returns response.data.connections, not response.data
expect(result).toEqual(mockConnections);
expect(Array.isArray(result)).toBe(true);
});
it('handles connections without email field', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'github',
provider_user_id: 'github-user-789',
connected_at: '2024-04-10T12:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(result[0].email).toBeUndefined();
expect(result[0].provider).toBe('github');
});
it('handles multiple connections from same provider', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'google',
provider_user_id: 'google-user-1',
email: 'work@gmail.com',
connected_at: '2024-01-01T00:00:00Z',
},
{
id: 'conn-2',
provider: 'google',
provider_user_id: 'google-user-2',
email: 'personal@gmail.com',
connected_at: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(result).toHaveLength(2);
expect(result.filter((c) => c.provider === 'google')).toHaveLength(2);
});
});
describe('disconnectOAuth', () => {
it('disconnects Google OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('google');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/google/');
});
it('disconnects Microsoft OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('microsoft');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/microsoft/');
});
it('disconnects GitHub OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('github');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/github/');
});
it('returns void on successful disconnect', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await disconnectOAuth('google');
expect(result).toBeUndefined();
});
it('handles disconnect for custom provider', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('custom-provider');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/custom-provider/');
});
});
describe('error handling', () => {
it('propagates errors from getOAuthProviders', async () => {
const error = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getOAuthProviders()).rejects.toThrow('Network error');
});
it('propagates errors from initiateOAuth', async () => {
const error = new Error('Provider not configured');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(initiateOAuth('google')).rejects.toThrow('Provider not configured');
});
it('propagates errors from handleOAuthCallback', async () => {
const error = new Error('Invalid authorization code');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(handleOAuthCallback('google', 'bad-code', 'state')).rejects.toThrow('Invalid authorization code');
});
it('propagates errors from getOAuthConnections', async () => {
const error = new Error('Unauthorized');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getOAuthConnections()).rejects.toThrow('Unauthorized');
});
it('propagates errors from disconnectOAuth', async () => {
const error = new Error('Connection not found');
vi.mocked(apiClient.delete).mockRejectedValue(error);
await expect(disconnectOAuth('google')).rejects.toThrow('Connection not found');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,989 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getBusinesses,
updateBusiness,
createBusiness,
deleteBusiness,
getUsers,
getBusinessUsers,
verifyUserEmail,
getTenantInvitations,
createTenantInvitation,
resendTenantInvitation,
cancelTenantInvitation,
getInvitationByToken,
acceptInvitation,
type PlatformBusiness,
type PlatformBusinessUpdate,
type PlatformBusinessCreate,
type PlatformUser,
type TenantInvitation,
type TenantInvitationCreate,
type TenantInvitationDetail,
type TenantInvitationAccept,
} from '../platform';
import apiClient from '../client';
describe('platform API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================================================
// Business Management
// ============================================================================
describe('getBusinesses', () => {
it('fetches all businesses from API', async () => {
const mockBusinesses: PlatformBusiness[] = [
{
id: 1,
name: 'Acme Corp',
subdomain: 'acme',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-01T00:00:00Z',
user_count: 5,
owner: {
id: 10,
username: 'john',
full_name: 'John Doe',
email: 'john@acme.com',
role: 'owner',
email_verified: true,
},
max_users: 20,
max_resources: 50,
contact_email: 'contact@acme.com',
phone: '555-1234',
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
{
id: 2,
name: 'Beta LLC',
subdomain: 'beta',
tier: 'STARTER',
is_active: true,
created_on: '2025-01-02T00:00:00Z',
user_count: 2,
owner: null,
max_users: 5,
max_resources: 10,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusinesses });
const result = await getBusinesses();
expect(apiClient.get).toHaveBeenCalledWith('/platform/businesses/');
expect(result).toEqual(mockBusinesses);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Acme Corp');
expect(result[1].owner).toBeNull();
});
it('returns empty array when no businesses exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinesses();
expect(result).toEqual([]);
});
});
describe('updateBusiness', () => {
it('updates a business with full data', async () => {
const businessId = 1;
const updateData: PlatformBusinessUpdate = {
name: 'Updated Name',
is_active: false,
subscription_tier: 'ENTERPRISE',
max_users: 100,
max_resources: 500,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
const mockResponse: PlatformBusiness = {
id: 1,
name: 'Updated Name',
subdomain: 'acme',
tier: 'ENTERPRISE',
is_active: false,
created_on: '2025-01-01T00:00:00Z',
user_count: 5,
owner: null,
max_users: 100,
max_resources: 500,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/1/',
updateData
);
expect(result).toEqual(mockResponse);
expect(result.name).toBe('Updated Name');
expect(result.is_active).toBe(false);
});
it('updates a business with partial data', async () => {
const businessId = 2;
const updateData: PlatformBusinessUpdate = {
is_active: true,
};
const mockResponse: PlatformBusiness = {
id: 2,
name: 'Beta LLC',
subdomain: 'beta',
tier: 'STARTER',
is_active: true,
created_on: '2025-01-02T00:00:00Z',
user_count: 2,
owner: null,
max_users: 5,
max_resources: 10,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/2/',
updateData
);
expect(result.is_active).toBe(true);
});
it('updates only specific permissions', async () => {
const businessId = 3;
const updateData: PlatformBusinessUpdate = {
can_accept_payments: true,
can_api_access: true,
};
const mockResponse: PlatformBusiness = {
id: 3,
name: 'Gamma Inc',
subdomain: 'gamma',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-03T00:00:00Z',
user_count: 10,
owner: null,
max_users: 20,
max_resources: 50,
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/3/',
updateData
);
});
});
describe('createBusiness', () => {
it('creates a business with minimal data', async () => {
const createData: PlatformBusinessCreate = {
name: 'New Business',
subdomain: 'newbiz',
};
const mockResponse: PlatformBusiness = {
id: 10,
name: 'New Business',
subdomain: 'newbiz',
tier: 'FREE',
is_active: true,
created_on: '2025-01-15T00:00:00Z',
user_count: 0,
owner: null,
max_users: 3,
max_resources: 5,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/businesses/',
createData
);
expect(result).toEqual(mockResponse);
expect(result.id).toBe(10);
expect(result.subdomain).toBe('newbiz');
});
it('creates a business with full data including owner', async () => {
const createData: PlatformBusinessCreate = {
name: 'Premium Business',
subdomain: 'premium',
subscription_tier: 'ENTERPRISE',
is_active: true,
max_users: 100,
max_resources: 500,
contact_email: 'contact@premium.com',
phone: '555-9999',
can_manage_oauth_credentials: true,
owner_email: 'owner@premium.com',
owner_name: 'Jane Smith',
owner_password: 'secure-password',
};
const mockResponse: PlatformBusiness = {
id: 11,
name: 'Premium Business',
subdomain: 'premium',
tier: 'ENTERPRISE',
is_active: true,
created_on: '2025-01-15T10:00:00Z',
user_count: 1,
owner: {
id: 20,
username: 'owner@premium.com',
full_name: 'Jane Smith',
email: 'owner@premium.com',
role: 'owner',
email_verified: false,
},
max_users: 100,
max_resources: 500,
contact_email: 'contact@premium.com',
phone: '555-9999',
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/businesses/',
createData
);
expect(result.owner).not.toBeNull();
expect(result.owner?.email).toBe('owner@premium.com');
});
it('creates a business with custom tier and limits', async () => {
const createData: PlatformBusinessCreate = {
name: 'Custom Business',
subdomain: 'custom',
subscription_tier: 'PROFESSIONAL',
max_users: 50,
max_resources: 100,
};
const mockResponse: PlatformBusiness = {
id: 12,
name: 'Custom Business',
subdomain: 'custom',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-15T12:00:00Z',
user_count: 0,
owner: null,
max_users: 50,
max_resources: 100,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(result.max_users).toBe(50);
expect(result.max_resources).toBe(100);
});
});
describe('deleteBusiness', () => {
it('deletes a business by ID', async () => {
const businessId = 5;
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteBusiness(businessId);
expect(apiClient.delete).toHaveBeenCalledWith('/platform/businesses/5/');
});
it('handles deletion with no response data', async () => {
const businessId = 10;
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
const result = await deleteBusiness(businessId);
expect(apiClient.delete).toHaveBeenCalledWith('/platform/businesses/10/');
expect(result).toBeUndefined();
});
});
// ============================================================================
// User Management
// ============================================================================
describe('getUsers', () => {
it('fetches all users from API', async () => {
const mockUsers: PlatformUser[] = [
{
id: 1,
email: 'admin@platform.com',
username: 'admin',
name: 'Platform Admin',
role: 'superuser',
is_active: true,
is_staff: true,
is_superuser: true,
email_verified: true,
business: null,
date_joined: '2024-01-01T00:00:00Z',
last_login: '2025-01-15T10:00:00Z',
},
{
id: 2,
email: 'user@acme.com',
username: 'user1',
name: 'Acme User',
role: 'staff',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-06-01T00:00:00Z',
last_login: '2025-01-14T15:30:00Z',
},
{
id: 3,
email: 'inactive@example.com',
username: 'inactive',
is_active: false,
is_staff: false,
is_superuser: false,
email_verified: false,
business: null,
date_joined: '2024-03-15T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getUsers();
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/');
expect(result).toEqual(mockUsers);
expect(result).toHaveLength(3);
expect(result[0].is_superuser).toBe(true);
expect(result[1].business_name).toBe('Acme Corp');
expect(result[2].is_active).toBe(false);
});
it('returns empty array when no users exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getUsers();
expect(result).toEqual([]);
});
});
describe('getBusinessUsers', () => {
it('fetches users for a specific business', async () => {
const businessId = 1;
const mockUsers: PlatformUser[] = [
{
id: 10,
email: 'owner@acme.com',
username: 'owner',
name: 'John Doe',
role: 'owner',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-01-01T00:00:00Z',
last_login: '2025-01-15T09:00:00Z',
},
{
id: 11,
email: 'staff@acme.com',
username: 'staff1',
name: 'Jane Smith',
role: 'staff',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-03-01T00:00:00Z',
last_login: '2025-01-14T16:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=1');
expect(result).toEqual(mockUsers);
expect(result).toHaveLength(2);
expect(result.every(u => u.business === 1)).toBe(true);
});
it('returns empty array when business has no users', async () => {
const businessId = 99;
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=99');
expect(result).toEqual([]);
});
it('handles different business IDs correctly', async () => {
const businessId = 5;
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=5');
});
});
describe('verifyUserEmail', () => {
it('verifies a user email by ID', async () => {
const userId = 10;
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyUserEmail(userId);
expect(apiClient.post).toHaveBeenCalledWith('/platform/users/10/verify_email/');
});
it('handles verification with no response data', async () => {
const userId = 25;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await verifyUserEmail(userId);
expect(apiClient.post).toHaveBeenCalledWith('/platform/users/25/verify_email/');
expect(result).toBeUndefined();
});
});
// ============================================================================
// Tenant Invitations
// ============================================================================
describe('getTenantInvitations', () => {
it('fetches all tenant invitations from API', async () => {
const mockInvitations: TenantInvitation[] = [
{
id: 1,
email: 'newclient@example.com',
token: 'abc123token',
status: 'PENDING',
suggested_business_name: 'New Client Corp',
subscription_tier: 'PROFESSIONAL',
custom_max_users: 50,
custom_max_resources: 100,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
personal_message: 'Welcome to our platform!',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-10T10:00:00Z',
expires_at: '2025-01-24T10:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
},
{
id: 2,
email: 'accepted@example.com',
token: 'xyz789token',
status: 'ACCEPTED',
suggested_business_name: 'Accepted Business',
subscription_tier: 'STARTER',
custom_max_users: null,
custom_max_resources: null,
permissions: {},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-05T08:00:00Z',
expires_at: '2025-01-19T08:00:00Z',
accepted_at: '2025-01-06T12:00:00Z',
created_tenant: 5,
created_tenant_name: 'Accepted Business',
created_user: 15,
created_user_email: 'accepted@example.com',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitations });
const result = await getTenantInvitations();
expect(apiClient.get).toHaveBeenCalledWith('/platform/tenant-invitations/');
expect(result).toEqual(mockInvitations);
expect(result).toHaveLength(2);
expect(result[0].status).toBe('PENDING');
expect(result[1].status).toBe('ACCEPTED');
});
it('returns empty array when no invitations exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTenantInvitations();
expect(result).toEqual([]);
});
});
describe('createTenantInvitation', () => {
it('creates a tenant invitation with minimal data', async () => {
const createData: TenantInvitationCreate = {
email: 'client@example.com',
subscription_tier: 'STARTER',
};
const mockResponse: TenantInvitation = {
id: 10,
email: 'client@example.com',
token: 'generated-token-123',
status: 'PENDING',
suggested_business_name: '',
subscription_tier: 'STARTER',
custom_max_users: null,
custom_max_resources: null,
permissions: {},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T14:00:00Z',
expires_at: '2025-01-29T14:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/',
createData
);
expect(result).toEqual(mockResponse);
expect(result.email).toBe('client@example.com');
expect(result.status).toBe('PENDING');
});
it('creates a tenant invitation with full data', async () => {
const createData: TenantInvitationCreate = {
email: 'vip@example.com',
suggested_business_name: 'VIP Corp',
subscription_tier: 'ENTERPRISE',
custom_max_users: 500,
custom_max_resources: 1000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
personal_message: 'Welcome to our premium tier!',
};
const mockResponse: TenantInvitation = {
id: 11,
email: 'vip@example.com',
token: 'vip-token-456',
status: 'PENDING',
suggested_business_name: 'VIP Corp',
subscription_tier: 'ENTERPRISE',
custom_max_users: 500,
custom_max_resources: 1000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
personal_message: 'Welcome to our premium tier!',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T15:00:00Z',
expires_at: '2025-01-29T15:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/',
createData
);
expect(result.suggested_business_name).toBe('VIP Corp');
expect(result.custom_max_users).toBe(500);
expect(result.permissions.can_white_label).toBe(true);
});
it('creates invitation with partial permissions', async () => {
const createData: TenantInvitationCreate = {
email: 'partial@example.com',
subscription_tier: 'PROFESSIONAL',
permissions: {
can_accept_payments: true,
},
};
const mockResponse: TenantInvitation = {
id: 12,
email: 'partial@example.com',
token: 'partial-token',
status: 'PENDING',
suggested_business_name: '',
subscription_tier: 'PROFESSIONAL',
custom_max_users: null,
custom_max_resources: null,
permissions: {
can_accept_payments: true,
},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T16:00:00Z',
expires_at: '2025-01-29T16:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(result.permissions.can_accept_payments).toBe(true);
});
});
describe('resendTenantInvitation', () => {
it('resends a tenant invitation by ID', async () => {
const invitationId = 5;
vi.mocked(apiClient.post).mockResolvedValue({});
await resendTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/5/resend/'
);
});
it('handles resend with no response data', async () => {
const invitationId = 10;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await resendTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/10/resend/'
);
expect(result).toBeUndefined();
});
});
describe('cancelTenantInvitation', () => {
it('cancels a tenant invitation by ID', async () => {
const invitationId = 7;
vi.mocked(apiClient.post).mockResolvedValue({});
await cancelTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/7/cancel/'
);
});
it('handles cancellation with no response data', async () => {
const invitationId = 15;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await cancelTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/15/cancel/'
);
expect(result).toBeUndefined();
});
});
describe('getInvitationByToken', () => {
it('fetches invitation details by token', async () => {
const token = 'abc123token';
const mockInvitation: TenantInvitationDetail = {
email: 'invited@example.com',
suggested_business_name: 'Invited Corp',
subscription_tier: 'PROFESSIONAL',
effective_max_users: 20,
effective_max_resources: 50,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
expires_at: '2025-01-30T12:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
const result = await getInvitationByToken(token);
expect(apiClient.get).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/abc123token/'
);
expect(result).toEqual(mockInvitation);
expect(result.email).toBe('invited@example.com');
expect(result.effective_max_users).toBe(20);
});
it('handles tokens with special characters', async () => {
const token = 'token-with-dashes_and_underscores';
const mockInvitation: TenantInvitationDetail = {
email: 'test@example.com',
suggested_business_name: 'Test',
subscription_tier: 'FREE',
effective_max_users: 3,
effective_max_resources: 5,
permissions: {},
expires_at: '2025-02-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
await getInvitationByToken(token);
expect(apiClient.get).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/token-with-dashes_and_underscores/'
);
});
it('fetches invitation with custom limits', async () => {
const token = 'custom-limits-token';
const mockInvitation: TenantInvitationDetail = {
email: 'custom@example.com',
suggested_business_name: 'Custom Business',
subscription_tier: 'ENTERPRISE',
effective_max_users: 1000,
effective_max_resources: 5000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
expires_at: '2025-03-01T12:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
const result = await getInvitationByToken(token);
expect(result.effective_max_users).toBe(1000);
expect(result.effective_max_resources).toBe(5000);
});
});
describe('acceptInvitation', () => {
it('accepts an invitation with full data', async () => {
const token = 'accept-token-123';
const acceptData: TenantInvitationAccept = {
email: 'newowner@example.com',
password: 'secure-password',
first_name: 'John',
last_name: 'Doe',
business_name: 'New Business LLC',
subdomain: 'newbiz',
contact_email: 'contact@newbiz.com',
phone: '555-1234',
};
const mockResponse = {
detail: 'Invitation accepted successfully. Your account has been created.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/accept-token-123/accept/',
acceptData
);
expect(result).toEqual(mockResponse);
expect(result.detail).toContain('successfully');
});
it('accepts an invitation with minimal data', async () => {
const token = 'minimal-token';
const acceptData: TenantInvitationAccept = {
email: 'minimal@example.com',
password: 'password123',
first_name: 'Jane',
last_name: 'Smith',
business_name: 'Minimal Business',
subdomain: 'minimal',
};
const mockResponse = {
detail: 'Account created successfully.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/minimal-token/accept/',
acceptData
);
expect(result.detail).toBe('Account created successfully.');
});
it('handles acceptance with optional contact fields', async () => {
const token = 'optional-fields-token';
const acceptData: TenantInvitationAccept = {
email: 'test@example.com',
password: 'testpass',
first_name: 'Test',
last_name: 'User',
business_name: 'Test Business',
subdomain: 'testbiz',
contact_email: 'info@testbiz.com',
};
const mockResponse = {
detail: 'Welcome to the platform!',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/optional-fields-token/accept/',
expect.objectContaining({
contact_email: 'info@testbiz.com',
})
);
});
it('preserves all required fields in request', async () => {
const token = 'complete-token';
const acceptData: TenantInvitationAccept = {
email: 'complete@example.com',
password: 'strong-password-123',
first_name: 'Complete',
last_name: 'User',
business_name: 'Complete Business Corp',
subdomain: 'complete',
contact_email: 'support@complete.com',
phone: '555-9876',
};
vi.mocked(apiClient.post).mockResolvedValue({
data: { detail: 'Success' },
});
await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/complete-token/accept/',
expect.objectContaining({
email: 'complete@example.com',
password: 'strong-password-123',
first_name: 'Complete',
last_name: 'User',
business_name: 'Complete Business Corp',
subdomain: 'complete',
contact_email: 'support@complete.com',
phone: '555-9876',
})
);
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,335 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getProfile,
updateProfile,
uploadAvatar,
deleteAvatar,
sendVerificationEmail,
verifyEmail,
requestEmailChange,
confirmEmailChange,
changePassword,
setupTOTP,
verifyTOTP,
disableTOTP,
getRecoveryCodes,
regenerateRecoveryCodes,
getSessions,
revokeSession,
revokeOtherSessions,
getLoginHistory,
sendPhoneVerification,
verifyPhoneCode,
getUserEmails,
addUserEmail,
deleteUserEmail,
sendUserEmailVerification,
verifyUserEmail,
setPrimaryEmail,
} from '../profile';
import apiClient from '../client';
describe('profile API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getProfile', () => {
it('fetches user profile', async () => {
const mockProfile = {
id: 1,
email: 'test@example.com',
name: 'Test User',
email_verified: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockProfile });
const result = await getProfile();
expect(apiClient.get).toHaveBeenCalledWith('/auth/profile/');
expect(result).toEqual(mockProfile);
});
});
describe('updateProfile', () => {
it('updates profile with provided data', async () => {
const mockUpdated = { id: 1, name: 'Updated Name' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockUpdated });
const result = await updateProfile({ name: 'Updated Name' });
expect(apiClient.patch).toHaveBeenCalledWith('/auth/profile/', { name: 'Updated Name' });
expect(result).toEqual(mockUpdated);
});
});
describe('uploadAvatar', () => {
it('uploads avatar file', async () => {
const mockResponse = { avatar_url: 'https://example.com/avatar.jpg' };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const file = new File(['test'], 'avatar.jpg', { type: 'image/jpeg' });
const result = await uploadAvatar(file);
expect(apiClient.post).toHaveBeenCalledWith(
'/auth/profile/avatar/',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result.avatar_url).toBe('https://example.com/avatar.jpg');
});
});
describe('deleteAvatar', () => {
it('deletes user avatar', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteAvatar();
expect(apiClient.delete).toHaveBeenCalledWith('/auth/profile/avatar/');
});
});
describe('email verification', () => {
it('sends verification email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendVerificationEmail();
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/verify/send/');
});
it('verifies email with token', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyEmail('verification-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/verify/confirm/', {
token: 'verification-token',
});
});
});
describe('email change', () => {
it('requests email change', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await requestEmailChange('new@example.com');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/change/', {
new_email: 'new@example.com',
});
});
it('confirms email change', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await confirmEmailChange('change-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/change/confirm/', {
token: 'change-token',
});
});
});
describe('changePassword', () => {
it('changes password with current and new', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await changePassword('oldPassword', 'newPassword');
expect(apiClient.post).toHaveBeenCalledWith('/auth/password/change/', {
current_password: 'oldPassword',
new_password: 'newPassword',
});
});
});
describe('2FA / TOTP', () => {
it('sets up TOTP', async () => {
const mockSetup = {
secret: 'ABCD1234',
qr_code: 'base64...',
provisioning_uri: 'otpauth://...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetup });
const result = await setupTOTP();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/setup/');
expect(result.secret).toBe('ABCD1234');
});
it('verifies TOTP code', async () => {
const mockResponse = { success: true, backup_codes: ['code1', 'code2'] };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyTOTP('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/verify/', { code: '123456' });
expect(result.success).toBe(true);
expect(result.recovery_codes).toEqual(['code1', 'code2']);
});
it('disables TOTP', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await disableTOTP('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', { mfa_code: '123456' });
});
it('gets recovery codes status', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
const result = await getRecoveryCodes();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/backup-codes/status/');
expect(result).toEqual([]);
});
it('regenerates recovery codes', async () => {
const mockCodes = ['code1', 'code2', 'code3'];
vi.mocked(apiClient.post).mockResolvedValue({ data: { backup_codes: mockCodes } });
const result = await regenerateRecoveryCodes();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/backup-codes/');
expect(result).toEqual(mockCodes);
});
});
describe('sessions', () => {
it('gets sessions', async () => {
const mockSessions = [
{ id: '1', device_info: 'Chrome', ip_address: '1.1.1.1', is_current: true },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSessions });
const result = await getSessions();
expect(apiClient.get).toHaveBeenCalledWith('/auth/sessions/');
expect(result).toEqual(mockSessions);
});
it('revokes session', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await revokeSession('session-123');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/sessions/session-123/');
});
it('revokes other sessions', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await revokeOtherSessions();
expect(apiClient.post).toHaveBeenCalledWith('/auth/sessions/revoke-others/');
});
it('gets login history', async () => {
const mockHistory = [
{ id: '1', timestamp: '2024-01-01', success: true },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHistory });
const result = await getLoginHistory();
expect(apiClient.get).toHaveBeenCalledWith('/auth/login-history/');
expect(result).toEqual(mockHistory);
});
});
describe('phone verification', () => {
it('sends phone verification', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendPhoneVerification('555-1234');
expect(apiClient.post).toHaveBeenCalledWith('/auth/phone/verify/send/', {
phone: '555-1234',
});
});
it('verifies phone code', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyPhoneCode('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/phone/verify/confirm/', {
code: '123456',
});
});
});
describe('multiple emails', () => {
it('gets user emails', async () => {
const mockEmails = [
{ id: 1, email: 'primary@example.com', is_primary: true, verified: true },
{ id: 2, email: 'secondary@example.com', is_primary: false, verified: false },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getUserEmails();
expect(apiClient.get).toHaveBeenCalledWith('/auth/emails/');
expect(result).toEqual(mockEmails);
});
it('adds user email', async () => {
const mockEmail = { id: 3, email: 'new@example.com', is_primary: false, verified: false };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockEmail });
const result = await addUserEmail('new@example.com');
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/', { email: 'new@example.com' });
expect(result).toEqual(mockEmail);
});
it('deletes user email', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteUserEmail(2);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/emails/2/');
});
it('sends user email verification', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendUserEmailVerification(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/send-verification/');
});
it('verifies user email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyUserEmail(2, 'verify-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/verify/', {
token: 'verify-token',
});
});
it('sets primary email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await setPrimaryEmail(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/set-primary/');
});
});
});

View File

@@ -0,0 +1,609 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
import {
getQuotaStatus,
getQuotaResources,
archiveResources,
unarchiveResource,
getOverageDetail,
QuotaStatus,
QuotaResourcesResponse,
ArchiveResponse,
QuotaOverageDetail,
} from '../quota';
import apiClient from '../client';
describe('quota API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getQuotaStatus', () => {
it('fetches quota status from API', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [
{
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-14T00:00:00Z',
},
],
usage: {
resources: {
current: 15,
limit: 10,
display_name: 'Resources',
},
staff: {
current: 3,
limit: 5,
display_name: 'Staff Members',
},
services: {
current: 8,
limit: 20,
display_name: 'Services',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(apiClient.get).toHaveBeenCalledWith('/quota/status/');
expect(result).toEqual(mockQuotaStatus);
expect(result.active_overages).toHaveLength(1);
expect(result.usage.resources.current).toBe(15);
});
it('returns empty active_overages when no overages exist', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [],
usage: {
resources: {
current: 5,
limit: 10,
display_name: 'Resources',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(result.active_overages).toHaveLength(0);
expect(result.usage.resources.current).toBeLessThan(result.usage.resources.limit);
});
it('handles multiple quota types in usage', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [],
usage: {
resources: {
current: 5,
limit: 10,
display_name: 'Resources',
},
staff: {
current: 2,
limit: 5,
display_name: 'Staff Members',
},
services: {
current: 15,
limit: 20,
display_name: 'Services',
},
customers: {
current: 100,
limit: 500,
display_name: 'Customers',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(Object.keys(result.usage)).toHaveLength(4);
expect(result.usage).toHaveProperty('resources');
expect(result.usage).toHaveProperty('staff');
expect(result.usage).toHaveProperty('services');
expect(result.usage).toHaveProperty('customers');
});
});
describe('getQuotaResources', () => {
it('fetches resources for a specific quota type', async () => {
const mockResourcesResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [
{
id: 1,
name: 'Conference Room A',
type: 'room',
created_at: '2025-01-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 2,
name: 'Conference Room B',
type: 'room',
created_at: '2025-01-02T11:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
const result = await getQuotaResources('resources');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/resources/');
expect(result).toEqual(mockResourcesResponse);
expect(result.quota_type).toBe('resources');
expect(result.resources).toHaveLength(2);
});
it('fetches staff members for staff quota type', async () => {
const mockStaffResponse: QuotaResourcesResponse = {
quota_type: 'staff',
resources: [
{
id: 10,
name: 'John Doe',
email: 'john@example.com',
role: 'staff',
created_at: '2025-01-15T09:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 11,
name: 'Jane Smith',
email: 'jane@example.com',
role: 'manager',
created_at: '2025-01-16T09:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaffResponse });
const result = await getQuotaResources('staff');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/staff/');
expect(result.quota_type).toBe('staff');
expect(result.resources[0]).toHaveProperty('email');
expect(result.resources[0]).toHaveProperty('role');
});
it('fetches services for services quota type', async () => {
const mockServicesResponse: QuotaResourcesResponse = {
quota_type: 'services',
resources: [
{
id: 20,
name: 'Haircut',
duration: 30,
price: '25.00',
created_at: '2025-02-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 21,
name: 'Color Treatment',
duration: 90,
price: '75.00',
created_at: '2025-02-02T10:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockServicesResponse });
const result = await getQuotaResources('services');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/services/');
expect(result.quota_type).toBe('services');
expect(result.resources[0]).toHaveProperty('duration');
expect(result.resources[0]).toHaveProperty('price');
});
it('includes archived resources', async () => {
const mockResourcesResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [
{
id: 1,
name: 'Active Resource',
created_at: '2025-01-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 2,
name: 'Archived Resource',
created_at: '2024-12-01T10:00:00Z',
is_archived: true,
archived_at: '2025-12-01T15:30:00Z',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
const result = await getQuotaResources('resources');
expect(result.resources).toHaveLength(2);
expect(result.resources[0].is_archived).toBe(false);
expect(result.resources[1].is_archived).toBe(true);
expect(result.resources[1].archived_at).toBe('2025-12-01T15:30:00Z');
});
it('handles empty resources list', async () => {
const mockEmptyResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmptyResponse });
const result = await getQuotaResources('resources');
expect(result.resources).toHaveLength(0);
expect(result.quota_type).toBe('resources');
});
});
describe('archiveResources', () => {
it('archives multiple resources successfully', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 3,
current_usage: 7,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', [1, 2, 3]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'resources',
resource_ids: [1, 2, 3],
});
expect(result).toEqual(mockArchiveResponse);
expect(result.archived_count).toBe(3);
expect(result.is_resolved).toBe(true);
});
it('archives single resource', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 1,
current_usage: 9,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('staff', [5]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'staff',
resource_ids: [5],
});
expect(result.archived_count).toBe(1);
});
it('indicates overage is still not resolved after archiving', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 2,
current_usage: 12,
limit: 10,
is_resolved: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', [1, 2]);
expect(result.is_resolved).toBe(false);
expect(result.current_usage).toBeGreaterThan(result.limit);
});
it('handles archiving with different quota types', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 5,
current_usage: 15,
limit: 20,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
await archiveResources('services', [10, 11, 12, 13, 14]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'services',
resource_ids: [10, 11, 12, 13, 14],
});
});
it('handles empty resource_ids array', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 0,
current_usage: 10,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', []);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'resources',
resource_ids: [],
});
expect(result.archived_count).toBe(0);
});
});
describe('unarchiveResource', () => {
it('unarchives a resource successfully', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('resources', 5);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'resources',
resource_id: 5,
});
expect(result).toEqual(mockUnarchiveResponse);
expect(result.success).toBe(true);
expect(result.resource_id).toBe(5);
});
it('unarchives staff member', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 10,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('staff', 10);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'staff',
resource_id: 10,
});
expect(result.success).toBe(true);
});
it('unarchives service', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 20,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('services', 20);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'services',
resource_id: 20,
});
expect(result.resource_id).toBe(20);
});
it('handles unsuccessful unarchive', async () => {
const mockUnarchiveResponse = {
success: false,
resource_id: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('resources', 5);
expect(result.success).toBe(false);
});
});
describe('getOverageDetail', () => {
it('fetches detailed overage information', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-14T00:00:00Z',
status: 'active',
created_at: '2025-12-07T10:00:00Z',
initial_email_sent_at: '2025-12-07T10:05:00Z',
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(1);
expect(apiClient.get).toHaveBeenCalledWith('/quota/overages/1/');
expect(result).toEqual(mockOverageDetail);
expect(result.status).toBe('active');
expect(result.overage_amount).toBe(5);
});
it('includes sent email timestamps', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 2,
quota_type: 'staff',
display_name: 'Staff Members',
current_usage: 8,
allowed_limit: 5,
overage_amount: 3,
days_remaining: 3,
grace_period_ends_at: '2025-12-10T00:00:00Z',
status: 'active',
created_at: '2025-11-30T10:00:00Z',
initial_email_sent_at: '2025-11-30T10:05:00Z',
week_reminder_sent_at: '2025-12-03T09:00:00Z',
day_reminder_sent_at: '2025-12-06T09:00:00Z',
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(2);
expect(result.initial_email_sent_at).toBe('2025-11-30T10:05:00Z');
expect(result.week_reminder_sent_at).toBe('2025-12-03T09:00:00Z');
expect(result.day_reminder_sent_at).toBe('2025-12-06T09:00:00Z');
});
it('includes archived resource IDs', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 3,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 10,
allowed_limit: 10,
overage_amount: 0,
days_remaining: 5,
grace_period_ends_at: '2025-12-12T00:00:00Z',
status: 'resolved',
created_at: '2025-12-01T10:00:00Z',
initial_email_sent_at: '2025-12-01T10:05:00Z',
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [1, 3, 5, 7],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(3);
expect(result.archived_resource_ids).toHaveLength(4);
expect(result.archived_resource_ids).toEqual([1, 3, 5, 7]);
expect(result.status).toBe('resolved');
});
it('handles resolved overage with zero overage_amount', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 4,
quota_type: 'services',
display_name: 'Services',
current_usage: 18,
allowed_limit: 20,
overage_amount: 0,
days_remaining: 0,
grace_period_ends_at: '2025-12-05T00:00:00Z',
status: 'resolved',
created_at: '2025-11-25T10:00:00Z',
initial_email_sent_at: '2025-11-25T10:05:00Z',
week_reminder_sent_at: '2025-11-28T09:00:00Z',
day_reminder_sent_at: null,
archived_resource_ids: [20, 21],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(4);
expect(result.overage_amount).toBe(0);
expect(result.status).toBe('resolved');
expect(result.current_usage).toBeLessThanOrEqual(result.allowed_limit);
});
it('handles expired overage', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 5,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 0,
grace_period_ends_at: '2025-12-06T00:00:00Z',
status: 'expired',
created_at: '2025-11-20T10:00:00Z',
initial_email_sent_at: '2025-11-20T10:05:00Z',
week_reminder_sent_at: '2025-11-27T09:00:00Z',
day_reminder_sent_at: '2025-12-05T09:00:00Z',
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(5);
expect(result.status).toBe('expired');
expect(result.days_remaining).toBe(0);
expect(result.overage_amount).toBeGreaterThan(0);
});
it('handles null email timestamps when no reminders sent', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 6,
quota_type: 'staff',
display_name: 'Staff Members',
current_usage: 6,
allowed_limit: 5,
overage_amount: 1,
days_remaining: 14,
grace_period_ends_at: '2025-12-21T00:00:00Z',
status: 'active',
created_at: '2025-12-07T10:00:00Z',
initial_email_sent_at: null,
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(6);
expect(result.initial_email_sent_at).toBeNull();
expect(result.week_reminder_sent_at).toBeNull();
expect(result.day_reminder_sent_at).toBeNull();
});
});
});

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
import {
getSandboxStatus,
toggleSandboxMode,
resetSandboxData,
} from '../sandbox';
import apiClient from '../client';
describe('sandbox API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getSandboxStatus', () => {
it('fetches sandbox status from API', async () => {
const mockStatus = {
sandbox_mode: true,
sandbox_enabled: true,
sandbox_schema: 'test_business_sandbox',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(apiClient.get).toHaveBeenCalledWith('/sandbox/status/');
expect(result).toEqual(mockStatus);
});
it('returns sandbox disabled status', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: false,
sandbox_schema: null,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(result.sandbox_mode).toBe(false);
expect(result.sandbox_enabled).toBe(false);
expect(result.sandbox_schema).toBeNull();
});
it('returns sandbox enabled but not active', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: true,
sandbox_schema: 'test_business_sandbox',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(result.sandbox_mode).toBe(false);
expect(result.sandbox_enabled).toBe(true);
expect(result.sandbox_schema).toBe('test_business_sandbox');
});
});
describe('toggleSandboxMode', () => {
it('enables sandbox mode', async () => {
const mockResponse = {
data: {
sandbox_mode: true,
message: 'Sandbox mode enabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await toggleSandboxMode(true);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: true,
});
expect(result.sandbox_mode).toBe(true);
expect(result.message).toBe('Sandbox mode enabled');
});
it('disables sandbox mode', async () => {
const mockResponse = {
data: {
sandbox_mode: false,
message: 'Sandbox mode disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await toggleSandboxMode(false);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: false,
});
expect(result.sandbox_mode).toBe(false);
expect(result.message).toBe('Sandbox mode disabled');
});
it('handles toggle with true parameter', async () => {
const mockResponse = {
data: {
sandbox_mode: true,
message: 'Switched to test data',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await toggleSandboxMode(true);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: true,
});
});
it('handles toggle with false parameter', async () => {
const mockResponse = {
data: {
sandbox_mode: false,
message: 'Switched to live data',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await toggleSandboxMode(false);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: false,
});
});
});
describe('resetSandboxData', () => {
it('resets sandbox data successfully', async () => {
const mockResponse = {
data: {
message: 'Sandbox data reset successfully',
sandbox_schema: 'test_business_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await resetSandboxData();
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/reset/');
expect(result.message).toBe('Sandbox data reset successfully');
expect(result.sandbox_schema).toBe('test_business_sandbox');
});
it('returns schema name after reset', async () => {
const mockResponse = {
data: {
message: 'Data reset complete',
sandbox_schema: 'my_company_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await resetSandboxData();
expect(result.sandbox_schema).toBe('my_company_sandbox');
});
it('calls reset endpoint without parameters', async () => {
const mockResponse = {
data: {
message: 'Reset successful',
sandbox_schema: 'test_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await resetSandboxData();
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/reset/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
});
});
describe('error handling', () => {
it('propagates errors from getSandboxStatus', async () => {
const error = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getSandboxStatus()).rejects.toThrow('Network error');
});
it('propagates errors from toggleSandboxMode', async () => {
const error = new Error('Unauthorized');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(toggleSandboxMode(true)).rejects.toThrow('Unauthorized');
});
it('propagates errors from resetSandboxData', async () => {
const error = new Error('Forbidden');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(resetSandboxData()).rejects.toThrow('Forbidden');
});
});
});

View File

@@ -0,0 +1,793 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTicketEmailAddresses,
getTicketEmailAddress,
createTicketEmailAddress,
updateTicketEmailAddress,
deleteTicketEmailAddress,
testImapConnection,
testSmtpConnection,
fetchEmailsNow,
setAsDefault,
TicketEmailAddressListItem,
TicketEmailAddress,
TicketEmailAddressCreate,
TestConnectionResponse,
FetchEmailsResponse,
} from '../ticketEmailAddresses';
import apiClient from '../client';
describe('ticketEmailAddresses API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTicketEmailAddresses', () => {
it('should fetch all ticket email addresses', async () => {
const mockAddresses: TicketEmailAddressListItem[] = [
{
id: 1,
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
is_active: true,
is_default: true,
last_check_at: '2025-12-07T10:00:00Z',
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
},
{
id: 2,
display_name: 'Sales',
email_address: 'sales@example.com',
color: '#3357FF',
is_active: true,
is_default: false,
emails_processed_count: 15,
created_at: '2025-12-02T10:00:00Z',
updated_at: '2025-12-05T10:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddresses });
const result = await getTicketEmailAddresses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
expect(result).toEqual(mockAddresses);
expect(result).toHaveLength(2);
});
it('should return empty array when no addresses exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketEmailAddresses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
it('should throw error when API call fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
await expect(getTicketEmailAddresses()).rejects.toThrow('Network error');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
});
});
describe('getTicketEmailAddress', () => {
it('should fetch a specific ticket email address by ID', async () => {
const mockAddress: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
last_check_at: '2025-12-07T10:00:00Z',
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddress });
const result = await getTicketEmailAddress(1);
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/1/');
expect(result).toEqual(mockAddress);
expect(result.id).toBe(1);
expect(result.email_address).toBe('support@example.com');
});
it('should handle fetching with different IDs', async () => {
const mockAddress: TicketEmailAddress = {
id: 999,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Sales',
email_address: 'sales@example.com',
color: '#3357FF',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'sales@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'sales@example.com',
is_active: true,
is_default: false,
emails_processed_count: 0,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-01T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddress });
const result = await getTicketEmailAddress(999);
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/999/');
expect(result.id).toBe(999);
});
it('should throw error when address not found', async () => {
const mockError = new Error('Not found');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
await expect(getTicketEmailAddress(999)).rejects.toThrow('Not found');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
});
describe('createTicketEmailAddress', () => {
it('should create a new ticket email address', async () => {
const createData: TicketEmailAddressCreate = {
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password: 'secure_password',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password: 'secure_password',
is_active: true,
is_default: false,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
...createData,
imap_password: undefined, // Passwords are not returned in response
smtp_password: undefined,
last_check_at: undefined,
last_error: undefined,
emails_processed_count: 0,
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTicketEmailAddress(createData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', createData);
expect(result).toEqual(mockResponse);
expect(result.id).toBe(1);
expect(result.display_name).toBe('Support');
});
it('should handle creating with minimal required fields', async () => {
const createData: TicketEmailAddressCreate = {
display_name: 'Minimal',
email_address: 'minimal@example.com',
color: '#000000',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'minimal@example.com',
imap_password: 'password',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: false,
smtp_use_ssl: false,
smtp_username: 'minimal@example.com',
smtp_password: 'password',
is_active: false,
is_default: false,
};
const mockResponse: TicketEmailAddress = {
id: 2,
tenant: 100,
tenant_name: 'Test Business',
...createData,
imap_password: undefined,
smtp_password: undefined,
emails_processed_count: 0,
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTicketEmailAddress(createData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', createData);
expect(result.id).toBe(2);
});
it('should throw error when validation fails', async () => {
const invalidData: TicketEmailAddressCreate = {
display_name: '',
email_address: 'invalid-email',
color: '#FF5733',
imap_host: '',
imap_port: 993,
imap_use_ssl: true,
imap_username: '',
imap_password: '',
imap_folder: 'INBOX',
smtp_host: '',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: '',
smtp_password: '',
is_active: true,
is_default: false,
};
const mockError = new Error('Validation error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(createTicketEmailAddress(invalidData)).rejects.toThrow('Validation error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', invalidData);
});
});
describe('updateTicketEmailAddress', () => {
it('should update an existing ticket email address', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
display_name: 'Updated Support',
color: '#00FF00',
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Updated Support',
email_address: 'support@example.com',
color: '#00FF00',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result).toEqual(mockResponse);
expect(result.display_name).toBe('Updated Support');
expect(result.color).toBe('#00FF00');
});
it('should update IMAP configuration', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
imap_host: 'imap.newserver.com',
imap_port: 993,
imap_password: 'new_password',
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.newserver.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.imap_host).toBe('imap.newserver.com');
});
it('should update SMTP configuration', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
smtp_host: 'smtp.newserver.com',
smtp_port: 465,
smtp_use_tls: false,
smtp_use_ssl: true,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.newserver.com',
smtp_port: 465,
smtp_use_tls: false,
smtp_use_ssl: true,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.smtp_host).toBe('smtp.newserver.com');
expect(result.smtp_port).toBe(465);
expect(result.smtp_use_ssl).toBe(true);
});
it('should toggle is_active status', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
is_active: false,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: false,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.is_active).toBe(false);
});
it('should throw error when update fails', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
display_name: 'Invalid',
};
const mockError = new Error('Update failed');
vi.mocked(apiClient.patch).mockRejectedValue(mockError);
await expect(updateTicketEmailAddress(1, updateData)).rejects.toThrow('Update failed');
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
});
});
describe('deleteTicketEmailAddress', () => {
it('should delete a ticket email address', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
await deleteTicketEmailAddress(1);
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/1/');
});
it('should handle deletion of different IDs', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
await deleteTicketEmailAddress(999);
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
it('should throw error when deletion fails', async () => {
const mockError = new Error('Cannot delete default address');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
await expect(deleteTicketEmailAddress(1)).rejects.toThrow('Cannot delete default address');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/1/');
});
it('should throw error when address not found', async () => {
const mockError = new Error('Not found');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
await expect(deleteTicketEmailAddress(999)).rejects.toThrow('Not found');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
});
describe('testImapConnection', () => {
it('should test IMAP connection successfully', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testImapConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('IMAP connection successful');
});
it('should handle failed IMAP connection', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'Authentication failed: Invalid credentials',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testImapConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid credentials');
});
it('should handle network errors during IMAP test', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(testImapConnection(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
});
it('should test IMAP connection for different addresses', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await testImapConnection(42);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/42/test_imap/');
});
});
describe('testSmtpConnection', () => {
it('should test SMTP connection successfully', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('SMTP connection successful');
});
it('should handle failed SMTP connection', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'Connection refused: Unable to connect to SMTP server',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
it('should handle TLS/SSL errors during SMTP test', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'SSL certificate verification failed',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(result.success).toBe(false);
expect(result.message).toContain('SSL certificate');
});
it('should handle network errors during SMTP test', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(testSmtpConnection(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
});
it('should test SMTP connection for different addresses', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await testSmtpConnection(99);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/99/test_smtp/');
});
});
describe('fetchEmailsNow', () => {
it('should fetch emails successfully', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Successfully processed 5 emails',
processed: 5,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.processed).toBe(5);
expect(result.errors).toBe(0);
});
it('should handle fetching with no new emails', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'No new emails to process',
processed: 0,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
expect(result.success).toBe(true);
expect(result.processed).toBe(0);
});
it('should handle errors during email processing', async () => {
const mockResponse: FetchEmailsResponse = {
success: false,
message: 'Failed to connect to IMAP server',
processed: 0,
errors: 1,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(result.success).toBe(false);
expect(result.errors).toBe(1);
expect(result.message).toContain('Failed to connect');
});
it('should handle partial processing with errors', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Processed 8 emails with 2 errors',
processed: 8,
errors: 2,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(result.success).toBe(true);
expect(result.processed).toBe(8);
expect(result.errors).toBe(2);
});
it('should handle network errors during fetch', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(fetchEmailsNow(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
});
it('should fetch emails for different addresses', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Successfully processed 3 emails',
processed: 3,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await fetchEmailsNow(42);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/42/fetch_now/');
});
});
describe('setAsDefault', () => {
it('should set email address as default successfully', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await setAsDefault(2);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/2/set_as_default/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('Email address set as default');
});
it('should handle setting default for different addresses', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await setAsDefault(99);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/99/set_as_default/');
});
it('should handle failure to set as default', async () => {
const mockResponse = {
success: false,
message: 'Cannot set inactive email as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await setAsDefault(1);
expect(result.success).toBe(false);
expect(result.message).toContain('Cannot set inactive');
});
it('should handle network errors when setting default', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(setAsDefault(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/set_as_default/');
});
it('should handle not found errors', async () => {
const mockError = new Error('Email address not found');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(setAsDefault(999)).rejects.toThrow('Email address not found');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/999/set_as_default/');
});
});
});

View File

@@ -0,0 +1,703 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTicketEmailSettings,
updateTicketEmailSettings,
testImapConnection,
testSmtpConnection,
testEmailConnection,
fetchEmailsNow,
getIncomingEmails,
reprocessIncomingEmail,
detectEmailProvider,
getOAuthStatus,
initiateGoogleOAuth,
initiateMicrosoftOAuth,
getOAuthCredentials,
deleteOAuthCredential,
type TicketEmailSettings,
type TicketEmailSettingsUpdate,
type TestConnectionResult,
type FetchNowResult,
type IncomingTicketEmail,
type EmailProviderDetectResult,
type OAuthStatusResult,
type OAuthInitiateResult,
type OAuthCredential,
} from '../ticketEmailSettings';
import apiClient from '../client';
describe('ticketEmailSettings API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTicketEmailSettings', () => {
it('should call GET /tickets/email-settings/', async () => {
const mockSettings: TicketEmailSettings = {
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password_masked: '***',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password_masked: '***',
smtp_from_email: 'support@example.com',
smtp_from_name: 'Support Team',
support_email_address: 'support@example.com',
support_email_domain: 'example.com',
is_enabled: true,
delete_after_processing: false,
check_interval_seconds: 300,
max_attachment_size_mb: 10,
allowed_attachment_types: ['pdf', 'jpg', 'png'],
last_check_at: '2025-12-07T10:00:00Z',
last_error: '',
emails_processed_count: 42,
is_configured: true,
is_imap_configured: true,
is_smtp_configured: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSettings });
const result = await getTicketEmailSettings();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-settings/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockSettings);
});
});
describe('updateTicketEmailSettings', () => {
it('should call PATCH /tickets/email-settings/ with update data', async () => {
const updateData: TicketEmailSettingsUpdate = {
imap_host: 'imap.outlook.com',
imap_port: 993,
is_enabled: true,
};
const mockResponse: TicketEmailSettings = {
imap_host: 'imap.outlook.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password_masked: '***',
imap_folder: 'INBOX',
smtp_host: 'smtp.outlook.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password_masked: '***',
smtp_from_email: 'support@example.com',
smtp_from_name: 'Support Team',
support_email_address: 'support@example.com',
support_email_domain: 'example.com',
is_enabled: true,
delete_after_processing: false,
check_interval_seconds: 300,
max_attachment_size_mb: 10,
allowed_attachment_types: ['pdf', 'jpg', 'png'],
last_check_at: null,
last_error: '',
emails_processed_count: 0,
is_configured: true,
is_imap_configured: true,
is_smtp_configured: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailSettings(updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-settings/', updateData);
expect(apiClient.patch).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle password updates', async () => {
const updateData: TicketEmailSettingsUpdate = {
imap_password: 'newpassword123',
smtp_password: 'newsmtppass456',
};
const mockResponse: TicketEmailSettings = {
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password_masked: '***',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password_masked: '***',
smtp_from_email: 'support@example.com',
smtp_from_name: 'Support Team',
support_email_address: 'support@example.com',
support_email_domain: 'example.com',
is_enabled: true,
delete_after_processing: false,
check_interval_seconds: 300,
max_attachment_size_mb: 10,
allowed_attachment_types: ['pdf', 'jpg', 'png'],
last_check_at: null,
last_error: '',
emails_processed_count: 0,
is_configured: true,
is_imap_configured: true,
is_smtp_configured: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailSettings(updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-settings/', updateData);
expect(result).toEqual(mockResponse);
});
});
describe('testImapConnection', () => {
it('should call POST /tickets/email-settings/test-imap/', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testImapConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-imap/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle connection failures', async () => {
const mockResult: TestConnectionResult = {
success: false,
message: 'Failed to connect: Invalid credentials',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testImapConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to connect');
});
});
describe('testSmtpConnection', () => {
it('should call POST /tickets/email-settings/test-smtp/', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testSmtpConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-smtp/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle SMTP connection failures', async () => {
const mockResult: TestConnectionResult = {
success: false,
message: 'SMTP error: Connection refused',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testSmtpConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
});
describe('testEmailConnection (legacy alias)', () => {
it('should be an alias for testImapConnection', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'Connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testEmailConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-imap/');
expect(result).toEqual(mockResult);
});
});
describe('fetchEmailsNow', () => {
it('should call POST /tickets/email-settings/fetch-now/', async () => {
const mockResult: FetchNowResult = {
success: true,
message: 'Successfully processed 5 emails',
processed: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await fetchEmailsNow();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/fetch-now/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle no new emails', async () => {
const mockResult: FetchNowResult = {
success: true,
message: 'No new emails found',
processed: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await fetchEmailsNow();
expect(result.processed).toBe(0);
expect(result.success).toBe(true);
});
});
describe('getIncomingEmails', () => {
it('should call GET /tickets/incoming-emails/ without params', async () => {
const mockEmails: IncomingTicketEmail[] = [
{
id: 1,
message_id: '<msg1@example.com>',
from_address: 'customer@example.com',
from_name: 'John Doe',
to_address: 'support@example.com',
subject: 'Help needed',
body_text: 'I need assistance with...',
extracted_reply: 'I need assistance with...',
ticket: 123,
ticket_subject: 'Help needed',
matched_user: 456,
ticket_id_from_email: '#123',
processing_status: 'PROCESSED',
processing_status_display: 'Processed',
error_message: '',
email_date: '2025-12-07T09:00:00Z',
received_at: '2025-12-07T09:01:00Z',
processed_at: '2025-12-07T09:02:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', { params: undefined });
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with status filter', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ status: 'FAILED' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { status: 'FAILED' },
});
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with ticket filter', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ ticket: 123 });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { ticket: 123 },
});
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with multiple filters', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ status: 'PROCESSED', ticket: 123 });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { status: 'PROCESSED', ticket: 123 },
});
expect(result).toEqual(mockEmails);
});
});
describe('reprocessIncomingEmail', () => {
it('should call POST /tickets/incoming-emails/:id/reprocess/', async () => {
const mockResponse = {
success: true,
message: 'Email reprocessed successfully',
comment_id: 789,
ticket_id: 123,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await reprocessIncomingEmail(456);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/incoming-emails/456/reprocess/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle reprocessing failures', async () => {
const mockResponse = {
success: false,
message: 'Failed to reprocess: Invalid email format',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await reprocessIncomingEmail(999);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/incoming-emails/999/reprocess/');
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to reprocess');
});
});
describe('detectEmailProvider', () => {
it('should call POST /tickets/email-settings/detect/ with email', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@gmail.com',
domain: 'gmail.com',
detected: true,
detected_via: 'domain_lookup',
provider: 'google',
display_name: 'Gmail',
imap_host: 'imap.gmail.com',
imap_port: 993,
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
oauth_supported: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@gmail.com');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/detect/', {
email: 'user@gmail.com',
});
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should detect Microsoft provider', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@outlook.com',
domain: 'outlook.com',
detected: true,
detected_via: 'domain_lookup',
provider: 'microsoft',
display_name: 'Outlook.com',
imap_host: 'outlook.office365.com',
imap_port: 993,
smtp_host: 'smtp.office365.com',
smtp_port: 587,
oauth_supported: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@outlook.com');
expect(result.provider).toBe('microsoft');
expect(result.oauth_supported).toBe(true);
});
it('should detect custom domain via MX records', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'admin@company.com',
domain: 'company.com',
detected: true,
detected_via: 'mx_record',
provider: 'google',
display_name: 'Google Workspace',
oauth_supported: true,
message: 'Detected Google Workspace via MX records',
notes: 'Use OAuth for best security',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('admin@company.com');
expect(result.detected_via).toBe('mx_record');
expect(result.provider).toBe('google');
});
it('should handle unknown provider', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@custom-server.com',
domain: 'custom-server.com',
detected: false,
provider: 'unknown',
display_name: 'Unknown Provider',
oauth_supported: false,
message: 'Could not auto-detect email provider',
suggested_imap_port: 993,
suggested_smtp_port: 587,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@custom-server.com');
expect(result.detected).toBe(false);
expect(result.provider).toBe('unknown');
expect(result.oauth_supported).toBe(false);
});
});
describe('getOAuthStatus', () => {
it('should call GET /oauth/status/', async () => {
const mockStatus: OAuthStatusResult = {
google: { configured: true },
microsoft: { configured: false },
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getOAuthStatus();
expect(apiClient.get).toHaveBeenCalledWith('/oauth/status/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockStatus);
});
it('should handle no OAuth configured', async () => {
const mockStatus: OAuthStatusResult = {
google: { configured: false },
microsoft: { configured: false },
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getOAuthStatus();
expect(result.google.configured).toBe(false);
expect(result.microsoft.configured).toBe(false);
});
});
describe('initiateGoogleOAuth', () => {
it('should call POST /oauth/google/initiate/ with default purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth();
expect(apiClient.post).toHaveBeenCalledWith('/oauth/google/initiate/', { purpose: 'email' });
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should call POST /oauth/google/initiate/ with custom purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth('calendar');
expect(apiClient.post).toHaveBeenCalledWith('/oauth/google/initiate/', { purpose: 'calendar' });
expect(result).toEqual(mockResult);
});
it('should handle OAuth initiation errors', async () => {
const mockResult: OAuthInitiateResult = {
success: false,
error: 'OAuth client credentials not configured',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth();
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('initiateMicrosoftOAuth', () => {
it('should call POST /oauth/microsoft/initiate/ with default purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth();
expect(apiClient.post).toHaveBeenCalledWith('/oauth/microsoft/initiate/', { purpose: 'email' });
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should call POST /oauth/microsoft/initiate/ with custom purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth('calendar');
expect(apiClient.post).toHaveBeenCalledWith('/oauth/microsoft/initiate/', {
purpose: 'calendar',
});
expect(result).toEqual(mockResult);
});
it('should handle Microsoft OAuth errors', async () => {
const mockResult: OAuthInitiateResult = {
success: false,
error: 'Microsoft OAuth not configured',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth();
expect(result.success).toBe(false);
expect(result.error).toBe('Microsoft OAuth not configured');
});
});
describe('getOAuthCredentials', () => {
it('should call GET /oauth/credentials/', async () => {
const mockCredentials: OAuthCredential[] = [
{
id: 1,
provider: 'google',
email: 'support@example.com',
purpose: 'email',
is_valid: true,
is_expired: false,
last_used_at: '2025-12-07T09:00:00Z',
last_error: '',
created_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
provider: 'microsoft',
email: 'admin@example.com',
purpose: 'email',
is_valid: false,
is_expired: true,
last_used_at: '2025-11-01T10:00:00Z',
last_error: 'Token expired',
created_at: '2025-01-15T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCredentials });
const result = await getOAuthCredentials();
expect(apiClient.get).toHaveBeenCalledWith('/oauth/credentials/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockCredentials);
expect(result).toHaveLength(2);
});
it('should handle empty credentials list', async () => {
const mockCredentials: OAuthCredential[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCredentials });
const result = await getOAuthCredentials();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('deleteOAuthCredential', () => {
it('should call DELETE /oauth/credentials/:id/', async () => {
const mockResponse = {
success: true,
message: 'OAuth credential deleted successfully',
};
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockResponse });
const result = await deleteOAuthCredential(123);
expect(apiClient.delete).toHaveBeenCalledWith('/oauth/credentials/123/');
expect(apiClient.delete).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle deletion of non-existent credential', async () => {
const mockResponse = {
success: false,
message: 'Credential not found',
};
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockResponse });
const result = await deleteOAuthCredential(999);
expect(apiClient.delete).toHaveBeenCalledWith('/oauth/credentials/999/');
expect(result.success).toBe(false);
});
});
});

View File

@@ -0,0 +1,577 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTickets,
getTicket,
createTicket,
updateTicket,
deleteTicket,
getTicketComments,
createTicketComment,
getTicketTemplates,
getTicketTemplate,
getCannedResponses,
refreshTicketEmails,
} from '../tickets';
import apiClient from '../client';
import type { Ticket, TicketComment, TicketTemplate, CannedResponse } from '../../types';
describe('tickets API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTickets', () => {
it('fetches all tickets without filters', async () => {
const mockTickets: Ticket[] = [
{
id: '1',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
ticketType: 'CUSTOMER',
status: 'OPEN',
priority: 'HIGH',
subject: 'Test Ticket',
description: 'Test description',
category: 'TECHNICAL',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: '2',
creator: 'user2',
creatorEmail: 'user2@example.com',
creatorFullName: 'User Two',
ticketType: 'PLATFORM',
status: 'IN_PROGRESS',
priority: 'MEDIUM',
subject: 'Another Ticket',
description: 'Another description',
category: 'BILLING',
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTickets });
const result = await getTickets();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/');
expect(result).toEqual(mockTickets);
});
it('applies status filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ status: 'OPEN' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?status=OPEN');
});
it('applies priority filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ priority: 'HIGH' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?priority=HIGH');
});
it('applies category filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ category: 'TECHNICAL' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?category=TECHNICAL');
});
it('applies ticketType filter with snake_case conversion', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ ticketType: 'CUSTOMER' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?ticket_type=CUSTOMER');
});
it('applies assignee filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ assignee: 'user123' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?assignee=user123');
});
it('applies multiple filters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({
status: 'OPEN',
priority: 'HIGH',
category: 'BILLING',
ticketType: 'CUSTOMER',
assignee: 'user456',
});
expect(apiClient.get).toHaveBeenCalledWith(
'/tickets/?status=OPEN&priority=HIGH&category=BILLING&ticket_type=CUSTOMER&assignee=user456'
);
});
it('applies partial filters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ status: 'CLOSED', priority: 'LOW' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?status=CLOSED&priority=LOW');
});
it('handles empty filters object', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({});
expect(apiClient.get).toHaveBeenCalledWith('/tickets/');
});
});
describe('getTicket', () => {
it('fetches a single ticket by ID', async () => {
const mockTicket: Ticket = {
id: '123',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
assignee: 'user2',
assigneeEmail: 'user2@example.com',
assigneeFullName: 'User Two',
ticketType: 'CUSTOMER',
status: 'IN_PROGRESS',
priority: 'HIGH',
subject: 'Important Ticket',
description: 'This needs attention',
category: 'TECHNICAL',
relatedAppointmentId: 'appt-456',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTicket });
const result = await getTicket('123');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/123/');
expect(result).toEqual(mockTicket);
});
});
describe('createTicket', () => {
it('creates a new ticket', async () => {
const newTicketData: Partial<Ticket> = {
subject: 'New Ticket',
description: 'New ticket description',
ticketType: 'CUSTOMER',
priority: 'MEDIUM',
category: 'GENERAL_INQUIRY',
};
const createdTicket: Ticket = {
id: '789',
creator: 'current-user',
creatorEmail: 'current@example.com',
creatorFullName: 'Current User',
status: 'OPEN',
createdAt: '2024-01-03T00:00:00Z',
updatedAt: '2024-01-03T00:00:00Z',
...newTicketData,
} as Ticket;
vi.mocked(apiClient.post).mockResolvedValue({ data: createdTicket });
const result = await createTicket(newTicketData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/', newTicketData);
expect(result).toEqual(createdTicket);
});
it('creates a ticket with all optional fields', async () => {
const newTicketData: Partial<Ticket> = {
subject: 'Complex Ticket',
description: 'Complex description',
ticketType: 'STAFF_REQUEST',
priority: 'URGENT',
category: 'TIME_OFF',
assignee: 'manager-123',
relatedAppointmentId: 'appt-999',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await createTicket(newTicketData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/', newTicketData);
});
});
describe('updateTicket', () => {
it('updates a ticket', async () => {
const updateData: Partial<Ticket> = {
status: 'RESOLVED',
priority: 'LOW',
};
const updatedTicket: Ticket = {
id: '123',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
ticketType: 'CUSTOMER',
subject: 'Existing Ticket',
description: 'Existing description',
category: 'TECHNICAL',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-05T00:00:00Z',
...updateData,
} as Ticket;
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedTicket });
const result = await updateTicket('123', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/123/', updateData);
expect(result).toEqual(updatedTicket);
});
it('updates ticket assignee', async () => {
const updateData = { assignee: 'new-assignee-456' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
await updateTicket('123', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/123/', updateData);
});
it('updates multiple ticket fields', async () => {
const updateData: Partial<Ticket> = {
status: 'CLOSED',
priority: 'LOW',
assignee: 'user789',
category: 'RESOLVED',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
await updateTicket('456', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/456/', updateData);
});
});
describe('deleteTicket', () => {
it('deletes a ticket', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteTicket('123');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/123/');
});
it('returns void', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await deleteTicket('456');
expect(result).toBeUndefined();
});
});
describe('getTicketComments', () => {
it('fetches all comments for a ticket', async () => {
const mockComments: TicketComment[] = [
{
id: 'c1',
ticket: 't1',
author: 'user1',
authorEmail: 'user1@example.com',
authorFullName: 'User One',
commentText: 'First comment',
createdAt: '2024-01-01T00:00:00Z',
isInternal: false,
},
{
id: 'c2',
ticket: 't1',
author: 'user2',
authorEmail: 'user2@example.com',
authorFullName: 'User Two',
commentText: 'Second comment',
createdAt: '2024-01-02T00:00:00Z',
isInternal: true,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockComments });
const result = await getTicketComments('t1');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/t1/comments/');
expect(result).toEqual(mockComments);
});
it('handles ticket with no comments', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketComments('t999');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/t999/comments/');
expect(result).toEqual([]);
});
});
describe('createTicketComment', () => {
it('creates a new comment on a ticket', async () => {
const commentData: Partial<TicketComment> = {
commentText: 'This is a new comment',
isInternal: false,
};
const createdComment: TicketComment = {
id: 'c123',
ticket: 't1',
author: 'current-user',
authorEmail: 'current@example.com',
authorFullName: 'Current User',
createdAt: '2024-01-03T00:00:00Z',
...commentData,
} as TicketComment;
vi.mocked(apiClient.post).mockResolvedValue({ data: createdComment });
const result = await createTicketComment('t1', commentData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/t1/comments/', commentData);
expect(result).toEqual(createdComment);
});
it('creates an internal comment', async () => {
const commentData: Partial<TicketComment> = {
commentText: 'Internal note',
isInternal: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await createTicketComment('t2', commentData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/t2/comments/', commentData);
});
});
describe('getTicketTemplates', () => {
it('fetches all ticket templates', async () => {
const mockTemplates: TicketTemplate[] = [
{
id: 'tmpl1',
name: 'Bug Report Template',
description: 'Template for bug reports',
ticketType: 'CUSTOMER',
category: 'TECHNICAL',
defaultPriority: 'HIGH',
subjectTemplate: 'Bug: {{title}}',
descriptionTemplate: 'Steps to reproduce:\n{{steps}}',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'tmpl2',
tenant: 'tenant123',
name: 'Time Off Request',
description: 'Staff time off template',
ticketType: 'STAFF_REQUEST',
category: 'TIME_OFF',
defaultPriority: 'MEDIUM',
subjectTemplate: 'Time Off: {{dates}}',
descriptionTemplate: 'Reason:\n{{reason}}',
isActive: true,
createdAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplates });
const result = await getTicketTemplates();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/templates/');
expect(result).toEqual(mockTemplates);
});
it('handles empty template list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketTemplates();
expect(result).toEqual([]);
});
});
describe('getTicketTemplate', () => {
it('fetches a single ticket template by ID', async () => {
const mockTemplate: TicketTemplate = {
id: 'tmpl123',
name: 'Feature Request Template',
description: 'Template for feature requests',
ticketType: 'CUSTOMER',
category: 'FEATURE_REQUEST',
defaultPriority: 'LOW',
subjectTemplate: 'Feature Request: {{feature}}',
descriptionTemplate: 'Description:\n{{description}}\n\nBenefit:\n{{benefit}}',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplate });
const result = await getTicketTemplate('tmpl123');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/templates/tmpl123/');
expect(result).toEqual(mockTemplate);
});
});
describe('getCannedResponses', () => {
it('fetches all canned responses', async () => {
const mockResponses: CannedResponse[] = [
{
id: 'cr1',
title: 'Thank You Response',
content: 'Thank you for contacting us. We will get back to you soon.',
category: 'GENERAL_INQUIRY',
isActive: true,
useCount: 42,
createdBy: 'admin',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'cr2',
tenant: 'tenant456',
title: 'Billing Issue',
content: 'We have received your billing inquiry and are investigating.',
category: 'BILLING',
isActive: true,
useCount: 18,
createdBy: 'manager',
createdAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponses });
const result = await getCannedResponses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/canned-responses/');
expect(result).toEqual(mockResponses);
});
it('handles empty canned responses list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getCannedResponses();
expect(result).toEqual([]);
});
});
describe('refreshTicketEmails', () => {
it('successfully refreshes ticket emails', async () => {
const mockResult = {
success: true,
processed: 5,
results: [
{
address: 'support@example.com',
display_name: 'Support',
processed: 3,
status: 'success',
last_check_at: '2024-01-05T12:00:00Z',
},
{
address: 'help@example.com',
display_name: 'Help Desk',
processed: 2,
status: 'success',
last_check_at: '2024-01-05T12:00:00Z',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/refresh-emails/');
expect(result.success).toBe(true);
expect(result.processed).toBe(5);
expect(result.results).toHaveLength(2);
});
it('handles refresh with errors', async () => {
const mockResult = {
success: false,
processed: 0,
results: [
{
address: 'invalid@example.com',
display_name: 'Invalid Email',
status: 'error',
error: 'Connection timeout',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(false);
expect(result.processed).toBe(0);
expect(result.results[0].status).toBe('error');
expect(result.results[0].error).toBe('Connection timeout');
});
it('handles partial success', async () => {
const mockResult = {
success: true,
processed: 2,
results: [
{
address: 'working@example.com',
processed: 2,
status: 'success',
},
{
address: null,
status: 'skipped',
message: 'No email address configured',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(true);
expect(result.processed).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0].status).toBe('success');
expect(result.results[1].status).toBe('skipped');
});
it('handles no configured email addresses', async () => {
const mockResult = {
success: false,
processed: 0,
results: [],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(false);
expect(result.processed).toBe(0);
expect(result.results).toHaveLength(0);
});
});
});

View File

@@ -5,12 +5,23 @@
import apiClient from './client';
export interface LoginCredentials {
username: string;
email: string;
password: string;
}
import { UserRole } from '../types';
export interface QuotaOverage {
id: number;
quota_type: string;
display_name: string;
current_usage: number;
allowed_limit: number;
overage_amount: number;
days_remaining: number;
grace_period_ends_at: string;
}
export interface MasqueradeStackEntry {
user_id: number;
username: string;
@@ -36,6 +47,7 @@ export interface LoginResponse {
business?: number;
business_name?: string;
business_subdomain?: string;
can_send_messages?: boolean;
};
masquerade_stack?: MasqueradeStackEntry[];
// MFA challenge response
@@ -58,13 +70,20 @@ export interface User {
business?: number;
business_name?: string;
business_subdomain?: string;
permissions?: Record<string, boolean>;
can_invite_staff?: boolean;
can_access_tickets?: boolean;
can_edit_schedule?: boolean;
can_send_messages?: boolean;
linked_resource_id?: number;
quota_overages?: QuotaOverage[];
}
/**
* Login user
*/
export const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>('/api/auth/login/', credentials);
const response = await apiClient.post<LoginResponse>('/auth/login/', credentials);
return response.data;
};
@@ -72,14 +91,14 @@ export const login = async (credentials: LoginCredentials): Promise<LoginRespons
* Logout user
*/
export const logout = async (): Promise<void> => {
await apiClient.post('/api/auth/logout/');
await apiClient.post('/auth/logout/');
};
/**
* Get current user
*/
export const getCurrentUser = async (): Promise<User> => {
const response = await apiClient.get<User>('/api/auth/me/');
const response = await apiClient.get<User>('/auth/me/');
return response.data;
};
@@ -87,7 +106,7 @@ export const getCurrentUser = async (): Promise<User> => {
* Refresh access token
*/
export const refreshToken = async (refresh: string): Promise<{ access: string }> => {
const response = await apiClient.post('/api/auth/refresh/', { refresh });
const response = await apiClient.post('/auth/refresh/', { refresh });
return response.data;
};
@@ -99,7 +118,7 @@ export const masquerade = async (
hijack_history?: MasqueradeStackEntry[]
): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>(
'/api/auth/hijack/acquire/',
'/auth/hijack/acquire/',
{ user_pk, hijack_history }
);
return response.data;
@@ -112,8 +131,16 @@ export const stopMasquerade = async (
masquerade_stack: MasqueradeStackEntry[]
): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>(
'/api/auth/hijack/release/',
'/auth/hijack/release/',
{ masquerade_stack }
);
return response.data;
};
/**
* Request password reset email
*/
export const forgotPassword = async (email: string): Promise<{ message: string }> => {
const response = await apiClient.post<{ message: string }>('/auth/password-reset/', { email });
return response.data;
};

184
frontend/src/api/billing.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* Billing API
*
* API client functions for the billing/subscription system.
*/
import apiClient from './client';
// ============================================================================
// Types
// ============================================================================
/**
* Entitlements - a map of feature codes to their values.
* Boolean features indicate permission (true/false).
* Integer features indicate limits.
*/
export interface Entitlements {
[key: string]: boolean | number | null;
}
/**
* Plan information (nested in PlanVersion)
*/
export interface Plan {
code: string;
name: string;
description?: string;
}
/**
* Plan version with pricing and features
*/
export interface PlanVersion {
id: number;
name: string;
is_legacy: boolean;
is_public?: boolean;
plan: Plan;
price_monthly_cents: number;
price_yearly_cents: number;
features?: PlanFeature[];
}
/**
* Feature attached to a plan version
*/
export interface PlanFeature {
feature_code: string;
feature_name: string;
feature_type: 'boolean' | 'integer';
bool_value?: boolean;
int_value?: number;
}
/**
* Current subscription
*/
export interface Subscription {
id: number;
status: 'active' | 'canceled' | 'past_due' | 'trialing';
plan_version: PlanVersion;
current_period_start: string;
current_period_end: string;
canceled_at?: string;
stripe_subscription_id?: string;
}
/**
* Add-on product
*/
export interface AddOnProduct {
id: number;
code: string;
name: string;
description?: string;
price_monthly_cents: number;
is_active: boolean;
}
/**
* Invoice line item
*/
export interface InvoiceLine {
id: number;
line_type: 'plan' | 'addon' | 'adjustment' | 'credit';
description: string;
quantity: number;
unit_amount: number;
total_amount: number;
}
/**
* Invoice
*/
export interface Invoice {
id: number;
status: 'draft' | 'pending' | 'paid' | 'void' | 'uncollectible';
period_start: string;
period_end: string;
subtotal_amount: number;
total_amount: number;
plan_name_at_billing: string;
plan_code_at_billing?: string;
created_at: string;
paid_at?: string;
lines?: InvoiceLine[];
}
// ============================================================================
// API Functions
// ============================================================================
/**
* Get effective entitlements for the current business.
* Returns a map of feature codes to their values.
*/
export const getEntitlements = async (): Promise<Entitlements> => {
try {
const response = await apiClient.get<Entitlements>('/me/entitlements/');
return response.data;
} catch (error) {
console.error('Failed to fetch entitlements:', error);
return {};
}
};
/**
* Get the current subscription for the business.
* Returns null if no subscription exists.
*/
export const getCurrentSubscription = async (): Promise<Subscription | null> => {
try {
const response = await apiClient.get<Subscription>('/me/subscription/');
return response.data;
} catch (error: any) {
if (error?.response?.status === 404) {
return null;
}
console.error('Failed to fetch subscription:', error);
throw error;
}
};
/**
* Get available plans (public, non-legacy plans).
*/
export const getPlans = async (): Promise<PlanVersion[]> => {
const response = await apiClient.get<PlanVersion[]>('/billing/plans/');
return response.data;
};
/**
* Get available add-on products.
*/
export const getAddOns = async (): Promise<AddOnProduct[]> => {
const response = await apiClient.get<AddOnProduct[]>('/billing/addons/');
return response.data;
};
/**
* Get invoices for the current business.
*/
export const getInvoices = async (): Promise<Invoice[]> => {
const response = await apiClient.get<Invoice[]>('/billing/invoices/');
return response.data;
};
/**
* Get a single invoice by ID.
* Returns null if not found.
*/
export const getInvoice = async (invoiceId: number): Promise<Invoice | null> => {
try {
const response = await apiClient.get<Invoice>(`/billing/invoices/${invoiceId}/`);
return response.data;
} catch (error: any) {
if (error?.response?.status === 404) {
return null;
}
console.error('Failed to fetch invoice:', error);
throw error;
}
};

View File

@@ -9,7 +9,7 @@ import { User, Resource, BusinessOAuthSettings, BusinessOAuthSettingsResponse, B
* Get all resources for the current business
*/
export const getResources = async (): Promise<Resource[]> => {
const response = await apiClient.get<Resource[]>('/api/resources/');
const response = await apiClient.get<Resource[]>('/resources/');
return response.data;
};
@@ -17,7 +17,7 @@ export const getResources = async (): Promise<Resource[]> => {
* Get all users for the current business
*/
export const getBusinessUsers = async (): Promise<User[]> => {
const response = await apiClient.get<User[]>('/api/business/users/');
const response = await apiClient.get<User[]>('/business/users/');
return response.data;
};
@@ -38,7 +38,7 @@ export const getBusinessOAuthSettings = async (): Promise<BusinessOAuthSettingsR
icon: string;
description: string;
}>;
}>('/api/business/oauth-settings/');
}>('/business/oauth-settings/');
// Transform snake_case to camelCase
return {
@@ -87,7 +87,7 @@ export const updateBusinessOAuthSettings = async (
icon: string;
description: string;
}>;
}>('/api/business/oauth-settings/', backendData);
}>('/business/oauth-settings/', backendData);
// Transform snake_case to camelCase
return {
@@ -112,7 +112,7 @@ export const getBusinessOAuthCredentials = async (): Promise<BusinessOAuthCreden
has_secret: boolean;
}>;
use_custom_credentials: boolean;
}>('/api/business/oauth-credentials/');
}>('/business/oauth-credentials/');
return {
credentials: response.data.credentials || {},
@@ -145,7 +145,7 @@ export const updateBusinessOAuthCredentials = async (
has_secret: boolean;
}>;
use_custom_credentials: boolean;
}>('/api/business/oauth-credentials/', backendData);
}>('/business/oauth-credentials/', backendData);
return {
credentials: response.data.credentials || {},

View File

@@ -71,7 +71,7 @@ apiClient.interceptors.response.use(
// Try to refresh token (from cookie)
const refreshToken = getCookie('refresh_token');
if (refreshToken) {
const response = await axios.post(`${API_BASE_URL}/api/auth/refresh/`, {
const response = await axios.post(`${API_BASE_URL}/auth/refresh/`, {
refresh: refreshToken,
});
@@ -88,11 +88,15 @@ apiClient.interceptors.response.use(
return apiClient(originalRequest);
}
} catch (refreshError) {
// Refresh failed - clear tokens and redirect to login
// Refresh failed - clear tokens and redirect to login on root domain
const { deleteCookie } = await import('../utils/cookies');
const { getBaseDomain } = await import('../utils/domain');
deleteCookie('access_token');
deleteCookie('refresh_token');
window.location.href = '/login';
const protocol = window.location.protocol;
const baseDomain = getBaseDomain();
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `${protocol}//${baseDomain}${port}/login`;
return Promise.reject(refreshError);
}
}

View File

@@ -3,6 +3,8 @@
* Centralized configuration for API endpoints and settings
*/
import { getBaseDomain, isRootDomain } from '../utils/domain';
// Determine API base URL based on environment
const getApiBaseUrl = (): string => {
// In production, this would be set via environment variable
@@ -10,8 +12,15 @@ const getApiBaseUrl = (): string => {
return import.meta.env.VITE_API_URL;
}
// Development: use api subdomain
return 'http://api.lvh.me:8000';
// Development: build API URL dynamically based on current domain
const baseDomain = getBaseDomain();
const protocol = window.location.protocol;
// For localhost or lvh.me, use port 8000
const isDev = baseDomain === 'localhost' || baseDomain === 'lvh.me';
const port = isDev ? ':8000' : '';
return `${protocol}//api.${baseDomain}${port}`;
};
export const API_BASE_URL = getApiBaseUrl();
@@ -24,8 +33,8 @@ export const getSubdomain = (): string | null => {
const hostname = window.location.hostname;
const parts = hostname.split('.');
// lvh.me without subdomain (root domain) - no business context
if (hostname === 'lvh.me') {
// Root domain (no subdomain) - no business context
if (isRootDomain()) {
return null;
}

View File

@@ -9,7 +9,7 @@ import { CustomDomain } from '../types';
* Get all custom domains for the current business
*/
export const getCustomDomains = async (): Promise<CustomDomain[]> => {
const response = await apiClient.get<CustomDomain[]>('/api/business/domains/');
const response = await apiClient.get<CustomDomain[]>('/business/domains/');
return response.data;
};
@@ -17,7 +17,7 @@ export const getCustomDomains = async (): Promise<CustomDomain[]> => {
* Add a new custom domain
*/
export const addCustomDomain = async (domain: string): Promise<CustomDomain> => {
const response = await apiClient.post<CustomDomain>('/api/business/domains/', {
const response = await apiClient.post<CustomDomain>('/business/domains/', {
domain: domain.toLowerCase().trim(),
});
return response.data;
@@ -27,7 +27,7 @@ export const addCustomDomain = async (domain: string): Promise<CustomDomain> =>
* Delete a custom domain
*/
export const deleteCustomDomain = async (domainId: number): Promise<void> => {
await apiClient.delete(`/api/business/domains/${domainId}/`);
await apiClient.delete(`/business/domains/${domainId}/`);
};
/**
@@ -35,7 +35,7 @@ export const deleteCustomDomain = async (domainId: number): Promise<void> => {
*/
export const verifyCustomDomain = async (domainId: number): Promise<{ verified: boolean; message: string }> => {
const response = await apiClient.post<{ verified: boolean; message: string }>(
`/api/business/domains/${domainId}/verify/`
`/business/domains/${domainId}/verify/`
);
return response.data;
};
@@ -45,7 +45,7 @@ export const verifyCustomDomain = async (domainId: number): Promise<{ verified:
*/
export const setPrimaryDomain = async (domainId: number): Promise<CustomDomain> => {
const response = await apiClient.post<CustomDomain>(
`/api/business/domains/${domainId}/set-primary/`
`/business/domains/${domainId}/set-primary/`
);
return response.data;
};

View File

@@ -79,7 +79,7 @@ export const searchDomains = async (
query: string,
tlds: string[] = ['.com', '.net', '.org']
): Promise<DomainAvailability[]> => {
const response = await apiClient.post<DomainAvailability[]>('/api/domains/search/search/', {
const response = await apiClient.post<DomainAvailability[]>('/domains/search/search/', {
query,
tlds,
});
@@ -90,7 +90,7 @@ export const searchDomains = async (
* Get TLD pricing
*/
export const getDomainPrices = async (): Promise<DomainPrice[]> => {
const response = await apiClient.get<DomainPrice[]>('/api/domains/search/prices/');
const response = await apiClient.get<DomainPrice[]>('/domains/search/prices/');
return response.data;
};
@@ -100,7 +100,7 @@ export const getDomainPrices = async (): Promise<DomainPrice[]> => {
export const registerDomain = async (
data: DomainRegisterRequest
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>('/api/domains/search/register/', data);
const response = await apiClient.post<DomainRegistration>('/domains/search/register/', data);
return response.data;
};
@@ -108,7 +108,7 @@ export const registerDomain = async (
* Get all registered domains for current business
*/
export const getRegisteredDomains = async (): Promise<DomainRegistration[]> => {
const response = await apiClient.get<DomainRegistration[]>('/api/domains/registrations/');
const response = await apiClient.get<DomainRegistration[]>('/domains/registrations/');
return response.data;
};
@@ -116,7 +116,7 @@ export const getRegisteredDomains = async (): Promise<DomainRegistration[]> => {
* Get a single domain registration
*/
export const getDomainRegistration = async (id: number): Promise<DomainRegistration> => {
const response = await apiClient.get<DomainRegistration>(`/api/domains/registrations/${id}/`);
const response = await apiClient.get<DomainRegistration>(`/domains/registrations/${id}/`);
return response.data;
};
@@ -128,7 +128,7 @@ export const updateNameservers = async (
nameservers: string[]
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/update_nameservers/`,
`/domains/registrations/${id}/update_nameservers/`,
{ nameservers }
);
return response.data;
@@ -142,7 +142,7 @@ export const toggleAutoRenew = async (
autoRenew: boolean
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/toggle_auto_renew/`,
`/domains/registrations/${id}/toggle_auto_renew/`,
{ auto_renew: autoRenew }
);
return response.data;
@@ -156,7 +156,7 @@ export const renewDomain = async (
years: number = 1
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/renew/`,
`/domains/registrations/${id}/renew/`,
{ years }
);
return response.data;
@@ -167,7 +167,7 @@ export const renewDomain = async (
*/
export const syncDomain = async (id: number): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/sync/`
`/domains/registrations/${id}/sync/`
);
return response.data;
};
@@ -176,6 +176,6 @@ export const syncDomain = async (id: number): Promise<DomainRegistration> => {
* Get domain search history
*/
export const getSearchHistory = async (): Promise<DomainSearchHistory[]> => {
const response = await apiClient.get<DomainSearchHistory[]>('/api/domains/history/');
const response = await apiClient.get<DomainSearchHistory[]>('/domains/history/');
return response.data;
};

View File

@@ -90,7 +90,7 @@ export interface MFAVerifyResponse {
* Get current MFA status
*/
export const getMFAStatus = async (): Promise<MFAStatus> => {
const response = await apiClient.get<MFAStatus>('/api/auth/mfa/status/');
const response = await apiClient.get<MFAStatus>('/auth/mfa/status/');
return response.data;
};
@@ -102,7 +102,7 @@ export const getMFAStatus = async (): Promise<MFAStatus> => {
* Send phone verification code
*/
export const sendPhoneVerification = async (phone: string): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/auth/mfa/phone/send/', { phone });
const response = await apiClient.post('/auth/mfa/phone/send/', { phone });
return response.data;
};
@@ -110,7 +110,7 @@ export const sendPhoneVerification = async (phone: string): Promise<{ success: b
* Verify phone number with code
*/
export const verifyPhone = async (code: string): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/auth/mfa/phone/verify/', { code });
const response = await apiClient.post('/auth/mfa/phone/verify/', { code });
return response.data;
};
@@ -118,7 +118,7 @@ export const verifyPhone = async (code: string): Promise<{ success: boolean; mes
* Enable SMS MFA (requires verified phone)
*/
export const enableSMSMFA = async (): Promise<MFAEnableResponse> => {
const response = await apiClient.post<MFAEnableResponse>('/api/auth/mfa/sms/enable/');
const response = await apiClient.post<MFAEnableResponse>('/auth/mfa/sms/enable/');
return response.data;
};
@@ -130,7 +130,7 @@ export const enableSMSMFA = async (): Promise<MFAEnableResponse> => {
* Initialize TOTP setup (returns QR code and secret)
*/
export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
const response = await apiClient.post<TOTPSetupResponse>('/api/auth/mfa/totp/setup/');
const response = await apiClient.post<TOTPSetupResponse>('/auth/mfa/totp/setup/');
return response.data;
};
@@ -138,7 +138,7 @@ export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
* Verify TOTP code to complete setup
*/
export const verifyTOTPSetup = async (code: string): Promise<MFAEnableResponse> => {
const response = await apiClient.post<MFAEnableResponse>('/api/auth/mfa/totp/verify/', { code });
const response = await apiClient.post<MFAEnableResponse>('/auth/mfa/totp/verify/', { code });
return response.data;
};
@@ -150,7 +150,7 @@ export const verifyTOTPSetup = async (code: string): Promise<MFAEnableResponse>
* Generate new backup codes (invalidates old ones)
*/
export const generateBackupCodes = async (): Promise<BackupCodesResponse> => {
const response = await apiClient.post<BackupCodesResponse>('/api/auth/mfa/backup-codes/');
const response = await apiClient.post<BackupCodesResponse>('/auth/mfa/backup-codes/');
return response.data;
};
@@ -158,7 +158,7 @@ export const generateBackupCodes = async (): Promise<BackupCodesResponse> => {
* Get backup codes status
*/
export const getBackupCodesStatus = async (): Promise<BackupCodesStatus> => {
const response = await apiClient.get<BackupCodesStatus>('/api/auth/mfa/backup-codes/status/');
const response = await apiClient.get<BackupCodesStatus>('/auth/mfa/backup-codes/status/');
return response.data;
};
@@ -170,7 +170,7 @@ export const getBackupCodesStatus = async (): Promise<BackupCodesStatus> => {
* Disable MFA (requires password or valid MFA code)
*/
export const disableMFA = async (credentials: { password?: string; mfa_code?: string }): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/auth/mfa/disable/', credentials);
const response = await apiClient.post('/auth/mfa/disable/', credentials);
return response.data;
};
@@ -182,7 +182,7 @@ export const disableMFA = async (credentials: { password?: string; mfa_code?: st
* Send MFA code for login (SMS only)
*/
export const sendMFALoginCode = async (userId: number, method: 'SMS' | 'TOTP' = 'SMS'): Promise<{ success: boolean; message: string; method: string }> => {
const response = await apiClient.post('/api/auth/mfa/login/send/', { user_id: userId, method });
const response = await apiClient.post('/auth/mfa/login/send/', { user_id: userId, method });
return response.data;
};
@@ -195,7 +195,7 @@ export const verifyMFALogin = async (
method: 'SMS' | 'TOTP' | 'BACKUP',
trustDevice: boolean = false
): Promise<MFAVerifyResponse> => {
const response = await apiClient.post<MFAVerifyResponse>('/api/auth/mfa/login/verify/', {
const response = await apiClient.post<MFAVerifyResponse>('/auth/mfa/login/verify/', {
user_id: userId,
code,
method,
@@ -212,7 +212,7 @@ export const verifyMFALogin = async (
* List trusted devices
*/
export const listTrustedDevices = async (): Promise<{ devices: TrustedDevice[] }> => {
const response = await apiClient.get('/api/auth/mfa/devices/');
const response = await apiClient.get('/auth/mfa/devices/');
return response.data;
};
@@ -220,7 +220,7 @@ export const listTrustedDevices = async (): Promise<{ devices: TrustedDevice[] }
* Revoke a specific trusted device
*/
export const revokeTrustedDevice = async (deviceId: number): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/api/auth/mfa/devices/${deviceId}/`);
const response = await apiClient.delete(`/auth/mfa/devices/${deviceId}/`);
return response.data;
};
@@ -228,6 +228,6 @@ export const revokeTrustedDevice = async (deviceId: number): Promise<{ success:
* Revoke all trusted devices
*/
export const revokeAllTrustedDevices = async (): Promise<{ success: boolean; message: string; count: number }> => {
const response = await apiClient.delete('/api/auth/mfa/devices/revoke-all/');
const response = await apiClient.delete('/auth/mfa/devices/revoke-all/');
return response.data;
};

View File

@@ -29,7 +29,7 @@ export const getNotifications = async (params?: { read?: boolean; limit?: number
queryParams.append('limit', String(params.limit));
}
const query = queryParams.toString();
const url = query ? `/api/notifications/?${query}` : '/api/notifications/';
const url = query ? `/notifications/?${query}` : '/notifications/';
const response = await apiClient.get(url);
return response.data;
};
@@ -38,7 +38,7 @@ export const getNotifications = async (params?: { read?: boolean; limit?: number
* Get count of unread notifications
*/
export const getUnreadCount = async (): Promise<number> => {
const response = await apiClient.get<UnreadCountResponse>('/api/notifications/unread_count/');
const response = await apiClient.get<UnreadCountResponse>('/notifications/unread_count/');
return response.data.count;
};
@@ -46,19 +46,19 @@ export const getUnreadCount = async (): Promise<number> => {
* Mark a single notification as read
*/
export const markNotificationRead = async (id: number): Promise<void> => {
await apiClient.post(`/api/notifications/${id}/mark_read/`);
await apiClient.post(`/notifications/${id}/mark_read/`);
};
/**
* Mark all notifications as read
*/
export const markAllNotificationsRead = async (): Promise<void> => {
await apiClient.post('/api/notifications/mark_all_read/');
await apiClient.post('/notifications/mark_all_read/');
};
/**
* Delete all read notifications
*/
export const clearAllNotifications = async (): Promise<void> => {
await apiClient.delete('/api/notifications/clear_all/');
await apiClient.delete('/notifications/clear_all/');
};

View File

@@ -45,7 +45,7 @@ export interface OAuthConnection {
* Get list of enabled OAuth providers
*/
export const getOAuthProviders = async (): Promise<OAuthProvider[]> => {
const response = await apiClient.get<{ providers: OAuthProvider[] }>('/api/auth/oauth/providers/');
const response = await apiClient.get<{ providers: OAuthProvider[] }>('/auth/oauth/providers/');
return response.data.providers;
};
@@ -54,7 +54,7 @@ export const getOAuthProviders = async (): Promise<OAuthProvider[]> => {
*/
export const initiateOAuth = async (provider: string): Promise<OAuthAuthorizationResponse> => {
const response = await apiClient.get<OAuthAuthorizationResponse>(
`/api/auth/oauth/${provider}/authorize/`
`/auth/oauth/${provider}/authorize/`
);
return response.data;
};
@@ -68,7 +68,7 @@ export const handleOAuthCallback = async (
state: string
): Promise<OAuthTokenResponse> => {
const response = await apiClient.post<OAuthTokenResponse>(
`/api/auth/oauth/${provider}/callback/`,
`/auth/oauth/${provider}/callback/`,
{
code,
state,
@@ -81,7 +81,7 @@ export const handleOAuthCallback = async (
* Get user's connected OAuth accounts
*/
export const getOAuthConnections = async (): Promise<OAuthConnection[]> => {
const response = await apiClient.get<{ connections: OAuthConnection[] }>('/api/auth/oauth/connections/');
const response = await apiClient.get<{ connections: OAuthConnection[] }>('/auth/oauth/connections/');
return response.data.connections;
};
@@ -89,5 +89,5 @@ export const getOAuthConnections = async (): Promise<OAuthConnection[]> => {
* Disconnect an OAuth account
*/
export const disconnectOAuth = async (provider: string): Promise<void> => {
await apiClient.delete(`/api/auth/oauth/connections/${provider}/`);
await apiClient.delete(`/auth/oauth/connections/${provider}/`);
};

View File

@@ -48,6 +48,8 @@ export interface ConnectAccountInfo {
export interface PaymentConfig {
payment_mode: PaymentMode;
tier: string;
tier_allows_payments: boolean;
stripe_configured: boolean;
can_accept_payments: boolean;
api_keys: ApiKeysInfo | null;
connect_account: ConnectAccountInfo | null;
@@ -95,7 +97,7 @@ export interface AccountSessionResponse {
* Returns the complete payment setup for the business.
*/
export const getPaymentConfig = () =>
apiClient.get<PaymentConfig>('/api/payments/config/status/');
apiClient.get<PaymentConfig>('/payments/config/status/');
// ============================================================================
// API Keys (Free Tier)
@@ -105,14 +107,14 @@ export const getPaymentConfig = () =>
* Get current API key configuration (masked keys).
*/
export const getApiKeys = () =>
apiClient.get<ApiKeysCurrentResponse>('/api/payments/api-keys/');
apiClient.get<ApiKeysCurrentResponse>('/payments/api-keys/');
/**
* Save API keys.
* Validates and stores the provided Stripe API keys.
*/
export const saveApiKeys = (secretKey: string, publishableKey: string) =>
apiClient.post<ApiKeysInfo>('/api/payments/api-keys/', {
apiClient.post<ApiKeysInfo>('/payments/api-keys/', {
secret_key: secretKey,
publishable_key: publishableKey,
});
@@ -122,7 +124,7 @@ export const saveApiKeys = (secretKey: string, publishableKey: string) =>
* Tests the keys against Stripe API.
*/
export const validateApiKeys = (secretKey: string, publishableKey: string) =>
apiClient.post<ApiKeysValidationResult>('/api/payments/api-keys/validate/', {
apiClient.post<ApiKeysValidationResult>('/payments/api-keys/validate/', {
secret_key: secretKey,
publishable_key: publishableKey,
});
@@ -132,13 +134,13 @@ export const validateApiKeys = (secretKey: string, publishableKey: string) =>
* Tests stored keys and updates their status.
*/
export const revalidateApiKeys = () =>
apiClient.post<ApiKeysValidationResult>('/api/payments/api-keys/revalidate/');
apiClient.post<ApiKeysValidationResult>('/payments/api-keys/revalidate/');
/**
* Delete stored API keys.
*/
export const deleteApiKeys = () =>
apiClient.delete<{ success: boolean; message: string }>('/api/payments/api-keys/delete/');
apiClient.delete<{ success: boolean; message: string }>('/payments/api-keys/delete/');
// ============================================================================
// Stripe Connect (Paid Tiers)
@@ -148,14 +150,14 @@ export const deleteApiKeys = () =>
* Get current Connect account status.
*/
export const getConnectStatus = () =>
apiClient.get<ConnectAccountInfo>('/api/payments/connect/status/');
apiClient.get<ConnectAccountInfo>('/payments/connect/status/');
/**
* Initiate Connect account onboarding.
* Returns a URL to redirect the user for Stripe onboarding.
*/
export const initiateConnectOnboarding = (refreshUrl: string, returnUrl: string) =>
apiClient.post<ConnectOnboardingResponse>('/api/payments/connect/onboard/', {
apiClient.post<ConnectOnboardingResponse>('/payments/connect/onboard/', {
refresh_url: refreshUrl,
return_url: returnUrl,
});
@@ -165,7 +167,7 @@ export const initiateConnectOnboarding = (refreshUrl: string, returnUrl: string)
* For custom Connect accounts that need a new onboarding link.
*/
export const refreshConnectOnboardingLink = (refreshUrl: string, returnUrl: string) =>
apiClient.post<{ url: string }>('/api/payments/connect/refresh-link/', {
apiClient.post<{ url: string }>('/payments/connect/refresh-link/', {
refresh_url: refreshUrl,
return_url: returnUrl,
});
@@ -175,14 +177,14 @@ export const refreshConnectOnboardingLink = (refreshUrl: string, returnUrl: stri
* Returns a client_secret for initializing Stripe's embedded Connect components.
*/
export const createAccountSession = () =>
apiClient.post<AccountSessionResponse>('/api/payments/connect/account-session/');
apiClient.post<AccountSessionResponse>('/payments/connect/account-session/');
/**
* Refresh Connect account status from Stripe.
* Syncs the local account record with the current state in Stripe.
*/
export const refreshConnectStatus = () =>
apiClient.post<ConnectAccountInfo>('/api/payments/connect/refresh-status/');
apiClient.post<ConnectAccountInfo>('/payments/connect/refresh-status/');
// ============================================================================
// Transaction Analytics
@@ -319,7 +321,7 @@ export const getTransactions = (filters?: TransactionFilters) => {
const queryString = params.toString();
return apiClient.get<TransactionListResponse>(
`/api/payments/transactions/${queryString ? `?${queryString}` : ''}`
`/payments/transactions/${queryString ? `?${queryString}` : ''}`
);
};
@@ -327,7 +329,7 @@ export const getTransactions = (filters?: TransactionFilters) => {
* Get a single transaction by ID.
*/
export const getTransaction = (id: number) =>
apiClient.get<Transaction>(`/api/payments/transactions/${id}/`);
apiClient.get<Transaction>(`/payments/transactions/${id}/`);
/**
* Get transaction summary/analytics.
@@ -339,7 +341,7 @@ export const getTransactionSummary = (filters?: Pick<TransactionFilters, 'start_
const queryString = params.toString();
return apiClient.get<TransactionSummary>(
`/api/payments/transactions/summary/${queryString ? `?${queryString}` : ''}`
`/payments/transactions/summary/${queryString ? `?${queryString}` : ''}`
);
};
@@ -347,26 +349,26 @@ export const getTransactionSummary = (filters?: Pick<TransactionFilters, 'start_
* Get charges from Stripe API.
*/
export const getStripeCharges = (limit: number = 20) =>
apiClient.get<ChargesResponse>(`/api/payments/transactions/charges/?limit=${limit}`);
apiClient.get<ChargesResponse>(`/payments/transactions/charges/?limit=${limit}`);
/**
* Get payouts from Stripe API.
*/
export const getStripePayouts = (limit: number = 20) =>
apiClient.get<PayoutsResponse>(`/api/payments/transactions/payouts/?limit=${limit}`);
apiClient.get<PayoutsResponse>(`/payments/transactions/payouts/?limit=${limit}`);
/**
* Get current balance from Stripe API.
*/
export const getStripeBalance = () =>
apiClient.get<BalanceResponse>('/api/payments/transactions/balance/');
apiClient.get<BalanceResponse>('/payments/transactions/balance/');
/**
* Export transaction data.
* Returns the file data directly for download.
*/
export const exportTransactions = (request: ExportRequest) =>
apiClient.post('/api/payments/transactions/export/', request, {
apiClient.post('/payments/transactions/export/', request, {
responseType: 'blob',
});
@@ -422,7 +424,7 @@ export interface RefundResponse {
* Get detailed transaction information including refund data.
*/
export const getTransactionDetail = (id: number) =>
apiClient.get<TransactionDetail>(`/api/payments/transactions/${id}/`);
apiClient.get<TransactionDetail>(`/payments/transactions/${id}/`);
/**
* Issue a refund for a transaction.
@@ -430,4 +432,115 @@ export const getTransactionDetail = (id: number) =>
* @param request - Optional refund request with amount and reason
*/
export const refundTransaction = (transactionId: number, request?: RefundRequest) =>
apiClient.post<RefundResponse>(`/api/payments/transactions/${transactionId}/refund/`, request || {});
apiClient.post<RefundResponse>(`/payments/transactions/${transactionId}/refund/`, request || {});
// ============================================================================
// Subscription Plans & Add-ons
// ============================================================================
export interface SubscriptionPlan {
id: number;
name: string;
description: string;
plan_type: 'base' | 'addon';
business_tier: string;
price_monthly: number | null;
price_yearly: number | null;
features: string[];
permissions: Record<string, boolean>;
limits: Record<string, number>;
transaction_fee_percent: number;
transaction_fee_fixed: number;
is_most_popular: boolean;
show_price: boolean;
stripe_price_id: string;
}
export interface SubscriptionPlansResponse {
current_tier: string;
current_plan: SubscriptionPlan | null;
plans: SubscriptionPlan[];
addons: SubscriptionPlan[];
}
export interface CheckoutResponse {
checkout_url: string;
session_id: string;
}
/**
* Get available subscription plans and add-ons.
*/
export const getSubscriptionPlans = () =>
apiClient.get<SubscriptionPlansResponse>('/payments/plans/');
/**
* Create a checkout session for upgrading or purchasing add-ons.
*/
export const createCheckoutSession = (planId: number, billingPeriod: 'monthly' | 'yearly' = 'monthly') =>
apiClient.post<CheckoutResponse>('/payments/checkout/', {
plan_id: planId,
billing_period: billingPeriod,
});
// ============================================================================
// Active Subscriptions
// ============================================================================
export interface ActiveSubscription {
id: string;
plan_name: string;
plan_type: 'base' | 'addon';
status: 'active' | 'past_due' | 'canceled' | 'incomplete' | 'trialing';
current_period_start: string;
current_period_end: string;
cancel_at_period_end: boolean;
cancel_at: string | null;
canceled_at: string | null;
amount: number;
amount_display: string;
interval: 'month' | 'year';
stripe_subscription_id: string;
}
export interface SubscriptionsResponse {
subscriptions: ActiveSubscription[];
has_active_subscription: boolean;
}
export interface CancelSubscriptionResponse {
success: boolean;
message: string;
cancel_at_period_end: boolean;
current_period_end: string;
}
export interface ReactivateSubscriptionResponse {
success: boolean;
message: string;
}
/**
* Get active subscriptions for the current tenant.
*/
export const getSubscriptions = () =>
apiClient.get<SubscriptionsResponse>('/payments/subscriptions/');
/**
* Cancel a subscription.
* @param subscriptionId - Stripe subscription ID
* @param immediate - If true, cancel immediately. If false, cancel at period end.
*/
export const cancelSubscription = (subscriptionId: string, immediate: boolean = false) =>
apiClient.post<CancelSubscriptionResponse>('/payments/subscriptions/cancel/', {
subscription_id: subscriptionId,
immediate,
});
/**
* Reactivate a subscription that was set to cancel at period end.
*/
export const reactivateSubscription = (subscriptionId: string) =>
apiClient.post<ReactivateSubscriptionResponse>('/payments/subscriptions/reactivate/', {
subscription_id: subscriptionId,
});

View File

@@ -11,6 +11,7 @@ export interface PlatformBusinessOwner {
full_name: string;
email: string;
role: string;
email_verified: boolean;
}
export interface PlatformBusiness {
@@ -32,6 +33,24 @@ export interface PlatformBusiness {
can_use_custom_domain: boolean;
can_white_label: boolean;
can_api_access: boolean;
// Feature permissions (optional - returned by API but may not always be present in tests)
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
}
export interface PlatformBusinessUpdate {
@@ -40,11 +59,38 @@ export interface PlatformBusinessUpdate {
subscription_tier?: string;
max_users?: number;
max_resources?: number;
// Platform permissions
can_manage_oauth_credentials?: boolean;
can_accept_payments?: boolean;
can_use_custom_domain?: boolean;
can_white_label?: boolean;
can_api_access?: boolean;
// Feature permissions
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_process_refunds?: boolean;
can_create_packages?: boolean;
can_use_email_templates?: boolean;
can_customize_booking_page?: boolean;
advanced_reporting?: boolean;
priority_support?: boolean;
dedicated_support?: boolean;
sso_enabled?: boolean;
}
export interface PlatformBusinessCreate {
@@ -72,6 +118,7 @@ export interface PlatformUser {
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
email_verified: boolean;
business: number | null;
business_name?: string;
business_subdomain?: string;
@@ -83,7 +130,7 @@ export interface PlatformUser {
* Get all businesses (platform admin only)
*/
export const getBusinesses = async (): Promise<PlatformBusiness[]> => {
const response = await apiClient.get<PlatformBusiness[]>('/api/platform/businesses/');
const response = await apiClient.get<PlatformBusiness[]>('/platform/businesses/');
return response.data;
};
@@ -95,7 +142,7 @@ export const updateBusiness = async (
data: PlatformBusinessUpdate
): Promise<PlatformBusiness> => {
const response = await apiClient.patch<PlatformBusiness>(
`/api/platform/businesses/${businessId}/`,
`/platform/businesses/${businessId}/`,
data
);
return response.data;
@@ -108,17 +155,25 @@ export const createBusiness = async (
data: PlatformBusinessCreate
): Promise<PlatformBusiness> => {
const response = await apiClient.post<PlatformBusiness>(
'/api/platform/businesses/',
'/platform/businesses/',
data
);
return response.data;
};
/**
* Delete a business/tenant (platform admin only)
* This permanently deletes the tenant and all associated data
*/
export const deleteBusiness = async (businessId: number): Promise<void> => {
await apiClient.delete(`/platform/businesses/${businessId}/`);
};
/**
* Get all users (platform admin only)
*/
export const getUsers = async (): Promise<PlatformUser[]> => {
const response = await apiClient.get<PlatformUser[]>('/api/platform/users/');
const response = await apiClient.get<PlatformUser[]>('/platform/users/');
return response.data;
};
@@ -126,10 +181,17 @@ export const getUsers = async (): Promise<PlatformUser[]> => {
* Get users for a specific business
*/
export const getBusinessUsers = async (businessId: number): Promise<PlatformUser[]> => {
const response = await apiClient.get<PlatformUser[]>(`/api/platform/users/?business=${businessId}`);
const response = await apiClient.get<PlatformUser[]>(`/platform/users/?business=${businessId}`);
return response.data;
};
/**
* Verify a user's email (platform admin only)
*/
export const verifyUserEmail = async (userId: number): Promise<void> => {
await apiClient.post(`/platform/users/${userId}/verify_email/`);
};
// ============================================================================
// Tenant Invitations
// ============================================================================
@@ -209,7 +271,7 @@ export interface TenantInvitationAccept {
* Get all tenant invitations (platform admin only)
*/
export const getTenantInvitations = async (): Promise<TenantInvitation[]> => {
const response = await apiClient.get<TenantInvitation[]>('/api/platform/tenant-invitations/');
const response = await apiClient.get<TenantInvitation[]>('/platform/tenant-invitations/');
return response.data;
};
@@ -220,7 +282,7 @@ export const createTenantInvitation = async (
data: TenantInvitationCreate
): Promise<TenantInvitation> => {
const response = await apiClient.post<TenantInvitation>(
'/api/platform/tenant-invitations/',
'/platform/tenant-invitations/',
data
);
return response.data;
@@ -230,14 +292,14 @@ export const createTenantInvitation = async (
* Resend a tenant invitation (platform admin only)
*/
export const resendTenantInvitation = async (invitationId: number): Promise<void> => {
await apiClient.post(`/api/platform/tenant-invitations/${invitationId}/resend/`);
await apiClient.post(`/platform/tenant-invitations/${invitationId}/resend/`);
};
/**
* Cancel a tenant invitation (platform admin only)
*/
export const cancelTenantInvitation = async (invitationId: number): Promise<void> => {
await apiClient.post(`/api/platform/tenant-invitations/${invitationId}/cancel/`);
await apiClient.post(`/platform/tenant-invitations/${invitationId}/cancel/`);
};
/**
@@ -245,7 +307,7 @@ export const cancelTenantInvitation = async (invitationId: number): Promise<void
*/
export const getInvitationByToken = async (token: string): Promise<TenantInvitationDetail> => {
const response = await apiClient.get<TenantInvitationDetail>(
`/api/platform/tenant-invitations/token/${token}/`
`/platform/tenant-invitations/token/${token}/`
);
return response.data;
};
@@ -258,7 +320,7 @@ export const acceptInvitation = async (
data: TenantInvitationAccept
): Promise<{ detail: string }> => {
const response = await apiClient.post<{ detail: string }>(
`/api/platform/tenant-invitations/token/${token}/accept/`,
`/platform/tenant-invitations/token/${token}/accept/`,
data
);
return response.data;

View File

@@ -0,0 +1,250 @@
/**
* API client for Platform Email Addresses
* These are email addresses managed directly on the mail.talova.net server
*/
import apiClient from './client';
export interface PlatformEmailAddress {
id: number;
display_name: string;
sender_name: string;
effective_sender_name: string;
local_part: string;
domain: string;
email_address: string;
color: string;
is_active: boolean;
is_default: boolean;
mail_server_synced: boolean;
last_sync_error?: string;
last_synced_at?: string;
last_check_at?: string;
emails_processed_count: number;
created_at: string;
updated_at: string;
imap_settings?: {
host: string;
port: number;
use_ssl: boolean;
username: string;
folder: string;
};
smtp_settings?: {
host: string;
port: number;
use_tls: boolean;
use_ssl: boolean;
username: string;
};
}
export interface AssignedUser {
id: number;
email: string;
first_name: string;
last_name: string;
full_name: string;
}
export interface AssignableUser extends AssignedUser {
role: string;
}
export interface PlatformEmailAddressListItem {
id: number;
display_name: string;
sender_name: string;
effective_sender_name: string;
local_part: string;
domain: string;
email_address: string;
color: string;
assigned_user?: AssignedUser | null;
is_active: boolean;
is_default: boolean;
mail_server_synced: boolean;
last_check_at?: string;
emails_processed_count: number;
created_at: string;
updated_at: string;
}
export interface PlatformEmailAddressCreate {
display_name: string;
sender_name?: string;
assigned_user_id?: number | null;
local_part: string;
domain: string;
color: string;
password: string;
is_active: boolean;
is_default: boolean;
}
export interface PlatformEmailAddressUpdate {
display_name?: string;
sender_name?: string;
assigned_user_id?: number | null;
color?: string;
password?: string;
is_active?: boolean;
is_default?: boolean;
}
export interface EmailDomain {
value: string;
label: string;
}
export interface TestConnectionResponse {
success: boolean;
message: string;
}
export interface SyncResponse {
success: boolean;
message: string;
mail_server_synced?: boolean;
last_synced_at?: string;
last_sync_error?: string;
}
export interface MailServerAccountsResponse {
success: boolean;
accounts: { email: string; raw_line: string }[];
count: number;
}
export interface ImportFromMailServerResponse {
success: boolean;
imported: { id: number; email: string; display_name: string }[];
imported_count: number;
skipped: { email: string; reason: string }[];
skipped_count: number;
message: string;
}
/**
* Get all platform email addresses
*/
export const getPlatformEmailAddresses = async (): Promise<PlatformEmailAddressListItem[]> => {
const response = await apiClient.get('/platform/email-addresses/');
return response.data;
};
/**
* Get a specific platform email address by ID
*/
export const getPlatformEmailAddress = async (id: number): Promise<PlatformEmailAddress> => {
const response = await apiClient.get(`/platform/email-addresses/${id}/`);
return response.data;
};
/**
* Create a new platform email address
*/
export const createPlatformEmailAddress = async (
data: PlatformEmailAddressCreate
): Promise<PlatformEmailAddress> => {
const response = await apiClient.post('/platform/email-addresses/', data);
return response.data;
};
/**
* Update an existing platform email address
*/
export const updatePlatformEmailAddress = async (
id: number,
data: PlatformEmailAddressUpdate
): Promise<PlatformEmailAddress> => {
const response = await apiClient.patch(`/platform/email-addresses/${id}/`, data);
return response.data;
};
/**
* Delete a platform email address (also removes from mail server)
*/
export const deletePlatformEmailAddress = async (id: number): Promise<void> => {
await apiClient.delete(`/platform/email-addresses/${id}/`);
};
/**
* Remove email address from database only (keeps mail server account)
*/
export const removeLocalPlatformEmailAddress = async (id: number): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post(`/platform/email-addresses/${id}/remove_local/`);
return response.data;
};
/**
* Sync email address to mail server
*/
export const syncPlatformEmailAddress = async (id: number): Promise<SyncResponse> => {
const response = await apiClient.post(`/platform/email-addresses/${id}/sync/`);
return response.data;
};
/**
* Test IMAP connection for a platform email address
*/
export const testImapConnection = async (id: number): Promise<TestConnectionResponse> => {
const response = await apiClient.post(`/platform/email-addresses/${id}/test_imap/`);
return response.data;
};
/**
* Test SMTP connection for a platform email address
*/
export const testSmtpConnection = async (id: number): Promise<TestConnectionResponse> => {
const response = await apiClient.post(`/platform/email-addresses/${id}/test_smtp/`);
return response.data;
};
/**
* Set a platform email address as the default
*/
export const setAsDefault = async (id: number): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post(`/platform/email-addresses/${id}/set_as_default/`);
return response.data;
};
/**
* Test SSH connection to the mail server
*/
export const testMailServerConnection = async (): Promise<TestConnectionResponse> => {
const response = await apiClient.post('/platform/email-addresses/test_mail_server/');
return response.data;
};
/**
* Get all email accounts from the mail server
*/
export const getMailServerAccounts = async (): Promise<MailServerAccountsResponse> => {
const response = await apiClient.get('/platform/email-addresses/mail_server_accounts/');
return response.data;
};
/**
* Get available email domains
*/
export const getAvailableDomains = async (): Promise<{ domains: EmailDomain[] }> => {
const response = await apiClient.get('/platform/email-addresses/available_domains/');
return response.data;
};
/**
* Get assignable users (platform users who can be assigned to email addresses)
*/
export const getAssignableUsers = async (): Promise<{ users: AssignableUser[] }> => {
const response = await apiClient.get('/platform/email-addresses/assignable_users/');
return response.data;
};
/**
* Import email addresses from the mail server
*/
export const importFromMailServer = async (): Promise<ImportFromMailServerResponse> => {
const response = await apiClient.post('/platform/email-addresses/import_from_mail_server/');
return response.data;
};

View File

@@ -75,7 +75,7 @@ export interface PlatformOAuthSettingsUpdate {
* Get platform OAuth settings
*/
export const getPlatformOAuthSettings = async (): Promise<PlatformOAuthSettings> => {
const { data } = await apiClient.get('/api/platform/settings/oauth/');
const { data } = await apiClient.get('/platform/settings/oauth/');
return data;
};
@@ -85,6 +85,6 @@ export const getPlatformOAuthSettings = async (): Promise<PlatformOAuthSettings>
export const updatePlatformOAuthSettings = async (
settings: PlatformOAuthSettingsUpdate
): Promise<PlatformOAuthSettings> => {
const { data } = await apiClient.post('/api/platform/settings/oauth/', settings);
const { data } = await apiClient.post('/platform/settings/oauth/', settings);
return data;
};

View File

@@ -71,43 +71,43 @@ export interface LoginHistoryEntry {
// Profile API
export const getProfile = async (): Promise<UserProfile> => {
const response = await apiClient.get('/api/auth/profile/');
const response = await apiClient.get('/auth/profile/');
return response.data;
};
export const updateProfile = async (data: Partial<UserProfile>): Promise<UserProfile> => {
const response = await apiClient.patch('/api/auth/profile/', data);
const response = await apiClient.patch('/auth/profile/', data);
return response.data;
};
export const uploadAvatar = async (file: File): Promise<{ avatar_url: string }> => {
const formData = new FormData();
formData.append('avatar', file);
const response = await apiClient.post('/api/auth/profile/avatar/', formData, {
const response = await apiClient.post('/auth/profile/avatar/', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
};
export const deleteAvatar = async (): Promise<void> => {
await apiClient.delete('/api/auth/profile/avatar/');
await apiClient.delete('/auth/profile/avatar/');
};
// Email API
export const sendVerificationEmail = async (): Promise<void> => {
await apiClient.post('/api/auth/email/verify/send/');
await apiClient.post('/auth/email/verify/send/');
};
export const verifyEmail = async (token: string): Promise<void> => {
await apiClient.post('/api/auth/email/verify/confirm/', { token });
await apiClient.post('/auth/email/verify/confirm/', { token });
};
export const requestEmailChange = async (newEmail: string): Promise<void> => {
await apiClient.post('/api/auth/email/change/', { new_email: newEmail });
await apiClient.post('/auth/email/change/', { new_email: newEmail });
};
export const confirmEmailChange = async (token: string): Promise<void> => {
await apiClient.post('/api/auth/email/change/confirm/', { token });
await apiClient.post('/auth/email/change/confirm/', { token });
};
// Password API
@@ -115,7 +115,7 @@ export const changePassword = async (
currentPassword: string,
newPassword: string
): Promise<void> => {
await apiClient.post('/api/auth/password/change/', {
await apiClient.post('/auth/password/change/', {
current_password: currentPassword,
new_password: newPassword,
});
@@ -123,12 +123,12 @@ export const changePassword = async (
// 2FA API (using new MFA endpoints)
export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
const response = await apiClient.post('/api/auth/mfa/totp/setup/');
const response = await apiClient.post('/auth/mfa/totp/setup/');
return response.data;
};
export const verifyTOTP = async (code: string): Promise<TOTPVerifyResponse> => {
const response = await apiClient.post('/api/auth/mfa/totp/verify/', { code });
const response = await apiClient.post('/auth/mfa/totp/verify/', { code });
// Map response to expected format
return {
success: response.data.success,
@@ -137,46 +137,46 @@ export const verifyTOTP = async (code: string): Promise<TOTPVerifyResponse> => {
};
export const disableTOTP = async (code: string): Promise<void> => {
await apiClient.post('/api/auth/mfa/disable/', { mfa_code: code });
await apiClient.post('/auth/mfa/disable/', { mfa_code: code });
};
export const getRecoveryCodes = async (): Promise<string[]> => {
const response = await apiClient.get('/api/auth/mfa/backup-codes/status/');
const response = await apiClient.get('/auth/mfa/backup-codes/status/');
// Note: Actual codes are only shown when generated, not retrievable later
return [];
};
export const regenerateRecoveryCodes = async (): Promise<string[]> => {
const response = await apiClient.post('/api/auth/mfa/backup-codes/');
const response = await apiClient.post('/auth/mfa/backup-codes/');
return response.data.backup_codes;
};
// Sessions API
export const getSessions = async (): Promise<Session[]> => {
const response = await apiClient.get('/api/auth/sessions/');
const response = await apiClient.get('/auth/sessions/');
return response.data;
};
export const revokeSession = async (sessionId: string): Promise<void> => {
await apiClient.delete(`/api/auth/sessions/${sessionId}/`);
await apiClient.delete(`/auth/sessions/${sessionId}/`);
};
export const revokeOtherSessions = async (): Promise<void> => {
await apiClient.post('/api/auth/sessions/revoke-others/');
await apiClient.post('/auth/sessions/revoke-others/');
};
export const getLoginHistory = async (): Promise<LoginHistoryEntry[]> => {
const response = await apiClient.get('/api/auth/login-history/');
const response = await apiClient.get('/auth/login-history/');
return response.data;
};
// Phone Verification API
export const sendPhoneVerification = async (phone: string): Promise<void> => {
await apiClient.post('/api/auth/phone/verify/send/', { phone });
await apiClient.post('/auth/phone/verify/send/', { phone });
};
export const verifyPhoneCode = async (code: string): Promise<void> => {
await apiClient.post('/api/auth/phone/verify/confirm/', { code });
await apiClient.post('/auth/phone/verify/confirm/', { code });
};
// Multiple Email Management API
@@ -189,27 +189,27 @@ export interface UserEmail {
}
export const getUserEmails = async (): Promise<UserEmail[]> => {
const response = await apiClient.get('/api/auth/emails/');
const response = await apiClient.get('/auth/emails/');
return response.data;
};
export const addUserEmail = async (email: string): Promise<UserEmail> => {
const response = await apiClient.post('/api/auth/emails/', { email });
const response = await apiClient.post('/auth/emails/', { email });
return response.data;
};
export const deleteUserEmail = async (emailId: number): Promise<void> => {
await apiClient.delete(`/api/auth/emails/${emailId}/`);
await apiClient.delete(`/auth/emails/${emailId}/`);
};
export const sendUserEmailVerification = async (emailId: number): Promise<void> => {
await apiClient.post(`/api/auth/emails/${emailId}/send-verification/`);
await apiClient.post(`/auth/emails/${emailId}/send-verification/`);
};
export const verifyUserEmail = async (emailId: number, token: string): Promise<void> => {
await apiClient.post(`/api/auth/emails/${emailId}/verify/`, { token });
await apiClient.post(`/auth/emails/${emailId}/verify/`, { token });
};
export const setPrimaryEmail = async (emailId: number): Promise<void> => {
await apiClient.post(`/api/auth/emails/${emailId}/set-primary/`);
await apiClient.post(`/auth/emails/${emailId}/set-primary/`);
};

103
frontend/src/api/quota.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Quota Management API
*/
import apiClient from './client';
import { QuotaOverage } from './auth';
export interface QuotaUsage {
current: number;
limit: number;
display_name: string;
}
export interface QuotaStatus {
active_overages: QuotaOverage[];
usage: Record<string, QuotaUsage>;
}
export interface QuotaResource {
id: number;
name: string;
email?: string;
role?: string;
type?: string;
duration?: number;
price?: string;
created_at: string | null;
is_archived: boolean;
archived_at: string | null;
}
export interface QuotaResourcesResponse {
quota_type: string;
resources: QuotaResource[];
}
export interface ArchiveResponse {
archived_count: number;
current_usage: number;
limit: number;
is_resolved: boolean;
}
export interface QuotaOverageDetail extends QuotaOverage {
status: string;
created_at: string;
initial_email_sent_at: string | null;
week_reminder_sent_at: string | null;
day_reminder_sent_at: string | null;
archived_resource_ids: number[];
}
/**
* Get current quota status
*/
export const getQuotaStatus = async (): Promise<QuotaStatus> => {
const response = await apiClient.get<QuotaStatus>('/quota/status/');
return response.data;
};
/**
* Get resources for a specific quota type
*/
export const getQuotaResources = async (quotaType: string): Promise<QuotaResourcesResponse> => {
const response = await apiClient.get<QuotaResourcesResponse>(`/quota/resources/${quotaType}/`);
return response.data;
};
/**
* Archive resources to resolve quota overage
*/
export const archiveResources = async (
quotaType: string,
resourceIds: number[]
): Promise<ArchiveResponse> => {
const response = await apiClient.post<ArchiveResponse>('/quota/archive/', {
quota_type: quotaType,
resource_ids: resourceIds,
});
return response.data;
};
/**
* Unarchive a resource
*/
export const unarchiveResource = async (
quotaType: string,
resourceId: number
): Promise<{ success: boolean; resource_id: number }> => {
const response = await apiClient.post('/quota/unarchive/', {
quota_type: quotaType,
resource_id: resourceId,
});
return response.data;
};
/**
* Get details for a specific overage
*/
export const getOverageDetail = async (overageId: number): Promise<QuotaOverageDetail> => {
const response = await apiClient.get<QuotaOverageDetail>(`/quota/overages/${overageId}/`);
return response.data;
};

View File

@@ -25,7 +25,7 @@ export interface SandboxResetResponse {
* Get current sandbox mode status
*/
export const getSandboxStatus = async (): Promise<SandboxStatus> => {
const response = await apiClient.get<SandboxStatus>('/api/sandbox/status/');
const response = await apiClient.get<SandboxStatus>('/sandbox/status/');
return response.data;
};
@@ -33,7 +33,7 @@ export const getSandboxStatus = async (): Promise<SandboxStatus> => {
* Toggle between live and sandbox mode
*/
export const toggleSandboxMode = async (enableSandbox: boolean): Promise<SandboxToggleResponse> => {
const response = await apiClient.post<SandboxToggleResponse>('/api/sandbox/toggle/', {
const response = await apiClient.post<SandboxToggleResponse>('/sandbox/toggle/', {
sandbox: enableSandbox,
});
return response.data;
@@ -43,6 +43,6 @@ export const toggleSandboxMode = async (enableSandbox: boolean): Promise<Sandbox
* Reset sandbox data to initial state
*/
export const resetSandboxData = async (): Promise<SandboxResetResponse> => {
const response = await apiClient.post<SandboxResetResponse>('/api/sandbox/reset/');
const response = await apiClient.post<SandboxResetResponse>('/sandbox/reset/');
return response.data;
};

View File

@@ -0,0 +1,157 @@
/**
* API client for Ticket Email Addresses
*/
import apiClient from './client';
export interface TicketEmailAddress {
id: number;
tenant: number;
tenant_name: string;
display_name: string;
email_address: string;
color: string;
imap_host: string;
imap_port: number;
imap_use_ssl: boolean;
imap_username: string;
imap_password?: string;
imap_folder: string;
smtp_host: string;
smtp_port: number;
smtp_use_tls: boolean;
smtp_use_ssl: boolean;
smtp_username: string;
smtp_password?: string;
is_active: boolean;
is_default: boolean;
last_check_at?: string;
last_error?: string;
emails_processed_count: number;
created_at: string;
updated_at: string;
is_imap_configured: boolean;
is_smtp_configured: boolean;
is_fully_configured: boolean;
}
export interface TicketEmailAddressListItem {
id: number;
display_name: string;
email_address: string;
color: string;
is_active: boolean;
is_default: boolean;
last_check_at?: string;
emails_processed_count: number;
created_at: string;
updated_at: string;
}
export interface TicketEmailAddressCreate {
display_name: string;
email_address: string;
color: string;
imap_host: string;
imap_port: number;
imap_use_ssl: boolean;
imap_username: string;
imap_password: string;
imap_folder: string;
smtp_host: string;
smtp_port: number;
smtp_use_tls: boolean;
smtp_use_ssl: boolean;
smtp_username: string;
smtp_password: string;
is_active: boolean;
is_default: boolean;
}
export interface TestConnectionResponse {
success: boolean;
message: string;
}
export interface FetchEmailsResponse {
success: boolean;
message: string;
processed?: number;
errors?: number;
}
/**
* Get all ticket email addresses for the current business
*/
export const getTicketEmailAddresses = async (): Promise<TicketEmailAddressListItem[]> => {
const response = await apiClient.get('/tickets/email-addresses/');
return response.data;
};
/**
* Get a specific ticket email address by ID
*/
export const getTicketEmailAddress = async (id: number): Promise<TicketEmailAddress> => {
const response = await apiClient.get(`/tickets/email-addresses/${id}/`);
return response.data;
};
/**
* Create a new ticket email address
*/
export const createTicketEmailAddress = async (
data: TicketEmailAddressCreate
): Promise<TicketEmailAddress> => {
const response = await apiClient.post('/tickets/email-addresses/', data);
return response.data;
};
/**
* Update an existing ticket email address
*/
export const updateTicketEmailAddress = async (
id: number,
data: Partial<TicketEmailAddressCreate>
): Promise<TicketEmailAddress> => {
const response = await apiClient.patch(`/tickets/email-addresses/${id}/`, data);
return response.data;
};
/**
* Delete a ticket email address
*/
export const deleteTicketEmailAddress = async (id: number): Promise<void> => {
await apiClient.delete(`/tickets/email-addresses/${id}/`);
};
/**
* Test IMAP connection for an email address
*/
export const testImapConnection = async (id: number): Promise<TestConnectionResponse> => {
const response = await apiClient.post(`/tickets/email-addresses/${id}/test_imap/`);
return response.data;
};
/**
* Test SMTP connection for an email address
*/
export const testSmtpConnection = async (id: number): Promise<TestConnectionResponse> => {
const response = await apiClient.post(`/tickets/email-addresses/${id}/test_smtp/`);
return response.data;
};
/**
* Manually fetch emails for an email address
*/
export const fetchEmailsNow = async (id: number): Promise<FetchEmailsResponse> => {
const response = await apiClient.post(`/tickets/email-addresses/${id}/fetch_now/`);
return response.data;
};
/**
* Set an email address as the default for the business
*/
export const setAsDefault = async (id: number): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post(`/tickets/email-addresses/${id}/set_as_default/`);
return response.data;
};

View File

@@ -122,7 +122,7 @@ export interface IncomingTicketEmail {
* Get ticket email settings
*/
export const getTicketEmailSettings = async (): Promise<TicketEmailSettings> => {
const response = await apiClient.get('/api/tickets/email-settings/');
const response = await apiClient.get('/tickets/email-settings/');
return response.data;
};
@@ -132,7 +132,7 @@ export const getTicketEmailSettings = async (): Promise<TicketEmailSettings> =>
export const updateTicketEmailSettings = async (
data: TicketEmailSettingsUpdate
): Promise<TicketEmailSettings> => {
const response = await apiClient.patch('/api/tickets/email-settings/', data);
const response = await apiClient.patch('/tickets/email-settings/', data);
return response.data;
};
@@ -140,7 +140,7 @@ export const updateTicketEmailSettings = async (
* Test IMAP connection
*/
export const testImapConnection = async (): Promise<TestConnectionResult> => {
const response = await apiClient.post('/api/tickets/email-settings/test-imap/');
const response = await apiClient.post('/tickets/email-settings/test-imap/');
return response.data;
};
@@ -148,7 +148,7 @@ export const testImapConnection = async (): Promise<TestConnectionResult> => {
* Test SMTP connection
*/
export const testSmtpConnection = async (): Promise<TestConnectionResult> => {
const response = await apiClient.post('/api/tickets/email-settings/test-smtp/');
const response = await apiClient.post('/tickets/email-settings/test-smtp/');
return response.data;
};
@@ -159,7 +159,7 @@ export const testEmailConnection = testImapConnection;
* Manually trigger email fetch
*/
export const fetchEmailsNow = async (): Promise<FetchNowResult> => {
const response = await apiClient.post('/api/tickets/email-settings/fetch-now/');
const response = await apiClient.post('/tickets/email-settings/fetch-now/');
return response.data;
};
@@ -170,7 +170,7 @@ export const getIncomingEmails = async (params?: {
status?: string;
ticket?: number;
}): Promise<IncomingTicketEmail[]> => {
const response = await apiClient.get('/api/tickets/incoming-emails/', { params });
const response = await apiClient.get('/tickets/incoming-emails/', { params });
return response.data;
};
@@ -183,7 +183,7 @@ export const reprocessIncomingEmail = async (id: number): Promise<{
comment_id?: number;
ticket_id?: number;
}> => {
const response = await apiClient.post(`/api/tickets/incoming-emails/${id}/reprocess/`);
const response = await apiClient.post(`/tickets/incoming-emails/${id}/reprocess/`);
return response.data;
};
@@ -193,7 +193,7 @@ export const reprocessIncomingEmail = async (id: number): Promise<{
* Also checks MX records for custom domains using Google Workspace or Microsoft 365
*/
export const detectEmailProvider = async (email: string): Promise<EmailProviderDetectResult> => {
const response = await apiClient.post('/api/tickets/email-settings/detect/', { email });
const response = await apiClient.post('/tickets/email-settings/detect/', { email });
return response.data;
};
@@ -225,7 +225,7 @@ export interface OAuthCredential {
* Get OAuth configuration status
*/
export const getOAuthStatus = async (): Promise<OAuthStatusResult> => {
const response = await apiClient.get('/api/oauth/status/');
const response = await apiClient.get('/oauth/status/');
return response.data;
};
@@ -233,7 +233,7 @@ export const getOAuthStatus = async (): Promise<OAuthStatusResult> => {
* Initiate Google OAuth flow
*/
export const initiateGoogleOAuth = async (purpose: string = 'email'): Promise<OAuthInitiateResult> => {
const response = await apiClient.post('/api/oauth/google/initiate/', { purpose });
const response = await apiClient.post('/oauth/google/initiate/', { purpose });
return response.data;
};
@@ -241,7 +241,7 @@ export const initiateGoogleOAuth = async (purpose: string = 'email'): Promise<OA
* Initiate Microsoft OAuth flow
*/
export const initiateMicrosoftOAuth = async (purpose: string = 'email'): Promise<OAuthInitiateResult> => {
const response = await apiClient.post('/api/oauth/microsoft/initiate/', { purpose });
const response = await apiClient.post('/oauth/microsoft/initiate/', { purpose });
return response.data;
};
@@ -249,7 +249,7 @@ export const initiateMicrosoftOAuth = async (purpose: string = 'email'): Promise
* List OAuth credentials
*/
export const getOAuthCredentials = async (): Promise<OAuthCredential[]> => {
const response = await apiClient.get('/api/oauth/credentials/');
const response = await apiClient.get('/oauth/credentials/');
return response.data;
};
@@ -257,6 +257,6 @@ export const getOAuthCredentials = async (): Promise<OAuthCredential[]> => {
* Delete OAuth credential
*/
export const deleteOAuthCredential = async (id: number): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/api/oauth/credentials/${id}/`);
const response = await apiClient.delete(`/oauth/credentials/${id}/`);
return response.data;
};

View File

@@ -17,52 +17,72 @@ export const getTickets = async (filters?: TicketFilters): Promise<Ticket[]> =>
if (filters?.ticketType) params.append('ticket_type', filters.ticketType);
if (filters?.assignee) params.append('assignee', filters.assignee);
const response = await apiClient.get(`/api/tickets/${params.toString() ? `?${params.toString()}` : ''}`);
const response = await apiClient.get(`/tickets/${params.toString() ? `?${params.toString()}` : ''}`);
return response.data;
};
export const getTicket = async (id: string): Promise<Ticket> => {
const response = await apiClient.get(`/api/tickets/${id}/`);
const response = await apiClient.get(`/tickets/${id}/`);
return response.data;
};
export const createTicket = async (data: Partial<Ticket>): Promise<Ticket> => {
const response = await apiClient.post('/api/tickets/', data);
const response = await apiClient.post('/tickets/', data);
return response.data;
};
export const updateTicket = async (id: string, data: Partial<Ticket>): Promise<Ticket> => {
const response = await apiClient.patch(`/api/tickets/${id}/`, data);
const response = await apiClient.patch(`/tickets/${id}/`, data);
return response.data;
};
export const deleteTicket = async (id: string): Promise<void> => {
await apiClient.delete(`/api/tickets/${id}/`);
await apiClient.delete(`/tickets/${id}/`);
};
export const getTicketComments = async (ticketId: string): Promise<TicketComment[]> => {
const response = await apiClient.get(`/api/tickets/${ticketId}/comments/`);
const response = await apiClient.get(`/tickets/${ticketId}/comments/`);
return response.data;
};
export const createTicketComment = async (ticketId: string, data: Partial<TicketComment>): Promise<TicketComment> => {
const response = await apiClient.post(`/api/tickets/${ticketId}/comments/`, data);
const response = await apiClient.post(`/tickets/${ticketId}/comments/`, data);
return response.data;
};
// Ticket Templates
export const getTicketTemplates = async (): Promise<TicketTemplate[]> => {
const response = await apiClient.get('/api/tickets/templates/');
const response = await apiClient.get('/tickets/templates/');
return response.data;
};
export const getTicketTemplate = async (id: string): Promise<TicketTemplate> => {
const response = await apiClient.get(`/api/tickets/templates/${id}/`);
const response = await apiClient.get(`/tickets/templates/${id}/`);
return response.data;
};
// Canned Responses
export const getCannedResponses = async (): Promise<CannedResponse[]> => {
const response = await apiClient.get('/api/tickets/canned-responses/');
const response = await apiClient.get('/tickets/canned-responses/');
return response.data;
};
// Refresh emails manually
export interface RefreshEmailsResult {
success: boolean;
processed: number;
results: {
address: string | null;
display_name?: string;
processed?: number;
status: string;
error?: string;
message?: string;
last_check_at?: string;
}[];
}
export const refreshTicketEmails = async (): Promise<RefreshEmailsResult> => {
const response = await apiClient.post('/tickets/refresh-emails/');
return response.data;
};

View File

@@ -0,0 +1,446 @@
/**
* Add Payment Method Modal Component
*
* Uses Stripe Elements with SetupIntent to securely save card details
* without charging the customer.
*
* For Stripe Connect, we must initialize Stripe with the connected account ID
* so the SetupIntent (created on the connected account) can be confirmed.
*/
import React, { useState, useEffect } from 'react';
import { loadStripe, Stripe } from '@stripe/stripe-js';
import {
Elements,
CardElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { CreditCard, Loader2, X, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useCreateSetupIntent, useSetDefaultPaymentMethod, useCustomerPaymentMethods } from '../hooks/useCustomerBilling';
import { useQueryClient } from '@tanstack/react-query';
// Cache for Stripe instances per connected account
// Note: Module-level cache persists across component re-renders but not page reloads
const stripeInstanceCache: Record<string, Promise<Stripe | null>> = {};
// Clear cache entry (useful for debugging)
export const clearStripeCache = (key?: string) => {
if (key) {
delete stripeInstanceCache[key];
} else {
Object.keys(stripeInstanceCache).forEach(k => delete stripeInstanceCache[k]);
}
};
// Get or create Stripe instance for a connected account (or platform account if empty)
// For direct_api mode, customPublishableKey will be the tenant's key
// For connect mode, we use the platform's key with stripeAccount
const getStripeInstance = (
stripeAccount: string,
customPublishableKey?: string
): Promise<Stripe | null> => {
// Use custom key for direct_api mode, platform key for connect mode
const publishableKey = customPublishableKey || import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '';
// Use 'platform' as cache key for direct_api mode (empty stripeAccount)
// For direct_api with custom key, include key in cache to avoid conflicts
const cacheKey = customPublishableKey
? `direct_${customPublishableKey.substring(0, 20)}`
: (stripeAccount || 'platform');
console.log('[AddPaymentMethodModal] getStripeInstance called with:', {
stripeAccount: stripeAccount || '(empty - direct_api mode)',
cacheKey,
publishableKey: publishableKey.substring(0, 20) + '...',
isDirectApi: !!customPublishableKey,
});
if (!stripeInstanceCache[cacheKey]) {
console.log('[AddPaymentMethodModal] Creating new Stripe instance for:', cacheKey);
// Only pass stripeAccount option if it's not empty (connect mode)
// For direct_api mode, we use the tenant's own API keys (no connected account needed)
stripeInstanceCache[cacheKey] = stripeAccount
? loadStripe(publishableKey, { stripeAccount })
: loadStripe(publishableKey);
} else {
console.log('[AddPaymentMethodModal] Using cached Stripe instance for:', cacheKey);
}
return stripeInstanceCache[cacheKey];
};
interface CardFormProps {
clientSecret: string;
onSuccess: () => void;
onCancel: () => void;
}
const CardFormInner: React.FC<CardFormProps> = ({
clientSecret,
onSuccess,
onCancel,
}) => {
const { t } = useTranslation();
const stripe = useStripe();
const elements = useElements();
const queryClient = useQueryClient();
const [isProcessing, setIsProcessing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isComplete, setIsComplete] = useState(false);
const [cardComplete, setCardComplete] = useState(false);
// Get current payment methods to check if this is the first one
const { data: paymentMethodsData } = useCustomerPaymentMethods();
const setDefaultPaymentMethod = useSetDefaultPaymentMethod();
// Detect dark mode for Stripe CardElement styling
const [isDarkMode, setIsDarkMode] = useState(() =>
document.documentElement.classList.contains('dark')
);
useEffect(() => {
// Watch for dark mode changes
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
return;
}
setIsProcessing(true);
setErrorMessage(null);
try {
// Confirm the SetupIntent with Stripe
const { error, setupIntent } = await stripe.confirmCardSetup(clientSecret, {
payment_method: {
card: cardElement,
},
});
if (error) {
setErrorMessage(error.message || t('billing.addCardFailed', 'Failed to add card. Please try again.'));
setIsProcessing(false);
return;
}
if (setupIntent && setupIntent.status === 'succeeded') {
// Get the payment method ID from the setup intent
const paymentMethodId = typeof setupIntent.payment_method === 'string'
? setupIntent.payment_method
: setupIntent.payment_method?.id;
// Check if there's already a default payment method
const existingMethods = paymentMethodsData?.payment_methods;
const hasDefaultMethod = existingMethods?.some(pm => pm.is_default) ?? false;
console.log('[AddPaymentMethodModal] SetupIntent succeeded:', {
paymentMethodId,
existingMethodsCount: existingMethods?.length ?? 0,
hasDefaultMethod,
});
// Set as default if no default payment method exists yet
if (!hasDefaultMethod && paymentMethodId) {
console.log('[AddPaymentMethodModal] No default payment method exists, setting new one as default:', paymentMethodId);
// Set as default (fire and forget - don't block the success flow)
setDefaultPaymentMethod.mutate(paymentMethodId, {
onSuccess: () => {
console.log('[AddPaymentMethodModal] Successfully set payment method as default');
},
onError: (err) => {
console.error('[AddPaymentMethodModal] Failed to set default payment method:', err);
},
});
} else {
console.log('[AddPaymentMethodModal] Default already exists or no paymentMethodId - existingMethods:', existingMethods?.length, 'hasDefaultMethod:', hasDefaultMethod, 'paymentMethodId:', paymentMethodId);
}
// Invalidate payment methods to refresh the list
queryClient.invalidateQueries({ queryKey: ['customerPaymentMethods'] });
setIsComplete(true);
setTimeout(() => {
onSuccess();
}, 1500);
}
} catch (err: any) {
setErrorMessage(err.message || t('billing.unexpectedError', 'An unexpected error occurred.'));
setIsProcessing(false);
}
};
if (isComplete) {
return (
<div className="text-center py-8">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{t('billing.cardAdded', 'Card Added Successfully!')}
</h3>
<p className="text-gray-600 dark:text-gray-400">
{t('billing.cardAddedDescription', 'Your payment method has been saved.')}
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: isDarkMode ? '#f1f5f9' : '#1e293b',
iconColor: isDarkMode ? '#94a3b8' : '#64748b',
'::placeholder': {
color: isDarkMode ? '#64748b' : '#94a3b8',
},
},
invalid: {
color: isDarkMode ? '#f87171' : '#dc2626',
iconColor: isDarkMode ? '#f87171' : '#dc2626',
},
},
}}
onChange={(e) => setCardComplete(e.complete)}
/>
</div>
{errorMessage && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{errorMessage}</p>
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={isProcessing}
className="flex-1 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={!stripe || isProcessing || !cardComplete}
className="flex-1 py-2.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.saving', 'Saving...')}
</>
) : (
<>
<CreditCard className="w-4 h-4" />
{t('billing.saveCard', 'Save Card')}
</>
)}
</button>
</div>
<p className="text-xs text-center text-gray-500 dark:text-gray-400 mt-2">
{t('billing.stripeSecure', 'Your payment information is securely processed by Stripe')}
</p>
</form>
);
};
interface AddPaymentMethodModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export const AddPaymentMethodModal: React.FC<AddPaymentMethodModalProps> = ({
isOpen,
onClose,
onSuccess,
}) => {
const { t } = useTranslation();
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [stripeAccount, setStripeAccount] = useState<string | null>(null);
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
const [error, setError] = useState<string | null>(null);
const createSetupIntent = useCreateSetupIntent();
// Detect dark mode for Stripe Elements appearance
const [isDarkMode, setIsDarkMode] = useState(() =>
document.documentElement.classList.contains('dark')
);
useEffect(() => {
// Watch for dark mode changes
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
if (isOpen && !clientSecret && !createSetupIntent.isPending) {
// Create SetupIntent when modal opens
createSetupIntent.mutate(undefined, {
onSuccess: (data) => {
console.log('[AddPaymentMethodModal] SetupIntent response:', {
client_secret: data.client_secret?.substring(0, 30) + '...',
setup_intent_id: data.setup_intent_id,
customer_id: data.customer_id,
stripe_account: data.stripe_account,
publishable_key: data.publishable_key ? data.publishable_key.substring(0, 20) + '...' : null,
});
// stripe_account can be empty string for direct_api mode, or acct_xxx for connect mode
// Only undefined/null indicates an error
if (data.stripe_account === undefined || data.stripe_account === null) {
console.error('[AddPaymentMethodModal] stripe_account is undefined/null - payment system may not be configured correctly');
setError(t('billing.paymentSystemNotConfigured', 'The payment system is not fully configured. Please contact support.'));
return;
}
setClientSecret(data.client_secret);
setStripeAccount(data.stripe_account);
// Load Stripe - empty stripe_account means direct_api mode (use tenant's publishable_key)
// Non-empty stripe_account means connect mode (use platform key with connected account)
setStripePromise(getStripeInstance(data.stripe_account, data.publishable_key));
},
onError: (err: any) => {
console.error('[AddPaymentMethodModal] SetupIntent error:', err);
setError(err.response?.data?.error || t('billing.setupIntentFailed', 'Failed to initialize. Please try again.'));
},
});
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setClientSecret(null);
setStripeAccount(null);
setStripePromise(null);
setError(null);
}
}, [isOpen]);
const handleSuccess = () => {
onSuccess?.();
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('billing.addPaymentMethod', 'Add Payment Method')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('billing.addPaymentMethodDescription', 'Save a card for future payments')}
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
{createSetupIntent.isPending ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-brand-600 mb-4" />
<p className="text-gray-600 dark:text-gray-400">
{t('common.loading', 'Loading...')}
</p>
</div>
) : error ? (
<div className="space-y-4">
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={() => {
setError(null);
createSetupIntent.mutate(undefined, {
onSuccess: (data) => {
setClientSecret(data.client_secret);
setStripeAccount(data.stripe_account);
setStripePromise(getStripeInstance(data.stripe_account, data.publishable_key));
},
onError: (err: any) => {
setError(err.response?.data?.error || t('billing.setupIntentFailed', 'Failed to initialize. Please try again.'));
},
});
}}
className="flex-1 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
{t('common.tryAgain', 'Try Again')}
</button>
</div>
</div>
) : clientSecret && stripePromise ? (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: isDarkMode ? 'night' : 'stripe',
variables: {
colorPrimary: '#2563eb',
colorBackground: isDarkMode ? '#1f2937' : '#ffffff',
colorText: isDarkMode ? '#f1f5f9' : '#1e293b',
colorDanger: isDarkMode ? '#f87171' : '#dc2626',
fontFamily: 'system-ui, -apple-system, sans-serif',
spacingUnit: '12px',
borderRadius: '8px',
},
},
}}
>
<CardFormInner
clientSecret={clientSecret}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</Elements>
) : null}
</div>
</div>
);
};
export default AddPaymentMethodModal;

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { X, AlertTriangle, CheckCircle, Info, AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type ModalVariant = 'info' | 'warning' | 'danger' | 'success';
interface ConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string | React.ReactNode;
confirmText?: string;
cancelText?: string;
variant?: ModalVariant;
isLoading?: boolean;
}
const variantConfig: Record<ModalVariant, {
icon: React.ReactNode;
iconBg: string;
confirmButtonClass: string;
}> = {
info: {
icon: <Info size={24} className="text-blue-600 dark:text-blue-400" />,
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
confirmButtonClass: 'bg-blue-600 hover:bg-blue-700 text-white',
},
warning: {
icon: <AlertTriangle size={24} className="text-amber-600 dark:text-amber-400" />,
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
confirmButtonClass: 'bg-amber-600 hover:bg-amber-700 text-white',
},
danger: {
icon: <AlertCircle size={24} className="text-red-600 dark:text-red-400" />,
iconBg: 'bg-red-100 dark:bg-red-900/30',
confirmButtonClass: 'bg-red-600 hover:bg-red-700 text-white',
},
success: {
icon: <CheckCircle size={24} className="text-green-600 dark:text-green-400" />,
iconBg: 'bg-green-100 dark:bg-green-900/30',
confirmButtonClass: 'bg-green-600 hover:bg-green-700 text-white',
},
};
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
cancelText,
variant = 'info',
isLoading = false,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
const config = variantConfig[variant];
const handleConfirm = () => {
onConfirm();
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md mx-4">
{/* Modal Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${config.iconBg}`}>
{config.icon}
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
</div>
<button
onClick={onClose}
disabled={isLoading}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Modal Body */}
<div className="p-6">
<div className="text-gray-600 dark:text-gray-300">
{typeof message === 'string' ? <p>{message}</p> : message}
</div>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
disabled={isLoading}
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{cancelText || t('common.cancel')}
</button>
<button
onClick={handleConfirm}
disabled={isLoading}
className={`px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center gap-2 ${config.confirmButtonClass}`}
>
{isLoading && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{confirmText || t('common.confirm')}
</button>
</div>
</div>
</div>
);
};
export default ConfirmationModal;

View File

@@ -4,6 +4,7 @@
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ExternalLink,
CheckCircle,
@@ -27,6 +28,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
tier,
onSuccess,
}) => {
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
const onboardingMutation = useConnectOnboarding();
@@ -53,7 +55,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
// Redirect to Stripe onboarding
window.location.href = result.url;
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to start onboarding');
setError(err.response?.data?.error || t('payments.failedToStartOnboarding'));
}
};
@@ -65,7 +67,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
// Redirect to continue onboarding
window.location.href = result.url;
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to refresh onboarding link');
setError(err.response?.data?.error || t('payments.failedToRefreshLink'));
}
};
@@ -73,13 +75,13 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return 'Standard Connect';
return t('payments.standardConnect');
case 'express':
return 'Express Connect';
return t('payments.expressConnect');
case 'custom':
return 'Custom Connect';
return t('payments.customConnect');
default:
return 'Connect';
return t('payments.connect');
}
};
@@ -91,9 +93,9 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800">Stripe Connected</h4>
<h4 className="font-medium text-green-800">{t('payments.stripeConnected')}</h4>
<p className="text-sm text-green-700 mt-1">
Your Stripe account is connected and ready to accept payments.
{t('payments.stripeConnectedDesc')}
</p>
</div>
</div>
@@ -103,14 +105,14 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
{/* Account Details */}
{connectAccount && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
<h4 className="font-medium text-gray-900 mb-3">{t('payments.accountDetails')}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Account Type:</span>
<span className="text-gray-600">{t('payments.accountType')}:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className="text-gray-600">{t('payments.status')}:</span>
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
connectAccount.status === 'active'
@@ -126,40 +128,40 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span>
<span className="text-gray-600">{t('payments.charges')}:</span>
<span className="flex items-center gap-1">
{connectAccount.charges_enabled ? (
<>
<CreditCard size={14} className="text-green-600" />
<span className="text-green-600">Enabled</span>
<span className="text-green-600">{t('payments.enabled')}</span>
</>
) : (
<>
<CreditCard size={14} className="text-gray-400" />
<span className="text-gray-500">Disabled</span>
<span className="text-gray-500">{t('payments.disabled')}</span>
</>
)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span>
<span className="text-gray-600">{t('payments.payouts')}:</span>
<span className="flex items-center gap-1">
{connectAccount.payouts_enabled ? (
<>
<Wallet size={14} className="text-green-600" />
<span className="text-green-600">Enabled</span>
<span className="text-green-600">{t('payments.enabled')}</span>
</>
) : (
<>
<Wallet size={14} className="text-gray-400" />
<span className="text-gray-500">Disabled</span>
<span className="text-gray-500">{t('payments.disabled')}</span>
</>
)}
</span>
</div>
{connectAccount.stripe_account_id && (
<div className="flex justify-between">
<span className="text-gray-600">Account ID:</span>
<span className="text-gray-600">{t('payments.accountId')}:</span>
<code className="font-mono text-gray-900 text-xs">
{connectAccount.stripe_account_id}
</code>
@@ -175,10 +177,9 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-yellow-800">Complete Onboarding</h4>
<h4 className="font-medium text-yellow-800">{t('payments.completeOnboarding')}</h4>
<p className="text-sm text-yellow-700 mt-1">
Your Stripe Connect account setup is incomplete.
Click below to continue the onboarding process.
{t('payments.onboardingIncomplete')}
</p>
<button
onClick={handleRefreshLink}
@@ -190,7 +191,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
) : (
<RefreshCw size={16} />
)}
Continue Onboarding
{t('payments.continueOnboarding')}
</button>
</div>
</div>
@@ -201,24 +202,22 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
{needsOnboarding && (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-800 mb-2">Connect with Stripe</h4>
<h4 className="font-medium text-blue-800 mb-2">{t('payments.connectWithStripe')}</h4>
<p className="text-sm text-blue-700">
As a {tier} tier business, you'll use Stripe Connect to accept payments.
This provides a seamless payment experience for your customers while
the platform handles payment processing.
{t('payments.tierPaymentDescription', { tier })}
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Secure payment processing
{t('payments.securePaymentProcessing')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Automatic payouts to your bank account
{t('payments.automaticPayouts')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
PCI compliance handled for you
{t('payments.pciCompliance')}
</li>
</ul>
</div>
@@ -233,7 +232,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
) : (
<>
<ExternalLink size={18} />
Connect with Stripe
{t('payments.connectWithStripe')}
</>
)}
</button>
@@ -259,7 +258,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
>
<ExternalLink size={14} />
Open Stripe Dashboard
{t('payments.openStripeDashboard')}
</a>
)}
</div>

View File

@@ -20,6 +20,7 @@ import {
Wallet,
Building2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
interface ConnectOnboardingEmbedProps {
@@ -37,6 +38,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
onComplete,
onError,
}) => {
const { t } = useTranslation();
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -68,7 +70,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
colorDanger: '#df1b41',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSizeBase: '14px',
spacingUnit: '4px',
spacingUnit: '12px',
borderRadius: '8px',
},
},
@@ -78,12 +80,12 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
setLoadingState('ready');
} catch (err: any) {
console.error('Failed to initialize Stripe Connect:', err);
const message = err.response?.data?.error || err.message || 'Failed to initialize payment setup';
const message = err.response?.data?.error || err.message || t('payments.failedToInitializePayment');
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}
}, [loadingState, onError]);
}, [loadingState, onError, t]);
// Handle onboarding completion
const handleOnboardingExit = useCallback(async () => {
@@ -100,23 +102,23 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
// Handle errors from the Connect component
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
console.error('Connect component load error:', loadError);
const message = loadError.error.message || 'Failed to load payment component';
const message = loadError.error.message || t('payments.failedToLoadPaymentComponent');
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}, [onError]);
}, [onError, t]);
// Account type display
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return 'Standard Connect';
return t('payments.standardConnect');
case 'express':
return 'Express Connect';
return t('payments.expressConnect');
case 'custom':
return 'Custom Connect';
return t('payments.customConnect');
default:
return 'Connect';
return t('payments.connect');
}
};
@@ -124,43 +126,43 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (isActive) {
return (
<div className="space-y-6">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
<CheckCircle className="text-green-600 dark:text-green-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800">Stripe Connected</h4>
<p className="text-sm text-green-700 mt-1">
Your Stripe account is connected and ready to accept payments.
<h4 className="font-medium text-green-800 dark:text-green-300">{t('payments.stripeConnected')}</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-1">
{t('payments.stripeConnectedDesc')}
</p>
</div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">{t('payments.accountDetails')}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Account Type:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.accountType')}:</span>
<span className="text-gray-900 dark:text-white">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">
<span className="text-gray-600 dark:text-gray-400">{t('payments.status')}:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
{connectAccount.status}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span>
<span className="flex items-center gap-1 text-green-600">
<span className="text-gray-600 dark:text-gray-400">{t('payments.charges')}:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CreditCard size={14} />
Enabled
{t('payments.enabled')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span>
<span className="flex items-center gap-1 text-green-600">
<span className="text-gray-600 dark:text-gray-400">{t('payments.payouts')}:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Wallet size={14} />
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
{connectAccount.payouts_enabled ? t('payments.enabled') : t('payments.pending')}
</span>
</div>
</div>
@@ -172,11 +174,11 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
// Completion state
if (loadingState === 'complete') {
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 mb-3" size={48} />
<h4 className="font-medium text-green-800 text-lg">Onboarding Complete!</h4>
<p className="text-sm text-green-700 mt-2">
Your Stripe account has been set up. You can now accept payments.
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 dark:text-green-400 mb-3" size={48} />
<h4 className="font-medium text-green-800 dark:text-green-300 text-lg">{t('payments.onboardingComplete')}</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-2">
{t('payments.stripeSetupComplete')}
</p>
</div>
);
@@ -186,12 +188,12 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'error') {
return (
<div className="space-y-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="text-red-600 shrink-0 mt-0.5" size={20} />
<AlertCircle className="text-red-600 dark:text-red-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-red-800">Setup Failed</h4>
<p className="text-sm text-red-700 mt-1">{errorMessage}</p>
<h4 className="font-medium text-red-800 dark:text-red-300">{t('payments.setupFailed')}</h4>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{errorMessage}</p>
</div>
</div>
</div>
@@ -200,9 +202,9 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
setLoadingState('idle');
setErrorMessage(null);
}}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Try Again
{t('payments.tryAgain')}
</button>
</div>
);
@@ -212,27 +214,26 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'idle') {
return (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<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 items-start gap-3">
<Building2 className="text-blue-600 shrink-0 mt-0.5" size={20} />
<Building2 className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-blue-800">Set Up Payments</h4>
<p className="text-sm text-blue-700 mt-1">
As a {tier} tier business, you'll use Stripe Connect to accept payments.
Complete the onboarding process to start accepting payments from your customers.
<h4 className="font-medium text-blue-800 dark:text-blue-300">{t('payments.setUpPayments')}</h4>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
{t('payments.tierPaymentDescriptionWithOnboarding', { tier })}
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700">
<ul className="mt-3 space-y-1 text-sm text-blue-700 dark:text-blue-400">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Secure payment processing
{t('payments.securePaymentProcessing')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Automatic payouts to your bank account
{t('payments.automaticPayouts')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
PCI compliance handled for you
{t('payments.pciCompliance')}
</li>
</ul>
</div>
@@ -244,7 +245,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] transition-colors"
>
<CreditCard size={18} />
Start Payment Setup
{t('payments.startPaymentSetup')}
</button>
</div>
);
@@ -255,7 +256,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
return (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
<p className="text-gray-600">Initializing payment setup...</p>
<p className="text-gray-600 dark:text-gray-400">{t('payments.initializingPaymentSetup')}</p>
</div>
);
}
@@ -264,15 +265,14 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'ready' && stripeConnectInstance) {
return (
<div className="space-y-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">Complete Your Account Setup</h4>
<p className="text-sm text-gray-600">
Fill out the information below to finish setting up your payment account.
Your information is securely handled by Stripe.
<div className="bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('payments.completeAccountSetup')}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('payments.fillOutInfoForPayment')}
</p>
</div>
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white p-4">
<div className="border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-800 p-4">
<ConnectComponentsProvider connectInstance={stripeConnectInstance}>
<ConnectAccountOnboarding
onExit={handleOnboardingExit}

Some files were not shown because too many files have changed in this diff Show More